rolter documentation
Start here. rolter is a high-performance OpenAI/Anthropic-compatible AI gateway and load balancer (Rust data plane + control plane, shadcn/ui dashboard).
Architecture
- Overview — system shape, crates, data/control plane split
- Load balancing — strategies and the cache-aware design
- Caching — response cache and KV-cache affinity
- Provider prompt caching — portable Anthropic prompt-cache controls
- Multi-region deployment — active-active/passive state and failover topology
- Config & hot reload — reload-free updates
- Data model — tenancy, keys, pricing, budgets
- RBAC & auth — roles, virtual keys, membership sources
- Invitations — one-time onboarding links for deployments with no IdP
- Single sign-on (OIDC) — optional OIDC login and group→role mapping
- MCP OAuth — MCP servers, consent grants, sealed token sessions
- SCIM provisioning — IdP-driven user lifecycle and provisioning tokens
- Security — secret handling, threat model
- Observability — metrics, tracing, logs
- Performance — hot-path principles and targets
Decisions
API
Development
Deployment
- Zero-config quickstart — start with no keys, add providers at runtime
- Configuration reference
- Custom CA bundles — trust private upstream PKI without disabling TLS verification
- Docker
Planning
Architecture overview
rolter is split into a data plane (the hot proxy path) and a control plane (management, RBAC, config writes, UI host), sharing a set of Rust library crates. This separation lets the proxy stay lean and fast while the control plane handles CRUD, auth, and persistence.
Components
flowchart LR
subgraph clients[Clients]
C([OpenAI / Anthropic SDKs])
A([Dashboard user])
end
C -->|/v1/*| GW["rolter-gateway<br/>data plane (Axum)"]
A --> CTL["rolter-control<br/>control plane + UI host"]
GW -->|forward + stream| UP["Upstreams<br/>OpenAI · Anthropic · vLLM pool"]
CTL -->|read/write| PG[("PostgreSQL<br/>source of truth")]
CTL -->|publish change events| RDS[("Redis / Valkey")]
RDS -->|notify| GW
GW -->|atomic snapshot swap| GW
GW -->|async batched writes| CH[("ClickHouse<br/>logs + cost")]
Crates
rolter-core— config model, domain types, error type, telemetry init. No I/O.rolter-balancer—LoadBalancertrait + strategies; pure and unit-tested.rolter-proxy—Forwarderover pooledreqwestclients; header injection, model rewrite, streaming, per-provider egress proxy.rolter-store— repository traits + in-memory impl; Postgres/Redis/ClickHouse backends land behind features.rolter-auth— virtual-key verification, roles, access checks.rolter-gateway(bin) — the data plane.rolter-control(bin) — the control plane + static UI host.
Data plane (rolter-gateway)
Built on Axum/Hyper/Tower. Holds an ArcSwap<Snapshot> routing table; reads are lock-free. Per request it:
- parses the JSON body enough to read
modelandstream - authenticates the virtual key (when keys are configured) and checks model access
- resolves the route, asks the route’s balancer to
picka target, thenobserves it (cache-aware learning) - forwards to the upstream provider with the right auth header, rewriting the model id
- streams the response body straight back with minimal copying
See data plane details in performance.md.
Control plane (rolter-control)
Hosts the management REST API consumed by the dashboard and serves the built SPA as static assets (ServeDir). It owns all writes to Postgres, enforces RBAC, and publishes a config-change event to Redis so gateways hot-swap their snapshot without a restart (see config-and-hot-reload.md).
Datastores
- PostgreSQL — source of truth for tenancy, RBAC, providers, routes, virtual keys, pricing, budgets.
- Redis / Valkey — response cache, rate-limit counters, cooldown state, and pub/sub for config propagation.
- ClickHouse — high-volume request and cost logs, queried by the dashboard for usage analytics.
Request lifecycle (sequence)
sequenceDiagram participant Client participant Gateway participant Balancer participant Upstream participant ClickHouse Client->>Gateway: POST /v1/chat/completions Gateway->>Gateway: auth virtual key + model access Gateway->>Balancer: pick(route, ctx) Balancer-->>Gateway: target index Gateway->>Upstream: forward (Bearer/x-api-key, rewritten model) Upstream-->>Gateway: response stream Gateway-->>Client: streamed body Gateway--)ClickHouse: async usage + cost log
Extensibility
- New balancing strategy: implement
rolter_balancer::LoadBalancer, wire intobuild(). - New provider protocol: extend
ProviderKindandrolter-proxytranslation. - New storage backend: implement
rolter-storetraits behind a cargo feature. - New modality (audio/image/video): add endpoints + provider adapters; the balancer/auth/logging layers are modality-agnostic.
Load balancing
Each route maps a public model name to one or more upstream targets and a strategy. Strategies implement rolter_balancer::LoadBalancer:
#![allow(unused)]
fn main() {
pub trait LoadBalancer: Send + Sync {
fn name(&self) -> &'static str;
fn pick(&self, ctx: &RouteContext, loads: &[u64]) -> Option<usize>;
fn observe(&self, target: usize, ctx: &RouteContext) {}
}
}
pick returns an index into the route’s targets; observe lets learning strategies (cache-aware) record what a target served. RouteContext carries an optional session_key (from x-session-id) and the request prompt used for affinity scoring.
Strategies (v1)
- round_robin — sequential rotation; predictable, zero state.
- random — uniform random; good for simple homogeneous pools.
- power_of_two — pick the less loaded of two random targets; needs a load snapshot.
- consistent_hash — hash-ring keyed by
session_key(falls back to prompt hash); pins a session/user to a target for KV reuse, survives target changes with minimal reshuffle (160 vnodes). - cache_aware — approximate prefix affinity; see caching.md.
- weighted — smooth weighted round-robin honouring each target’s
weight. - pipeline — composable filter → weighted-score → argmax selection: eligibility filtering drops ineligible targets, then a stack of
Scorers (session affinity + static weight + in-flight load + prefix-cache affinity) is combined as a weighted sum and the argmax wins (ties broken randomly). Session affinity pins repeat requests from the samex-session-idto their last-served target (TTL-bounded) for warm-cache reuse. The extension point every future cost/latency/KV-cache scorer plugs into. - precise_cache_aware — consumes each target’s vLLM ZMQ KV-event stream and scores the exact leading fraction of caller-supplied token blocks resident on that target. Missing token ids and stale, malformed, disconnected, or sequence-gapped streams stay neutral; least-load routing remains the fallback.
- lmcache_aware — polls each target’s configured LMCache controller signal and prefers available caches with free capacity (
1 - occupancy). Empty, saturated, failed, and stale controllers stay neutral and fall back to least load. - adaptive — a weighted blend of observed latency, catalog cost and in-flight load, governed by the deployment-wide
[adaptive_routing]policy. See below. - predicted_latency — rank targets by what this request is modelled to cost on each of them, from the queue it would join and its own prompt size, rather than by a per-target average. See below.
- lora_aware — LoRA-adapter affinity for a fleet serving many adapters over shared base weights: prefer a target that already holds the requested adapter resident, with prefix affinity and in-flight load behind it. See below.
Adaptive routing
adaptive is the only strategy whose behavior is owned by a global policy rather than the route:
[adaptive_routing]
enabled = false # kill switch; the whole feature is off by default
latency_weight = 1.0
cost_weight = 0.5
load_weight = 0.25
exploration_ratio = 0.05 # clamped to [0, 0.5]
min_samples = 50
Three conditions must all hold before the blend routes a single request: the kill switch is on, at least one weight is non-zero, and the route has both served min_samples requests and gathered latency samples for at least two targets. Until then — and immediately again if the policy is switched off — every pick goes to the same pipeline stack the route would have used otherwise, so moving a route to adaptive shifts no traffic on its own. The fallback stack keeps learning while the blend is engaged, so disengaging lands on a warm session/prefix cache.
The policy is also owned by the control plane: GET/PUT /api/v1/adaptive-routing-policy (superadmin only) persists it, audits the change as adaptive_routing_policy.update, and returns the affected_routes the change reaches. It travels to the data plane in the normal snapshot, so a change applies without a restart. The API refuses an all-zero blend outright — stopping adaptive routing is what enabled = false is for.
Once engaged, an exploration_ratio share of picks is made uniformly at random so a target the blend has learned to avoid keeps producing fresh latency samples instead of going dark. Operator input is clamped on the way in: negative weights become zero and exploration never exceeds half the traffic.
Telemetry
The scores the blend ranks on live in the balancer, which lives in the gateway process, so the control plane cannot read them directly. Each gateway therefore pushes a sample every 15s to POST /internal/adaptive-telemetry on the control plane — the same internal token and the same x-rolter-node-id identity as the snapshot poll, so one node is one row here and in the cluster inventory (#543). Nothing is written on the request path: the sample is taken by a background task, and a pick costs two relaxed atomics for per-target attribution.
Per route and target the sample carries the blended score, its latency/cost/load components, the raw signals behind them (smoothed latency in ms, catalog price, in-flight count), how many picks that target has served and how long ago the last one was — plus the decision split, and the sanitized policy that node actually runs, which can lag the stored policy until the node converges.
The control plane keeps the newest sample per (node, model) in adaptive_routing_telemetry and serves GET /api/v1/adaptive-routing-telemetry (superadmin only), grouped by route with one entry per reporting node. Samples older than 60s are excluded as no longer current, and rows are pruned after an hour so a scaled-down node leaves the scoreboard.
This is deliberately current state, not history: one row per node and route, overwritten on every report, so the table is the size of the fleet rather than of the traffic. A time series belongs in the request log, and would be a separate endpoint rather than a change to this one. Prometheus deployments get the same per-target scores without the dashboard from rolter_adaptive_routing_target_score{model,target}.
LoRA-aware routing
For a fleet serving many LoRA adapters off shared base weights, routing a
request to a node that already holds the adapter is the same class of win as
prefix-cache affinity (#853, borrowed from llm-d). lora_aware composes with
the existing scorers rather than replacing them:
| Scorer | Weight |
|---|---|
| adapter residency | 1.0 |
| prefix affinity | 0.5 |
| in-flight load | 0.25 |
Adapter residency outranks prefix affinity because the costs are not comparable: missing a warm prefix recomputes some tokens, while missing a resident adapter can force the engine to load adapter weights before it decodes anything at all. Load stays in the stack so a fleet where every target holds the adapter still balances instead of pinning.
Residency is learned from traffic, not declared. rolter cannot see which
adapters an engine currently holds, and a static declaration would go stale the
moment the engine evicted one. Each target keeps a bounded LRU set of the
adapters it has recently served, sized to mirror vLLM’s --max-loras, so it
tracks what the engine plausibly still holds rather than everything it has ever
served. An unbounded “has ever served” set would be worse than no scoring at
all, because it steers confidently to a target that went cold long ago.
Adapter identity is the requested model, which is how vLLM addresses adapters over shared base weights. The gateway only sets it when the request addresses something other than the route’s own model — that is, a passthrough provider-group route (ADR-0017). On a single-model route the two are equal, no adapter is set, and the scorer is inert. This matters: adapter affinity deliberately pins rather than spreads, so treating a route’s one model as an adapter would pin the entire route to whichever target happened to serve first.
When no candidate holds the adapter — a cold adapter, or a request with none —
every candidate scores 0.0. That is neutral rather than a penalty: an equal
contribution cannot move the argmax, so the rest of the pipeline decides and
this scorer stays silent instead of guessing.
Predicted-latency scheduling
fastest ranks on a per-target latency EWMA. That is the right signal when
every request is the same size, and the wrong one otherwise: a target whose
average is high because it happened to serve the long prompts looks slow even
when it is the emptiest box in the fleet, and a target with a deep queue looks
fast right up until the queue is what you join.
predicted_latency models the cost instead of averaging it (#853, borrowed
from llm-d). Per target:
latency_ms ≈ w0 + w1 · queue_depth + w2 · prompt_ktokens
w0 is fixed overhead, w1 is what one queued request ahead of you costs, and
w2 is prefill cost per thousand prompt tokens. The three coefficients are
learned online from completed requests with normalized least mean squares — one
multiply-add per feature per sample, no matrix, no allocation, no periodic
refit.
| Scorer | Weight |
|---|---|
| predicted latency | 1.0 |
| in-flight load | 0.25 |
Load stays in the stack for two reasons: it carries the route while the models are cold, and once they are warm it is the tiebreaker between targets the model rates equally — the common case on a homogeneous fleet, where the honest answer is “either, take the emptier”.
Three features, not more. A gateway sees queue depth and prompt size. It does not see batch composition, KV-cache pressure, or where the engine is in its scheduling loop, and a model with parameters it cannot observe fits noise. The interesting error is between “the queue is deep” and “the prompt is long”, and a linear model separates those. That is the honest ceiling for this vantage point.
A cold target predicts nothing. Below 8 completed requests a target returns
no prediction, and the scorer reads that as unknown, not as slow. A route
that switches to predicted_latency therefore behaves exactly like the
least-load pipeline until the models have evidence, so the switch itself moves
no traffic.
Only successful requests teach. The same gate the latency EWMA uses: a fast failure cannot train a target into looking cheap. Coefficients are clamped, so a client that held a stream open for a week produces one bad sample rather than a permanently poisoned model.
Queue depth is read before the increment, so the model learns the queue a request joined, not the one it created.
Models live in the load tracker, not the routing snapshot, for the same reason the latency EWMA does: a config reload must not throw away what they learned, or every reload would send the route back to cold behaviour. A route that gains a target keeps its existing models and the new target reads as unpredicted until the next process start — the conservative direction, since a target with no evidence should not be ranked.
Choosing a strategy
| Use case | Strategy |
|---|---|
| Homogeneous pool, stateless | round_robin / random |
| Variable request durations | power_of_two |
| Multi-turn chat, sticky session | consistent_hash |
| Shared system prompts / few-shot / RAG | cache_aware |
| Blend cache + load + weight signals | pipeline |
| vLLM fleet with KV event publishing | precise_cache_aware |
| LMCache fleet with occupancy controller | lmcache_aware |
| Mixed-price providers, minimize spend | cheapest |
| Heterogeneous pool, minimize latency | fastest |
| Mixed price and latency, let the gateway tune | adaptive |
| Many LoRA adapters over shared base weights | lora_aware |
| Heterogeneous pool, variable prompt sizes, deep queues | predicted_latency |
Both external strategies perform network I/O only in background tasks. The request hot path reads bounded in-process state and atomics.
Selecting a strategy from the dashboard
The route and provider-group editors offer every strategy in this table except adaptive, which is governed by the deployment-wide [adaptive_routing] policy and has its own screen — a per-route dropdown would misrepresent how it is controlled. precise_cache_aware and lmcache_aware are offered with a hint that they need a telemetry source on the target providers, since without one they fall back to least-load silently rather than failing.
A picker always renders the value the route or group already holds, even one it would not otherwise offer — including a strategy set from rolter.toml or the API, or one added to the backend allowlist ahead of the dashboard. A native <select> whose value matches no option displays the first option instead, so before #897 a group balanced by adaptive read as round_robin. Whatever the menu chooses to offer, editing must never rewrite a strategy the operator did not touch.
Caching
rolter deals with two distinct kinds of caching.
1. KV-cache affinity (load balancing)
The big win for self-hosted fleets: vLLM/SGLang reuse the attention KV cache for shared prompt prefixes (system prompts, few-shot examples, conversation history). But that only helps if the next matching request lands on the same replica. Naive round-robin scatters related requests and destroys cache locality.
The cache_aware strategy keeps, per target, a byte trie of prompts it has served. For an incoming prompt it computes the fraction of leading bytes already present on each target and:
- if the best match ≥
threshold(default0.5), pins the request to that target (cache hit) - otherwise spreads to the least-warmed target (or least loaded once load is wired)
This is approximate (no coupling to the engine). The per-target trie is capped at a node ceiling (default 1M nodes) with LRU eviction: inserting past the cap drops the least-recently-inserted prompt, pruning only the nodes that become unreferenced (shared prefixes survive). Each trie exposes an eviction counter for observability.
flowchart TD
R[incoming prompt] --> S{best prefix match >= threshold?}
S -- yes --> P[pin to best target<br/>cache hit]
S -- no --> L[least-warmed / least-loaded target]
P --> O[observe: insert prompt into target trie]
L --> O
Precise vLLM mode
Set a provider’s [providers.kv_events] block and choose strategy = "precise_cache_aware". rolter supports the vLLM V1 msgpack KVEventBatch protocol documented for vLLM 0.22: a three-frame ZMQ publication (topic, big-endian sequence, payload) containing tagged BlockStored, BlockRemoved, and AllBlocksCleared events. BlockStored.token_ids and block_size derive stable local prefix identities; external block hashes allow exact removals. State is capped by max_blocks per provider.
Exact scoring needs the token ids produced by the same tokenizer as vLLM. Send them in x-rolter-vllm-token-ids as comma-separated unsigned integers. Without the header, with stale/malformed events, or after a sequence gap, the scorer is neutral and least-load routing takes over. A sequence gap clears the local index and precise scoring remains disabled until an AllBlocksCleared event establishes a clean boundary.
[[providers]]
name = "vllm-a"
kind = "openai_compatible"
api_base = "http://vllm-a:8000"
[providers.kv_events]
endpoint = "tcp://vllm-a:5557"
topic = "kv-events"
max_blocks = 1000000
stale_secs = 30
vLLM must enable KV events with the ZMQ publisher and matching topic. Metrics expose consumed/malformed events, stream failures, decision count, and per-provider freshness.
LMCache-aware mode
Set [providers.lmcache] and use strategy = "lmcache_aware". The supported controller signal is an HTTP 200 JSON object:
{"occupancy": 0.42, "cache_available": true}
occupancy is clamped to [0,1]; available targets score 1 - occupancy, unavailable targets score zero. Polling happens in the background. Failed, malformed, or stale signals are neutral, so existing least-load routing continues.
[providers.lmcache]
endpoint = "http://lmcache-a:9000/v1/occupancy"
refresh_secs = 2
stale_secs = 10
2. Response cache
Optional caching of full responses to cut cost/latency for repeated requests:
- exact: hash of the normalized request → cached response (Redis), short TTL, opt-in per route/key.
- semantic: after an exact miss, embed the normalized prompt through a configured provider and compare cosine similarity against a bounded recent-entry window in Redis. The route controls the threshold and candidate cap. Embedding, Redis, and decode failures fail open to normal routing.
Streaming responses are cached on completion and replayed as a synthetic stream. Cache status is surfaced via response headers (e.g. x-rolter-cache: hit|miss).
[cache]
enabled = true
[routes.cache]
enabled = true
[routes.cache.semantic]
provider = "openai"
model = "text-embedding-3-small"
threshold = 0.92
max_candidates = 256
Configuration & hot reload
A core requirement: operators change routes, providers, keys, limits and pricing from the UI and have them take effect without restarting the gateway.
Sources of config
- Bootstrap file (
rolter.toml) — used for first run, local dev, and IaC. Maps torolter_core::GatewayConfig. - Database (Postgres) — the runtime source of truth once the control plane is running. The control plane composes a
GatewayConfig-equivalent snapshot from normalized tables.
Propagation
sequenceDiagram
participant UI
participant Control as rolter-control
participant PG as PostgreSQL
participant Redis
participant GW as rolter-gateway
UI->>Control: PUT /api/v1/routes/... (RBAC checked)
Control->>PG: write change in a transaction
Control->>PG: bump config_version.version
Control->>Redis: PUBLISH rolter.config {version}
Redis-->>GW: message {version}
GW->>Control: GET /internal/snapshot?version=N
Control-->>GW: full snapshot (JSON)
GW->>GW: build Snapshot, ArcSwap::store (atomic)
- The gateway keeps the routing table in an
ArcSwap<Snapshot>. Swapping is atomic and wait-free for readers — in-flight requests keep using the old snapshot; new requests see the new one. - Versioning:
config_versionin Postgres is the monotonic source of truth. The gateway also reconciles on an interval (and at startup) so a missed pub/sub message self-heals. - Validation: the control plane validates a snapshot (every route target references a known provider, etc.) before bumping the version, so gateways never load a broken config.
- Per-row resilience: validation used to be all-or-nothing, so a single half-built row 500’d
/internal/snapshotand froze config propagation for every tenant. Rows that are unservable only by themselves are now omitted from the snapshot instead. That covers both a route with no usable target (created before its targets are added, or pointing at a deleted provider) and a provider whose own definition is invalid — a malformedapi_base, a hosted adapter missing itsapi_key_env. Dropping a provider drops the routes that depended on it, since they are then left with no resolvable target. Structural problems that span rows (duplicate names, a badmetrics_path, unreadable CA bundles) still fail the whole snapshot, since silently dropping one of two colliding rows would be worse than refusing. - Saying what was dropped (#926): omitting an entry quietly is its own failure mode — every gateway keeps serving its last good config, so nothing looks broken until a gateway restarts cold or someone wonders why a change never took effect. So the reasons travel with the config:
/internal/snapshotcarries aproblems: [...]array alongsideversionandconfig, present only when something was dropped. It is absent from a healthy snapshot, which is what every gateway in the fleet transfers on every poll.- the gateway logs them once per change, at the point it applies a new version, rather than once per poll — a fleet polling every 5s would otherwise repeat the same complaint thousands of times a day. An older control plane sends no such field, so it defaults rather than failing the decode during a rollout.
- the dashboard reads
GET /api/v1/config/problemsand shows them on the Providers screen. That endpoint runs the same computation the snapshot does, so the two cannot disagree; it also reports structural problems, which never reach a gateway at all.
Why this design
- Redis pub/sub gives near-instant fan-out to many gateway replicas.
- Postgres versioning makes the system correct even if Redis drops a message.
ArcSwapkeeps the hot path lock-free; no read ever blocks on a config write.
Alternatives considered: Postgres LISTEN/NOTIFY (avoids a Redis dependency but Redis is already needed for cache/rate limits), and pure polling (simplest, higher latency). See ADR.
Data model
PostgreSQL is the source of truth. The initial schema lives in migrations/0001_init.sql; ClickHouse log schema in clickhouse/001_logs.sql.
Tenancy
erDiagram
orgs ||--o{ teams : has
teams ||--o{ projects : has
projects ||--o{ routes : has
routes ||--o{ route_targets : has
projects ||--o{ virtual_keys : has
orgs ||--o{ providers : has
providers ||--o{ provider_keys : has
users ||--o{ memberships : has
orgs ||--o{ mcp_servers : registers
users ||--o{ mcp_oauth_grants : consents
mcp_servers ||--o{ mcp_oauth_grants : authorizes
orgs ||--o{ mcp_tool_groups : governs
orgs ||--o| mcp_gateway_settings : configures
mcp_oauth_grants ||--o{ mcp_oauth_sessions : issues
- Org → Team → Project → Virtual Key is the hierarchy. Budgets and rate limits attach at any scope and combine most-restrictive-wins.
- Providers are owned at the org level and referenced by route targets. Upstream credentials live in
provider_keys, envelope-encrypted (see security.md). - Routes belong to a project and map a public
modeltoroute_targetswith astrategy. - Virtual keys belong to a project, store only a hash of the key plus a display prefix, and carry an optional model allow-list.
- MCP servers belong to an org and declare required OAuth scopes and exposed tool names. Only enabled servers reach gateway snapshots. Grants bind a user to a server; sealed token sessions belong to a grant. MCP tool groups persist named server/tool manifests, while MCP gateway settings hold organization defaults for registration and MCP-aware clients; neither is currently a request-path authorization boundary.
Cost & limits
model_prices— USD per million tokens for input/output (+ cached input). Used to computecost_usdper request, written to ClickHouse.budgets— spend caps per scope and period; enforced before forwarding and refreshed from spend aggregates.rate_limits— RPM/TPM per scope; counters live in Redis for multi-instance correctness.
Config versioning
config_version holds a single monotonic counter the gateways watch for reload-free updates (config-and-hot-reload.md). audit_log records who changed what.
The RBAC tables are split on exactly this question. access_profile_policies, access_profile_assignments, access_profiles and memberships all carry a bump_config_version() trigger, because the gateway resolves the model/route policy of each virtual key’s owner from them and enforces it on the request path (#791, ADR-0023). custom_roles and custom_role_grants do not and must not: they decide control-plane authorization, which is evaluated live per request, so a bump would wake the fleet for a change it cannot observe.
plugin_instances is the control-plane registry for desired request/response middleware configuration. A row belongs to an org and may narrow to a project; slugs are unique within that scope. Endpoint credentials are environment-variable references and config is always a JSON object. The table deliberately has no bump_config_version() trigger until the allocation-light gateway dispatcher in #509 consumes it—an enabled registry row is configuration, not a false claim that middleware is running.
The guardrail registry is deployment-wide:
guardrail_rulesstores the ordered built-in and bounded-regex policy managed by the dashboard. Exactly one ofbuiltinorpatternis present on every row.guardrail_providersstores external webhook endpoints and environment-variable credential references. A partial unique index permits at most one enabled provider because the gateway exposes one vendor-neutral webhook contract.
Both tables bump config_version in the write transaction. File-owned rules remain immutable and win name collisions; database rules extend that policy. An enabled file-owned webhook remains authoritative, otherwise the active registry provider supplies the snapshot webhook.
Several deployment-wide settings are stored as singleton tables: one row keyed by id boolean primary key default true check (id), seeded by their own migration, so a read never has to handle “not configured yet”. runtime_policy, compatibility_policy, security_settings, logging_settings, client_settings and model_defaults all follow this shape and all bump config_version in the write transaction.
Two of them landed with the Settings screens (#564):
client_settings— the base URL the dashboard advertises (advisory; the gateway never reads it), the allowlist of inbound client headers forwarded to the upstream provider, the static headers the gateway injects on every upstream request, and the request-id header. Trace-context headers propagate independently of the allowlist, and injected header values are treated as credential material: they reach the gateway through the snapshot but never the audit log, which records only the names.model_defaults— anenabledkill switch plus optionaldefault_model,default_temperature,default_top_panddefault_max_tokens. Defaults only ever fill a key the request omitted, so the table can be populated without changing the meaning of any request that was already explicit.default_temperatureanddefault_top_paredouble precision, notreal: anf32default serializes into JSON as0.800000011920929and that is what would reach the provider.
Data written by the data plane
Most tables flow control plane → gateway. Two flow the other way, written from the channel the gateway already holds and never read back by it:
cluster_nodes— one row per node, upserted from the snapshot poll (#543).adaptive_routing_telemetry— one row per(node, model)holding the newest adaptive-routing sample that node pushed (#751): the decision split, the sanitized policy it runs, and a JSON document of per-target scores and signals. A scoreboard, not history — every report overwrites the row, so the table is the size of the fleet rather than of the traffic.
Neither carries a bump_config_version() trigger, and neither may grow one: the data plane does not consume them, and a version bump on every heartbeat or sample would make routine bookkeeping look like a config change and wake the whole fleet.
ClickHouse tables
PostgreSQL holds configuration; ClickHouse holds the high-volume append-only
streams, all partitioned by day with a 90-day TTL and written in batches off the
hot path. Schema lives in clickhouse/, applied by the
container’s init directory:
request_logs— one row per proxied request, with cost and token counts.provider_health_events— per-target health observations from every signal.mcp_tool_call_logs— MCP tool invocations.ui_events— dashboard UX events (#805): screen views and time-to-interactive, navigation and back-outs, form submit/abandon and which validation rules fire, empty- and error-state impressions, save-to-confirmation latency. Carriestrace_id, so a UX event and the gateway request it caused are one join apart.
ui_events is structurally incapable of holding content: every column is a
key, an enum, a duration or an id, so “no form values, no prompt text” is a
property of the schema rather than a policy applied on write. None of these
tables is read by the data plane, so none carries a bump_config_version()
trigger.
Migrations are append-only
Migrations are embedded with sqlx::migrate! and sqlx stores a checksum of every
migration it applies. If a shipped file’s bytes change — even a stripped trailing
newline, as a formatting hook once did in #710 — every database that already ran it
refuses to start on its next upgrade:
Error: store error: migration 18 was previously applied but has been modified
CI databases always start empty, so nothing else catches this: the change merges
green and breaks deployments later. Never edit, delete or rename a file under
crates/rolter-store/migrations/; add a new NNNN_*.sql instead. Numbers are
append-only and never reused, even where the sequence has a gap.
scripts/check-migrations-immutable.sh enforces this. It runs as the
migrations append-only job in quality.yml (inside the ci-ok gate) and as a
prek hook locally, rejecting any modified, deleted or renamed migration relative
to the branch’s fork point from master.
Mapping to the gateway
The control plane composes the normalized tables into the same shape as rolter_core::GatewayConfig (providers, routes, virtual keys and authorized MCP sessions), which the gateway turns into an immutable Snapshot.
RBAC & authentication
Two distinct auth surfaces:
1. Gateway (data plane) — virtual keys
Clients call /v1/* with a virtual key (Authorization: Bearer <key> or x-api-key). The gateway:
- looks the key up in the current snapshot
- checks the key’s model allow-list (empty = all)
- (roadmap) enforces budgets and RPM/TPM limits for the key’s scope chain
Keys are stored as hashes; the presented key is compared in constant time (rolter_auth::verify_key).
Empty key sets
An empty effective key set does not mean “auth disabled” on a managed
deployment. A gateway started with --snapshot-url (its config comes from the
control plane) fails closed: with no virtual keys in the snapshot every
/v1/* request gets 401. This keeps revoking the last key a lock-down rather
than an accidental opening of the whole data plane.
A gateway running from a static bootstrap config with no keys stays keyless, so
local fake-llm development needs no setup.
Override either default with server.require_auth:
| value | behaviour on an empty key set |
|---|---|
true | always deny (401) |
false | always allow the keyless path |
| unset (default) | managed → deny, static local config → allow |
2. Control plane (dashboard) — users + roles
Human users authenticate to the control plane. Two providers ship today: local accounts (argon2id password hashes, rolter_control::auth) and OAuth2/OIDC SSO (rolter_control::sso, authorization-code flow with PKCE, JWKS-verified id tokens, IdP group → role mapping). RBAC roles:
- admin — full control within scope (manage providers, routes, keys, members, budgets)
- member — create/edit routes and keys within scope
- viewer — read-only (dashboards, logs)
Roles are granted via memberships at an org / team / project scope. Permission checks resolve the most specific membership for the target resource.
flowchart LR
U[User] -->|member of| Scope[org / team / project]
Scope -->|role| Caps[admin / member / viewer]
Caps --> Action{allowed?}
The capability table is the only source of truth
CAPABILITIES in crates/rolter-control/src/rbac_matrix.rs records, for every resource, what each of read / create / update / delete takes — a minimum scoped role, superadmin-only, or that the resource has no such action at all. Both the published matrix and the guard read it, so they cannot drift.
A guarded handler names a (resource, action) pair instead of a role:
#![allow(unused)]
fn main() {
authorize(&state, &principal, ScopeChain::org(org_id), cap!("provider", Create)).await?;
// deployment-wide, no scope to hold a role in:
authorize_superadmin(&principal, superadmin_cap!("feature_flags", Update))?;
}
cap! resolves the requirement through a const fn, so naming a resource the table does not define — or an action it marks as unsupported — is a compile error, and superadmin_cap! additionally fails to compile unless the table says the pair is superadmin-only. No handler names a Role; unit tests in rbac_matrix.rs scan every control-plane module to keep it that way, check the module list against src/ so a new file cannot slip past, and assert that every row in the table is claimed by at least one guard.
Two read-only endpoints publish that table, so a dashboard never assembles a permission matrix of its own:
GET /api/v1/rbac/matrix— every role and, per resource, the minimum role each action takes (or that the action is superadmin-only, or unsupported entirely). Any authenticated caller may read it; it describes rules, not anyone’s access.GET /api/v1/rbac/effective?org_id=&team_id=&project_id=— the calling principal’s resolved role at that scope chain and the concreteresource:actionpairs they may perform, evaluated from their memberships.
effective is advisory to the client and authoritative only on the server: a caller that ignores it and issues the request anyway gets the same 403. Scope precedence is unchanged — a project-scoped grant authorizes that project, not the whole org.
Read access is a viewer’s and mutations are an admin’s, with three deliberate exceptions:
- deployment-wide policy (feature flags, runtime/compatibility/adaptive policy, logging settings, cluster nodes, security settings, alerting, MCP tool-call logs) has no tenancy scope to be a member of, so it is superadmin-only;
- global account lifecycle — creating an org, editing or deleting a user account, and the model/pricing catalog — reaches across orgs, so it is superadmin-only too, while inviting a user into an org stays an org admin’s;
- a user’s own things — minting a virtual key for yourself takes
member(a viewer cannot), and revoking your own MCP OAuth grant or session takes only a viewer membership plus ownership, which the handler checks after the guard.
Listing the pricing catalog (GET /api/v1/model-prices) and the effective model list (GET /api/v1/models) is every authenticated caller’s, with no membership anywhere. That is a third authority alongside a scoped role and superadmin, and the table names it rather than implying a role floor: both are deployment-wide catalogs of upstream capability and list price that carry no tenant’s data, and deployment is not a scope a membership can be held at, so a viewer floor there would have described a bar nobody could clear. GET /api/v1/rbac/matrix reports those cells as authenticated_only. The effective model list is still filtered per caller by the access-profile model policy (#534), so what a caller sees remains theirs alone.
Every cell in the table is now backed by the guard.
Custom roles and access profiles
The three built-in roles are a floor, not the whole rule set. An org may define custom roles: a base role plus a set of explicit (resource, action) grants drawn from the same CAPABILITIES table the guard reads. A grant can only widen — a custom role never takes away what its base role already allows, so the built-in roles keep behaving exactly as before and nothing has to be migrated.
Custom roles are not assigned to people directly. They are composed into an access profile, which names each role together with the org, team or project it applies at, and the profile is then assigned to users or teams. One profile can therefore say “auditor at the org, deploy admin on this one project” and be reused across an organization instead of re-granted per person.
flowchart LR P[Access profile] -->|role @ scope| CR[Custom role] CR -->|base| Built[admin / member / viewer] CR -->|grants| Pairs["resource:action pairs"] P -->|assigned to| Who[user or team] P -->|optional| Pol[model / route policy]
Evaluation order inside authorize is unchanged for the common case: memberships resolve first, and the configurable half is consulted only if the built-in answer was “no”. That keeps a plain deployment on exactly the old code path, and makes a custom grant strictly additive.
A profile may also carry a model and route policy — allow and deny lists over the models and routes its holders may reach. Deny wins over allow. Where a user holds several profiles the lists are unioned rather than intersected, since a second profile must never reduce access; one consequence is load-bearing: a profile with no model restriction at all makes the merged allow-list unrestricted, because that profile already permitted everything on its own.
That policy is published on GET /api/v1/rbac/effective as model_policy, and since #791 it is also enforced by the data plane. The bridge is the virtual key: a policy belongs to a person, but a request carries a credential, so the control plane resolves each key owner’s merged policy when it builds /internal/snapshot and publishes it on the key record. The gateway then applies it in KeyMeta::model_permitted and KeyMeta::route_permitted, alongside the key’s own model allow-list.
Both must permit a model, deliberately. The key list is what the key’s creator scoped that credential to; the policy is what an operator decided the person may reach at all. Neither can widen the other, so a key naming a model its owner is denied stays denied.
The shape, the merge rule and the allow/deny matching all live in rolter_core::ModelPolicy, which the control plane, the store and the gateway share — two implementations of “deny wins” free to drift apart would be a security bug.
Enforcement keys on the virtual key’s created_by. A key with no owner — admin-created and config-defined keys — carries no policy, because there is no person whose profiles could apply; restricting those is still the key’s own model list.
Because the gateway now reads them, four of these tables do carry a bump_config_version() trigger: access_profile_policies, access_profile_assignments, access_profiles and memberships (a profile assigned to a team reaches every member, so a membership change alters someone’s effective policy with no profile row changing). custom_roles and custom_role_grants still do not and still must not: they decide control-plane authorization, which is evaluated per request against the live database, so there remains nothing to propagate and a trigger would only wake the fleet for a change it cannot observe. See ADR-0023 for why the policy is resolved at snapshot time rather than when a key is minted.
Changing one is safe by construction:
- deleting a custom role that a profile still references returns
409rather than silently emptying the profile’s composition; detach it first; - deleting a profile does cascade its own assignments, since the assignment has no meaning without it;
- every create, update and delete on a role, a profile, an assignment or a policy is written to
audit_logwith the before/after, because all of them change what real people can do.
GET /api/v1/rbac/matrix?org_id= returns the org’s custom roles alongside the built-in ones, so the dashboard’s matrix is API-backed and updates after any change instead of holding state of its own.
Endpoints:
GET/POST /api/v1/orgs/{org_id}/custom-roles,GET/PUT/DELETE /api/v1/custom-roles/{id}GET/POST /api/v1/orgs/{org_id}/access-profiles,GET/PUT/DELETE /api/v1/access-profiles/{id}GET/POST /api/v1/access-profiles/{id}/assignments,DELETE /api/v1/access-profile-assignments/{id}PUT /api/v1/access-profiles/{id}/policy
Identity providers (ROL-35)
Local login and SSO both end at the same place: a verified identity that gets
turned into a session and reconciled memberships. That shared shape is
rolter_auth::IdentityProvider — an async trait with one method,
resolve(Credential) -> Result<Identity, IdentityError> — so a new provider
only has to prove who someone is; everything downstream (session issuance,
group → role reconciliation, audit logging) is unchanged.
rolter_control::auth::LocalIdentityProviderverifies aCredential::Password, preserving the original handler’s timing-safety property: every rejection path (unknown account, deactivated, sso-only, password login disabled by org policy, wrong password) still runs exactly one argon2 verification, so response time reveals nothing about which case applied.rolter_control::sso::OidcIdentityProviderverifies aCredential::AuthorizationCode, wrapping the existing code-exchange and JWKS id-token verification.- Concrete providers live in
rolter-control(next to thesqlx/reqwestthey need), not inrolter-auth, which only defines the trait and stays free of those dependencies.
Roadmap
- LDAP — bind + group mapping for enterprise directories (#241), the next
provider to implement
IdentityProvider. - JWT service auth and short-lived tokens.
- Audit log surfaced in the UI.
- Optional constant-time map / pepper for virtual-key lookup hardening.
Invitations
Invitations are how a rolter deployment onboards people without an identity provider. An admin mints a one-time link, the invitee opens it and chooses their own password, and the role the invitation carries is granted on acceptance.
This is deliberately not the same as POST /api/v1/orgs/{org_id}/users, which
creates an account with a password the admin picked. That is right for seeding
and for service accounts, and wrong for onboarding a colleague: it leaves
someone else holding their credential — usually in a chat log.
The flow
POST /api/v1/orgs/{org_id}/invitations(admin at the target scope) returns the invitation, the token, and a ready-madeaccept_url. The token is shown exactly once; only its peppered SHA-256 digest is stored, the same treatment sessions and virtual keys get, so a database dump alone yields no usable link.GET /api/v1/invitations/accept/{token}— unauthenticated preview returning the org name, the invited email, the role and the expiry. Nothing else: a link that leaked should not also leak a directory.POST /api/v1/invitations/accept/{token}/acceptwith the invitee’s chosen password creates the account, grants the membership, and returns a live session so they land signed in.DELETE /api/v1/invitations/{id}revokes a pending invitation.
Links expire after seven days.
Failing closed
Expired, revoked, already-accepted and simply wrong tokens all return the same
401. The caller learns whether their link works, not which of those it is.
Acceptance is single-use even under a race: the claim is an
UPDATE … WHERE accepted_at IS NULL, which either affects one row or tells the
loser they lost — before any account or membership is created.
One live invitation exists per email per org, enforced by a partial unique index. Re-inviting replaces rather than accumulates.
Existing accounts
Acceptance adopts an account that already exists under the invited email rather than forking a second row for the same person. Someone may already hold a login in another org, or have arrived through SSO first.
- An account with a password keeps it. An invite link is not a password reset.
- An SSO-only account (no password) gains the password it was invited to set.
- A deactivated account cannot be revived by an invitation (
403).
Relationship to single sign-on
Invitations and single sign-on co-exist; neither requires the other.
The membership an acceptance grants carries source = 'manual', and an SSO
login only ever reconciles source = 'sso' rows. So an invited role survives
every later IdP login, while roles that came from IdP groups are recomputed
each time. A deployment can run invitations only, SSO only, or both at once.
Configuration
| Setting | Where | Notes |
|---|---|---|
ROLTER_PUBLIC_URL | env | the accept_url is built from it; set it correctly behind a proxy or the link you hand out points at localhost |
ROLTER_SESSION_PEPPER | env | peppers the stored token digest, same as session tokens |
rolter does not send the email itself: it returns the link and lets the operator deliver it however their organization already delivers things. That keeps the control plane free of an SMTP dependency, which matters for air-gapped deployments.
Related
- Single sign-on (OIDC) — the IdP path, and how membership sources keep the two from fighting
- RBAC & auth — roles and scopes
- SCIM provisioning — IdP-driven lifecycle for deployments that want accounts created without anyone clicking a link
Single sign-on (OIDC)
rolter can authenticate operators against an OIDC identity provider — Keycloak, Okta, Entra ID, Auth0, Google Workspace — and turn the groups that provider reports into rolter roles.
Single sign-on is optional and additive. A deployment that never registers a provider behaves exactly as it did before: local accounts, passwords, and operator-granted roles. Nothing about SSO is reachable, and the login screen never mentions it.
The three supported deployments
| Deployment | Configuration | Login screen |
|---|---|---|
| Local accounts only | no sso_providers rows | email + password |
| SSO only | a provider, and allow_password_login = false on the org | one “Continue with …” button |
| Both | a provider, and password login left enabled | password form and the button |
The dashboard asks GET /api/v1/auth/methods — the one unauthenticated
endpoint in this area — and renders whichever of the three it is told. That
endpoint returns provider names, slugs and start URLs only; all of which are
already visible in the login URL, and none of which are secret.
The flow
Authorization code with PKCE, no implicit grant, no client-side tokens:
GET /auth/sso/{slug}/startmints astate, anonceand a PKCE verifier, stores them insso_login_states, and redirects to the provider’sauthorization_endpoint.- The provider redirects back to
GET /auth/sso/{slug}/callback. - The callback consumes the state row (
DELETE … RETURNING), so a replayedcode+statepair finds nothing and is refused. States older than ten minutes are treated as absent and swept. - The code is exchanged at the
token_endpointwith the PKCE verifier and the sealed client secret. - The id token is verified against the provider’s JWKS: signature by
kid, issuer, audience (client_id), expiry, and thenoncefrom step 1.
Rules that hold on every path:
- The redirect URI comes from configuration (
ROLTER_PUBLIC_URL), never from the request, so an attacker cannot point the callback elsewhere. - Only asymmetric algorithms are accepted (RS256/384/512, ES256/384, PS256).
HS256andnoneare rejected — a symmetric id token signed with a value the attacker may know is not evidence of anything. - The discovery document’s
issuermust equal the configured issuer. - A failed token exchange reports the HTTP status only. The provider’s error body can echo the client secret back, and that must not reach a log.
Groups become memberships
An operator maps an IdP group to a role at an org, team or project scope:
POST /api/v1/sso-providers/{id}/group-mappings
{"group_name": "platform", "role": "admin", "team_id": "…"}
The groups claim is read in every shape providers actually send it — a JSON
array, a single string, or a space-separated list — and Keycloak’s leading /
is stripped, so operators map the group name they see in the IdP’s own UI. The
claim name is configurable per provider (group_claim, default groups).
If no mapping matches, the provider’s default_role applies. If there is no
default_role either, the login is refused: SSO authenticates, it does not
implicitly authorize.
Manual and SSO grants co-exist
Every membership records where it came from:
source = 'manual'— an invitation, the admin API, the seed command.source = 'sso'— an IdP group mapping.
Each login reconciles only the sso rows inside that provider’s org. So:
- A role an operator granted by hand survives every SSO login, forever.
- Dropping a user from a mapped group revokes the role that group granted, on their next login — including the login that is then refused for having no grants left. That is how deprovisioning through the IdP works without SCIM.
- Another org’s SSO grants are untouched; they belong to that org’s provider.
An account is adopted by verified email: someone invited last month who now arrives through the IdP keeps the same user row, the same virtual keys and the same manual roles. SSO-created accounts get no password — an SSO identity must not silently gain a second, weaker credential.
Org login policy
PUT /api/v1/orgs/{org_id}/auth-policy (org admin) sets two flags:
allow_password_login— when false, members of this org cannot use the password form.allow_sso— when false, callbacks for this org’s providers are refused without deleting the provider rows, so an IdP can be cut off in one request.
Two guard rails, both returning 409:
- Both flags off is not a policy, it is an outage.
- Password login cannot be disabled before an enabled provider exists.
And one exemption: a superadmin can always log in with a password, whatever the policy says. A mistyped issuer or an IdP outage would otherwise lock the deployment out with no way back in. That exemption is the reason the flag is safe to turn on at all; keep the superadmin’s password strong and stored somewhere the IdP does not gate.
Configuration
| Setting | Where | Notes |
|---|---|---|
ROLTER_PUBLIC_URL | env | the control plane’s externally reachable base URL; the redirect URI is derived from it. Defaults to http://localhost:4001 |
ROLTER_KEK | env | required to store or read a client secret; the secret is sealed with AES-256-GCM exactly like provider credentials |
ROLTER_SESSION_PEPPER | env | session tokens are stored as peppered digests, same as local logins |
Register the redirect URI "$ROLTER_PUBLIC_URL/auth/sso/{slug}/callback" with
the identity provider.
Testing
Unit and Postgres-gated integration tests drive a stub IdP in-process, which
covers rolter’s own logic. Interoperability is a separate question, so the
e2e harness runs the same flows against a
real Keycloak — genuine discovery document, real JWKS, real login form, real
/-prefixed realm groups:
cd integration/e2e && uv run pytest tests/test_sso.py --idp
That suite is nightly and on-demand (.github/workflows/sso-e2e.yml), not part
of the per-PR gate.
Related
- RBAC & auth — roles, scopes and how they are enforced
- SCIM provisioning — IdP-driven account lifecycle, which pairs with SSO but is independent of it
- Security — secret handling and the threat model
MCP servers, OAuth grants and sessions
rolter stores the OAuth state behind Model Context Protocol access: which MCP servers an org has registered, which users consented to what, and the token sessions held against those consents. Transport and the authorization-code / on-behalf-of exchange belong to the MCP proxy; this layer owns persistence, listing, revocation and audit, so it is useful to a stdio, SSE, streamable-HTTP or WebSocket implementation alike.
Model
mcp_servers (org-scoped)
├── mcp_oauth_grants one live grant per (server, user)
│ └── mcp_oauth_sessions token material, sealed at rest
├── mcp_tool_groups named server/tool policy manifests
└── mcp_gateway_settings organization defaults
- Server — name, slug (unique per org), URL, transport and the OAuth scopes every proxied call requires. Deleting one cascades to its grants and sessions: withdrawing the server withdraws access to it.
- Grant — a user’s consent against a server, with the scope set they agreed to. A user holds at most one live grant per server (a partial unique index on
revoked_at is null); revoked grants are kept so the audit trail survives. Re-consenting updates the scopes in place rather than accumulating rows. - Session — the tokens issued under a grant, with
expires_at, an optional refresh token andrefresh_expires_at.
Token handling
Access and refresh tokens are sealed with AES-256-GCM under the deployment KEK (ROLTER_KEK), the same mechanism as upstream provider credentials — there is deliberately no plaintext column for either. Ciphertext and nonce sit side by side; the KEK never reaches the database.
The store exposes tokens through two credential-only paths. McpOAuthRepo::open_session opens one explicitly selected live session for lifecycle code. The Postgres config store opens only the newest live session per (server, user) whose scopes remain within its live grant and cover the server’s required scopes; those records travel through the token-guarded /internal/snapshot channel already used for decrypted provider credentials. Public API DTOs carry only metadata and a has_refresh_token boolean.
The gateway indexes servers by (org, slug) and sessions by (server, user). A request to /mcp/{server} must authenticate with a database-backed virtual key whose created_by user owns the selected session. The gateway repeats the required-scope check before connecting and replaces the caller’s virtual key with the downstream bearer token. Revocation, expiry and policy writes bump config_version, so snapshot polling removes authorization without a restart.
Who sees what
| caller | grants / sessions visible | may revoke |
|---|---|---|
| superadmin / admin token | every one in the org | any |
| org admin | every one in the org | any |
| org member or viewer | only the ones they own | only their own |
| anyone outside the org | none (403) | none (403) |
Every listing is joined through mcp_servers.org_id, so a cross-tenant read is not expressible, not merely filtered out.
Endpoints
GET/POST /api/v1/orgs/{org_id}/mcp-servers,PATCH/DELETE /api/v1/mcp-servers/{id}— viewer reads, admin writes. A server URL must behttp(s); the transport must be one ofstdio,sse,streamable_http,websocket.PATCHupdates registry metadata, enabled state, declared tools and required scopes without destroying grants or sessions.GET /api/v1/orgs/{org_id}/mcp/library— curated definitions annotated with whether the slug is installed. Installing one uses the ordinary server create endpoint, so the registry remains the source of truth.GET/POST /api/v1/orgs/{org_id}/mcp/tool-groups,PUT/DELETE /api/v1/mcp/tool-groups/{id}— exact server/tool policy manifests. These definitions are not yet enforced by the proxy.GET/PUT /api/v1/orgs/{org_id}/mcp/settings— organization transport and request defaults. The current HTTP proxy still uses deployment-level transport timeouts.GET /api/v1/orgs/{org_id}/mcp/grants,DELETE /api/v1/mcp/grants/{id}GET /api/v1/orgs/{org_id}/mcp/sessions,DELETE /api/v1/mcp/sessions/{id}GET/PUT /api/v1/mcp-servers/{id}/oauth-client— the OAuth client rolter presents to the server’s authorization server. Admin-only in both directions: the row names a third party the tenant has chosen to trust. The client secret is sealed with the deployment KEK on write and never read back;PUTwith an empty secret downgrades a confidential client to a public one.POST /api/v1/mcp-servers/{id}/oauth/authorize— begin consent. Returns the authorization URL rather than a302, because the caller is the dashboard overfetchand cannot usefully follow a cross-origin redirect.GET /auth/mcp/callback— where the browser returns. Authenticated by the one-shot login state, not by a session bearer token.POST /api/v1/mcp/sessions/{id}/refresh— renew a session from its stored refresh token.POST /api/v1/mcp/sessions/{id}/exchange— RFC 8693 token exchange for a narrower, downstream session.GET/POST/DELETE /mcp/{server_slug}/{path...}— Streamable HTTP/SSE proxy on the gateway, authorized by virtual-key owner, server and required scopes
Revoking a grant revokes every session under it in the same transaction, so consent and tokens can never disagree. Server creation/deletion and both revocations are written to audit_log.
The session lifecycle
Consent runs as an ordinary authorization-code flow with PKCE. POST .../oauth/authorize mints a verifier, seals it into a one-shot mcp_oauth_login_states row keyed by state, and returns the authorization URL. The callback consumes that row — a replayed state finds nothing and fails — verifies the code against the sealed verifier, then writes the grant and its first session in one transaction.
A background refresher sweeps every 60 seconds and renews up to 100 sessions per pass, 5 minutes before expiry, so the skew between rolter’s clock and the authorization server’s plus one round trip is always covered. It handles refresh-token rotation by replacing the stored refresh material whenever the response carries a new one. A permanently refused refresh revokes the session rather than retrying: a 4xx carrying invalid_grant or invalid_scope is a final answer about this grant — consent was withdrawn or the token was rotated away — and a retry loop would only hammer the upstream. Transient failures (network errors, 5xx) leave the session alone for the next sweep.
Token exchange (urn:ietf:params:oauth:grant-type:token-exchange) is the server-to-server half. A service acting for a user gets its own session row descending from the same grant, so it can be revoked independently without taking the user’s interactive session with it, and its scopes are intersected against the grant’s — an exchange can never widen consent.
SCIM 2.0 provisioning
An identity provider can create, update, deactivate and reconcile rolter accounts and groups over SCIM 2.0 instead of an operator doing it by hand. Users answer who exists; groups, through an operator-written mapping, answer what they may do.
Tokens carry the tenant
There is no org id anywhere in the SCIM path. An operator mints an org-scoped provisioning token, and that token is what resolves the tenant:
POST /api/v1/orgs/{org_id}/scim-tokens { "name": "okta" } # org admin
GET /api/v1/orgs/{org_id}/scim-tokens
DELETE /api/v1/scim-tokens/{id}
The plaintext token (rolter_scim_…) is returned once, at creation. Only its peppered SHA-256 digest is stored — the same treatment as session tokens — so a database dump alone cannot be replayed, and a lost token is rotated rather than looked up. Revoking takes effect on the very next request; there is no cache to invalidate.
Because the token resolves to exactly one org, an IdP cannot address another tenant’s users: a foreign resource id is a 404, not a redacted row.
Users
GET /scim/v2/Users?filter=userName eq "ada@example.com"
POST /scim/v2/Users
GET /scim/v2/Users/{id}
PUT /scim/v2/Users/{id}
PATCH /scim/v2/Users/{id}
DELETE /scim/v2/Users/{id}
- Filters. Only
userName eq "value"is supported — the one shape reconciliation needs. Any other filter is a400withscimType: invalidFilterrather than being ignored: silently returning the whole directory reads to an IdP as “no such user”, and it then re-creates the account. - Idempotence. IdPs retry. Creating an existing
userNamereturns409withscimType: uniquenessinstead of a second account. If the email already belongs to a local account (invited by an admin, or provisioned elsewhere), that account is adopted rather than failing on the unique email, so provisioning converges. - PATCH. The
activetoggle is implemented, in both thepath: "active"and bare{"active": false}forms, and with the string"true"/"false"some IdPs send. Any other operation is a400— an IdP that gets a success for an operation nothing applied would believe the change landed. - Deactivation logs the user out. Setting
active: falsestampsdeactivated_atand deletes the account’s live sessions, because an IdP disabling a leaver expects them out now, not merely unable to log in again. - DELETE deprovisions. The account is deactivated, its sessions dropped, and the org’s SCIM identity mapping removed. The
usersrow itself stays: its memberships and audit trail must outlive any one IdP, and a laterPOSTadopts it again.
No password path
A password attribute in a SCIM body is ignored, not honoured. Provisioned accounts are SSO-shaped: password_hash stays null, and there is no code path through which SCIM can set or read a local credential.
Groups
GET /scim/v2/Groups?filter=displayName eq "platform"
POST /scim/v2/Groups
GET /scim/v2/Groups/{id}
PUT /scim/v2/Groups/{id}
PATCH /scim/v2/Groups/{id}
DELETE /scim/v2/Groups/{id}
Same token, same tenancy rule, same ScimError envelope as Users.
- Filters. Only
displayName eq "value"is supported, for the same reasonuserName eqis the only Users filter: answering an unsupported filter with the whole directory reads to an IdP as “no such group”, and it then creates a duplicate. - Members must already be provisioned here. A
membersentry has to be a user with a SCIM identity in the token’s org. Anything else — an unparsable id, a local account the IdP never created, another tenant’s user — is a400withscimType: invalidValue. A group can therefore never be a side door into an account the Users surface did not make. - PATCH. The operations IdPs actually send are implemented:
add/replaceonmembers(array of{"value": id}, array of bare ids, or a single entry),removeonmembersboth with a value list and in the filteredmembers[value eq "…"]path form Okta uses,removeon the whole attribute to empty the group,replaceondisplayName/externalId, and the pathless{"op": "replace", "value": {…}}shape. Anything else is a400. - Idempotence. A replayed create is a
409withscimType: uniqueness. A replayedaddof an existing member, or aPUTre-sending the same member list, changes nothing. - DELETE. The group row goes and its members are reconciled in the same request, so the roles it granted disappear immediately rather than at the next sync. A second
DELETEis a404.
Group→team mapping
A mapping is written by an operator, not by the IdP — the IdP may not decide what its groups are worth:
POST /api/v1/orgs/{org_id}/scim-group-mappings # org admin
{ "group_name": "platform", "role": "member", "team_id": "…" }
GET /api/v1/orgs/{org_id}/scim-group-mappings
DELETE /api/v1/scim-group-mappings/{id}
This is deliberately the SSO group-mapping model, down to the vocabulary: a group_name grants one of admin/member/viewer at the most specific non-null scope id (team_id, project_id, or the org itself when neither is named), and a mapping may only grant inside its own org — a team or project belonging to another tenant is a 400. Two divergent group-mapping models in one control plane would be two things for an operator to learn and two places for a privilege bug to hide.
Mappings key on the group’s displayName, which is what an operator sees in the IdP UI. Renaming a group in the IdP therefore detaches it from a mapping written against the old name; rewrite the mapping, or keep the SCIM display name stable.
A mapping may be written before the IdP has ever mentioned the group. Creating or deleting one reconciles that group’s current members straight away, so a mapping change does not wait for the next sync.
Reconciliation converges
Every group write recomputes the affected users’ memberships from the database, never from the request body. The wanted set is “every mapping of every group this user is currently in”; source = 'scim' rows that are not in it are deleted, missing ones are created. Three consequences:
- A scheduled sync re-sending the same group state is a no-op.
- Dropping a user from a group revokes exactly what that group granted, in the same request.
- A grant an operator made by hand carries
source = 'manual'and is never touched — the same rule SSO logins follow, so the enrolment paths can be used side by side. An equivalent manual grant also suppresses creating a duplicatescimrow.
DELETE /scim/v2/Users/{id} drops the account from every group in the org before removing the identity, so a deprovisioned account cannot keep a group-granted role.
Roles
A provisioned account is granted a viewer membership at the org it was provisioned into, merely for existing — least privilege on purpose. Anything beyond a read comes from a group mapping an operator wrote.
Audit
scim.user.create, scim.user.update, scim.user.deprovision, scim.group.create, scim.group.update and scim.group.delete are written to audit_log, scoped to the org, with the provisioning token’s id in the detail — the actor is a token, not a human. Operator actions carry the acting operator instead: scim_token.create / scim_token.revoke, and scim_group_mapping.create / scim_group_mapping.delete.
Security
Secret handling
- Upstream provider keys are never stored in plaintext in the database. They are envelope-encrypted with AES-256-GCM: a per-record data key/nonce, wrapped by a master key (KEK) supplied via
ROLTER_KEK(env/file). Pluggable backends (HashiCorp Vault, cloud KMS) are a roadmap item. - In the bootstrap file, prefer
api_key_envover inlineapi_keyso secrets stay in the environment, not on disk. - Virtual keys are stored as hashes with a short display prefix; the raw key is shown once at creation.
- Secrets are never logged. The gateway redacts auth headers from traces.
Transport
- Upstream calls use rustls (no OpenSSL). HTTP/2 keep-alive with connection pooling.
- Optional per-provider egress proxy (
egress_proxy, HTTP/HTTPS/SOCKS5) for networks where providers aren’t directly reachable. - Optional global or per-provider custom CA bundles add private PKI roots to outbound upstream clients while retaining public roots, certificate-chain validation, and hostname verification.
- Terminate TLS at the gateway or a fronting proxy/ingress in production.
Cross-origin policy (CORS)
security_settings.allowed_origins and allowed_headers are enforced by a
middleware on the control plane (crates/rolter-control/src/cors.rs). The
policy is read from an ArcSwap per request, so an edit through
PUT /api/v1/security-settings applies immediately rather than at the next
restart.
Only the control plane has one, because it is the only origin a browser talks
to: it serves the dashboard, and the Playground reaches the data plane through
the /gw/* reverse proxy rather than calling the gateway directly. A gateway
CORS layer would govern requests browsers do not make, and would need
SecuritySettings in the snapshot to do it.
Behaviour:
- No origins configured is the default, and the same-origin deployment everyone runs. The middleware adds no headers at all and the request path is unchanged — CORS is not needed when the control plane serves both the dashboard and the API.
- Matching is exact against the configured list, modulo case and a trailing
slash. There is no wildcard and no suffix match, so
evil-dash.example.comanddash.example.com.evil.testdo not inherit an entry fordash.example.com.validate_originalready rejects*at write time. - A denied origin gets a normal response with no CORS headers; the browser
enforces the block.
Vary: Originis set on every answer so a shared cache cannot serve one origin’s response to another. - Preflights are answered by the middleware with
204and never reach the router, since they carry no credentials. authorization,content-type,traceparent,tracestateandx-request-idare always allowed, unioned with whatever is configured. The trace headers are what let the dashboard’s spans join the gateway’s on a split-origin deployment; leaving them to configuration means browser trace propagation silently does not work until someone remembers them.x-request-idis exposed viaAccess-Control-Expose-Headers, so a browser can read the correlation id the API already echoes.
Egress policy (SSRF)
A provider’s api_base decides where the gateway sends traffic, so an admin
surface that accepts an arbitrary one turns the proxy into an SSRF primitive
aimed at whatever the gateway’s network position can reach.
rolter is built to run self-hosted and air-gapped, so upstreams legitimately
live on loopback, RFC1918 and container networks — blanket-denying private
destinations would break the core use case. What no legitimate LLM upstream
needs is the link-local range, which is where cloud instance metadata lives
(169.254.169.254, fe80::/10). That is denied by default; everything else is
opt-in:
[egress]
block_link_local = true # default — cloud instance metadata
block_loopback = false # sidecar / single-host deployments
block_private = false # on-prem clusters
allow_hosts = [] # exact-host escape hatch
Enforced in two places: the control plane rejects a denied api_base at
write time (400, so it never reaches the database), and snapshot
validation re-checks it, so a bootstrap toml can’t smuggle one in either.
Config-time validation classifies IP literals only. Resolving hostnames there would make validation depend on live DNS — reintroducing the “one bad row freezes every gateway” failure mode described in config-and-hot-reload.md — and would be bypassable by DNS rebinding regardless.
So the same policy is enforced a third time, at connect time, by a custom
resolver on every upstream client: whatever DNS actually returns is classified
immediately before the connection is made. That covers the two cases config
validation cannot see — a hostname like metadata.internal, and a name that
resolved to a public address when it was configured but resolves to
169.254.169.254 at request time. A denied destination surfaces as a policy
error naming the host, not an opaque connect failure.
A name that resolves to several addresses keeps the permitted ones: refusing the whole name would take down a legitimate multi-homed upstream. Only a name left with nothing is refused — which is exactly what rebinding to a denied address produces. The resolver reads the policy from a live handle, so a hot reload re-tunes enforcement without discarding pooled connections.
Control-plane input validation
Every control-plane mutation body is decoded through a SafeJson extractor
rather than axum’s Json. Before the body is deserialized into its typed
struct, every string in it — nested objects, arrays and object keys included —
is screened for control characters, and the request is rejected with a 400
naming the offending field.
The concrete failure this prevents: Postgres text columns cannot store a NUL
byte, so a field carrying one failed deep inside the store and surfaced as an
unhandled 500 instead of input validation, violating the “bounded error, no
unwrap/expect on a request path” invariant. Screening the whole C0/C1 range
(and U+007F) also closes the log-injection vector a raw escape or newline
would otherwise open in operator-facing logs.
Tab, newline and carriage return stay allowed — multi-line values are legitimate, a PEM CA bundle being the obvious one. Malformed JSON now also comes back in the same OpenAI-style error envelope as every other failure instead of axum’s default rejection body.
Open mode (no admin token)
With no ROLTER_ADMIN_TOKEN set, Principal short-circuits to Superadmin for
every request: the management API and /internal/snapshot have no
authentication step to fail. This is the zero-credential local-dev shape, and
crates/rolter-control/src/open_mode.rs is what keeps it from being anything
else. Before either listener is opened, it evaluates “is a token set” against
every address about to be bound:
| admin token | bind | outcome |
|---|---|---|
| set | any | Closed — RBAC enforced |
| unset | all listeners loopback | OpenLoopback — allowed, warned |
| unset | any non-loopback listener | refuses to start |
| unset | non-loopback + --allow-open-mode | OpenAcknowledged — allowed, warned loudly |
--internal-addr counts as a listener here: an exposed credential channel is
no better than an exposed API, so either one alone is enough to refuse.
The decision also rides into the dashboard through window.__ROLTER_CONFIG__
(openMode: true), which renders a persistent banner. Without it the dashboard
looks identical whether the control plane is gated or wide open, which is the
property that made this dangerous rather than merely permissive (#970).
This is why ROLTER_CONTROL_HOST defaults to 127.0.0.1 rather than
0.0.0.0: containers and clusters set it explicitly, and by then they have a
reason to have set a token too.
Control↔data-plane trust boundary
GET /internal/snapshot returns provider api_keys decrypted. That is by
necessity — the data plane needs the upstream credential to authenticate to the
provider — but it makes the snapshot channel a different trust boundary from
the operator-facing management API, and it should be configured as one:
ROLTER_INTERNAL_TOKEN=... # gates /internal/*, distinct from ROLTER_ADMIN_TOKEN
ROLTER_INTERNAL_ADDR=127.0.0.1:4002 # serves /internal/* on its own socket
With ROLTER_INTERNAL_TOKEN set, the operator admin token no longer opens the
snapshot — only the gateway’s own credential does. With ROLTER_INTERNAL_ADDR
set, /internal/* is not mounted on the public API router at all, so it is
absent from the port the dashboard and management API are served from rather
than merely gated on it. Bind it to loopback or a private interface. The
gateway sends ROLTER_INTERNAL_TOKEN on snapshot polls, falling back to
ROLTER_ADMIN_TOKEN.
Both are optional. Unset, the historical behavior holds — /internal/* shares
the public listener and accepts the admin token — and the control plane logs a
warning at startup saying so. The tenant-facing CRUD surface never returns a
provider key in any configuration.
Two things this deliberately does not do. There is no mTLS between the planes: a shared secret over a private interface is comparable strength when the network path is already trusted, and mTLS adds certificate lifecycle to an air-gapped deployment. And the snapshot still carries plaintext rather than sealed ciphertext: envelope-passing would require the gateway to hold the KEK, moving the master secret onto every data-plane node, which is a worse trade for most deployments. Revisit both if the planes ever cross an untrusted network.
Wire transparency
- Outbound requests to upstream providers carry no rolter-identifying marks: no
User-Agent, no addedX-*/Viaheaders, no metadata injected into the JSON body, no marks in SSE framing. The only headers sent are functionally required ones —content-type, the provider’s auth header, andanthropic-versionfor Anthropic. - Responses back to clients likewise gain no rolter-added headers.
- This is a tested guarantee: golden wire tests in
rolter-proxycapture the raw outbound request head and fail on any unexpected header (seeopenai_wire_carries_no_rolter_signature).
External PII sanitization (#848)
The gateway can hand request content — and optionally response content — to a self-hosted de-identification service before it reaches a provider. Configured under [pii_sanitizer]; see user-docs/security/pii-sanitizer.mdx for the operator-facing contract.
The design property worth stating precisely: the placeholder→plaintext mapping never enters the gateway process. The sanitizer substitutes deterministic placeholders and returns an opaque restoration token; rolter holds the token and nothing else. There is therefore no log line, metric label, span attribute or cached body from which the original values can be recovered, and no in-memory table for a crash dump to expose.
Consequences that fall out of that choice:
RestorationTicketis not printable. ItsDisplayrenders<restoration token redacted>, and the token is reachable only throughtoken_for(&scope), which returnsNoneunless the caller’s org/team/project/route match the scope the ticket was minted under. A token from one project cannot restore content in another.- Restoration is opt-out, not opt-in-by-default.
RestorationPolicy::Neveris the default;CallerAuthorizedhonoursx-rolter-pii-restoreonly because the policy allows it, never because the caller asked. - The response leg never requests reversibility. Restoring provider-generated content would return the very data that leg exists to remove.
- A streamed response with an active response leg is refused (
pii_streaming_unsupported, HTTP 400), mirroringguardrails.streaming_post_call. Correct streaming restoration would need either a network round trip per chunk — destroying TTFT — or the mapping held in-process, destroying the property above.streaming = "passthrough"waives the response leg instead. - A malformed sanitizer reply is a failure, unlike the guardrail webhook’s decision parsing which defaults to allow. Defaulting would forward content the gateway believes is sanitized and is not.
- Restore runs after the cache store, so what is cached is what the upstream said. A later hit on the same entry is re-evaluated against its own request’s policy and ticket rather than replaying someone else’s restored plaintext.
fail_openis the default and is the risk to monitor. It forwards unsanitized content when the service is down.rolter_pii_sanitizer_errors_totalrising underfail_openmeans personal data is reaching providers; deployments where the sanitizer is a compliance control should setfail_closed.
A response-leg failure never fails the request even under fail_closed: the upstream call already happened and was already billed, so refusing to deliver would spend the caller’s money and return nothing. The counter records it.
Threat model (high level)
- Tenant isolation: virtual keys are scoped to a project; model allow-lists prevent access to unconfigured models; cache keys are namespaced to avoid cross-tenant cache poisoning.
- Abuse: RPM/TPM rate limits and budgets bound spend and load (roadmap enforcement).
- AuthZ: control-plane mutations are RBAC-checked and recorded in
audit_log. - Supply chain:
cargo deny/advisory scanning in CI is a roadmap item.
Operational guidance
- Always set a strong
ROLTER_KEK(e.g.openssl rand -hex 32) and rotate provider keys periodically. - Run the control plane on a private network; expose only the gateway publicly.
- Back up Postgres; treat the master key as the most sensitive secret.
LDAP authentication
The third identity provider behind rolter_auth::IdentityProvider, after local
password login and OIDC SSO (#241). It is compiled in behind the ldap cargo
feature and is off unless configured.
How a login is verified
A user’s distinguished name is rarely their login name, so verification is a two-phase bind:
- Find. Bind as a read-only service account and search
base_dnfor the entry matchinguser_filter, with{login}substituted. - Verify. Bind a second time as the entry that was found, using the password the user submitted. That bind is the authentication.
rolter never reads, compares or stores a password hash from the directory. It also never caches the password.
A filter matching more than one entry is a hard failure rather than a login: binding as an arbitrary one of several matches would authenticate the wrong person.
Group mapping is least-privilege
group_role_map maps a directory group onto a rolter role. It is explicit and
additive:
- a user in several mapped groups gets the union of their roles;
- a user in no mapped group is rejected, even with a correct password.
There is no implicit default role and no wildcard. Authenticating proves who someone is, not what they may do — the same rule SSO already follows.
Group names are matched case-insensitively and with surrounding whitespace trimmed, because directories are inconsistent about DN case and an operator who copies a group from their directory’s UI should not silently get no access.
What a client is told
Every authentication failure returns the same generic rejection:
| Situation | Result |
|---|---|
| No such user | NotVerified |
| Wrong password | NotVerified |
User outside base_dn | NotVerified |
| Authenticated, but in no mapped group | NotVerified |
| Directory unreachable or misbehaving | Provider("directory unavailable") |
The first four are deliberately indistinguishable. A login form that distinguishes “no such user” from “wrong password” is a user-enumeration oracle against the corporate directory, which is a worse leak than the login itself.
A directory being down is distinguishable, because that is an operational fact the operator needs and reveals nothing about any account. Distinguished names, filters, bind credentials and raw LDAP result codes are logged for the operator and never returned.
Configuration
url = "ldaps://dir.example.com" # ldaps:// strongly preferred
bind_dn = "cn=svc-rolter,dc=example,dc=com"
bind_password = "…" # read-only service account
base_dn = "ou=people,dc=example,dc=com"
user_filter = "(uid={login})" # must contain {login}
email_attr = "mail"
display_name_attr = "cn"
group_attr = "memberOf"
require_tls = true
[group_role_map]
"cn=rolter-admins,ou=groups,dc=example,dc=com" = "admin"
"cn=rolter-users,ou=groups,dc=example,dc=com" = "member"
Configuration is validated before it is used to authenticate anyone. These are rejected at startup rather than at first login:
- a URL that is neither
ldap://norldaps://; require_tlswith a plainldap://URL — the service-account bind password would cross the network in cleartext;- a
user_filterwithout{login}, which would authenticate whoever the filter happens to match, for any login submitted; - an empty
base_dn; - an empty
group_role_map, under which no directory user could ever be granted a role.
Filter injection
The login is escaped per RFC 4515 before substitution. Without it a login of *
matches every user in the subtree, and )(uid=admin rewrites the filter — LDAP
injection, the directory analogue of SQL injection. \, *, (, ), / and
NUL are escaped.
Operational limitations
- Group membership is read from the user entry via
group_attr(typicallymemberOf). Directories that do not populate a reverse-membership attribute need a nested group search, which is not implemented. - Nested groups are not expanded. A user in a group that is itself a member of a mapped group is not granted that role. Map the groups users are directly in.
- No connection pooling. Each login opens and closes its connections. This is correct but not fast; an interactive login is not a hot path, and pooling bound connections has its own correctness hazards.
- No referral chasing. A referral from the directory is not followed.
ldaps://only for transport security. StartTLS on a plainldap://connection is not implemented.
Health and readiness
Both rolter binaries serve two operational endpoints that answer two different questions. They are unauthenticated, cheap, and safe to probe on every pod.
| Endpoint | Question | Failure means |
|---|---|---|
GET /healthz | Is the process alive and its runtime not wedged? | Kubernetes kills and restarts the pod |
GET /readyz | Can this process actually serve traffic right now? | Kubernetes removes the pod from the Service and nothing else |
The words mean the same thing on rolter-gateway and rolter-control.
Why the split exists
Collapsing both probes onto one endpoint has no correct answer, only a choice of which failure to accept:
- If the single endpoint answers as soon as Axum binds, Kubernetes marks the
pod Ready before it can serve. A rolling update then routes dashboard and
/internal/snapshottraffic — which every gateway in the fleet polls — at a control pod whose migrations have not run and whose pool has no connection. - If the single endpoint instead checks Postgres, a database blip fails the liveness probe and Kubernetes kills every control pod at once. A recoverable dependency outage becomes a restart storm, and the restarts make the outage worse by reconnecting a stampede at the recovering database.
Splitting the endpoints lets a dependency failure drain traffic without touching the process.
/healthz — liveness
Returns 200 OK with the body ok whenever the HTTP server is accepting
connections. It checks no dependency: not Postgres, not Redis, not
ClickHouse, not an upstream provider. That is the point — nothing outside the
process may cause a restart.
/readyz — readiness
Gateway
Returns 200 OK normally, and 503 Service Unavailable with the body
draining once the instance has been drained from the dashboard’s cluster
screen — the state arrives on the /internal/snapshot response the gateway
already polls. A drained gateway therefore leaves the Service and finishes
its in-flight requests, instead of being killed with them in progress.
Control plane
Returns a JSON body describing each check:
{
"status": "ready",
"checks": {"database": "ok", "migrations": "ok", "kek": "ok"}
}
200 when status is ready, 503 otherwise. The checks are:
- database — the Postgres pool hands out a connection within 2 seconds. The
timeout is deliberately shorter than a typical probe
timeoutSecondsso an exhausted pool answers a clean503rather than hanging until kubelet reports a timeout, which looks like a dead process. - migrations — every migration embedded in this binary is applied to the
database. A pod running ahead of its schema would serve wrong answers on the
CRUD API and
/internal/snapshot, so it is not ready. - kek —
ROLTER_KEKparses when it is set. A KEK set to an empty value seals nothing and decrypts nothing, so the pod cannot serve credentials. Reported asnot configured(and not a failure) when the variable is unset.
Redis and ClickHouse are deliberately not checked. The control plane serves configuration without either — losing Redis costs the config-bump fan-out notification, losing ClickHouse costs the analytics screens — so their absence is degraded, not unready. Removing every control pod from the Service because analytics is down would be a strictly worse outage.
Running without --database-url (bootstrap config only), there is nothing to
wait on and the answer is always ready.
Probe wiring
charts/rolter wires this for you:
readinessProbe:
httpGet: {path: /readyz, port: http}
livenessProbe:
httpGet: {path: /healthz, port: http}
startupProbe: # control plane only
httpGet: {path: /healthz, port: http}
The startupProbe on the control plane holds liveness off while first-boot
migrations run against a cold database, so a slow migration is not mistaken for
a wedged process. Tune the periods under control.probes in values.yaml.
What to alert on
/readyzdown for longer than a failover — the control plane cannot reach its database, or is running against a schema it has not migrated. Page./healthzflapping — the process itself is crashing or wedging. Page./readyzdown on a single gateway — usually just a drained instance, which is expected during a rolling update. Do not page on this alone.
Observability
Metrics
- The gateway exposes Prometheus metrics at
GET /metrics: counters (rolter_requests_total,rolter_upstream_errors_total,rolter_auth_failures_total, reload/log/budget/rate-limit/retry/cooldown/health/breaker/scrape counters), gauges (rolter_config_version,rolter_breaker_entries), and per-model latency histograms —rolter_request_latency_ms(total) androlter_request_ttft_ms(time-to-first-token), each labelled{model=...}with the standard_bucket/_sum/_countseries. Histograms are observed once per completed request from the log sink, off the response hot path. - The exporter is hand-rolled (atomic counters + non-cumulative histogram buckets cumulated at render) rather than the
metricsfacade + global recorder, which does not fit the lock-freearc-swapdesign where an explicitArc<Metrics>is threaded through the request path. - Passive per-target SLA signal:
rolter_target_requests_total{provider,target,outcome}(a counter,outcome=okfor 2xx elseerror) is tallied once per completed request from the log sink — free, derived from real traffic, no extra upstream calls. A per-target error rate / uptime issum(rate(rolter_target_requests_total{outcome="error"}[5m])) / sum(rate(rolter_target_requests_total[5m])). This is the first slice of provider stability tracking (ROL-123); the ClickHouseprovider_health_eventstable and the dashboard land in later slices. The active prober is guarded: bounded probe concurrency with per-provider jitter, consecutive-failure/-recovery thresholds gating the unhealthy flip (no single-probe flapping), and exponential probe backoff when a probe itself gets a 429. - Multi-key providers:
rolter_key_cooldowns_tripped_totalcounts api keys parked after a key-level failure (429/401 on a provider with several keys); the request retries in-flight on a sibling key. - A/B attribution:
rolter_variant_requests_total{model,variant}(a counter) tallies requests per chosen variant, so traffic splits are visible in Prometheus/Grafana without querying ClickHouse. Classic single-pool routes (no variant) emit nothing. Observed from the same log-sink funnel (ROL-195, part of ROL-188). - Adaptive routing (#544):
rolter_adaptive_routing_decisions_total{model,mode}splits a route’s picks betweenblend(the latency/cost/load blend),exploration(the bounded random share that keeps starved targets sampled) andfallback(the deterministicpipelinestack served while the kill switch is off or the evidence is too thin), androlter_adaptive_routing_engaged{model}is1while the blend is actually routing. Only routes on theadaptivestrategy emit these. A config reload rebuilds the balancer and so resets the counters, which lines up with arolter_config_versionbump — alert onrate(), not the absolute value. A route sitting atengaged 0with all picks onfallbackis the expected steady state before an operator enables the policy.rolter_adaptive_routing_target_score{model,target}(#751) adds the blended score each target currently carries — the same numbers the control plane serves atGET /api/v1/adaptive-routing-telemetry, computed at scrape time rather than on the request path. - Roadmap: add per-provider/route labels on the histograms, in-flight gauges, cache-hit ratio, and circuit-breaker state gauges.
- Roadmap: scrape/federate upstream engine metrics from vLLM/SGLang/TGI
/metricsand correlate them per target (queue depth, KV-cache usage, running/waiting requests) to feed load- and cache-aware routing and the dashboard.
Tracing & context propagation
tracing+tracing-subscriberwithRUST_LOGfiltering;TraceLayerlogs each HTTP request.- Inbound: accept W3C
traceparent/tracestate(andb3) from clients and continue the trace; honorx-request-id/x-correlation-id. - Outbound to engines: inject the active trace context into upstream requests so vLLM/SGLang/TGI spans join the same distributed trace. vLLM and SGLang support OpenTelemetry tracing (e.g. vLLM
--otlp-traces-endpoint); point them at the same OTLP collector so engine prefill/decode spans line up with rolter’s request span. - A per-request
request_idis echoed in a response header and stamped on logs, metric exemplars and spans for correlation. - Events: rolter calls no span-event API directly, but
tracing-opentelemetryturns everytracingevent fired inside an active span into an OTel span event. That API is deprecated upstream in favour of log-based events; the target model, and the sequencing it forces, are recorded in ADR-0025.
How the context actually moves
rolter-core::telemetry owns both directions, and both are inert unless an OTLP
endpoint is configured:
-
Extract.
GatewayMakeSpan(rolter-gateway::trace) is theTraceLayer’s span-maker: it builds the request span and makes the extracted inbound context its parent. A B3-only caller is normalized into an equivalenttraceparentfirst, so one W3C propagator serves both wire formats. Without this the gateway’s spans were disconnected roots — the trace id reached the request log, but nothing joined the caller’s trace.It has to be the span-maker rather than a middleware layered inside the
TraceLayer:DefaultMakeSpanbuilds the request span at DEBUG, so under the defaultRUST_LOG=infoit is disabled, and setting a parent on a disabled span silently does nothing. With no pipeline installed it falls back to that stock DEBUG span, so the untraced path costs what it always did. -
Inject. The context handed to the provider is injected from the current span, inside the per-attempt
upstream.requestspan, rather than copied from the caller. Copying it verbatim made the provider call a child of the caller’s span and therefore a sibling of the gateway’s own work, which silently invalidated every waterfall built from the data. The allowlisted client headers fromForwarder::forwarded_header_namesare unaffected; only the trace headers changed hands. -
Log correlation.
RequestLog.trace_idis read off the span context when a pipeline is installed and falls back to parsing the inbound header otherwise, so ClickHouse and the trace backend agree by construction.
Pipeline spans
One span per stage, so a slow request is attributable rather than merely slow:
| Span | Attributes |
|---|---|
auth | — |
guardrails.pre | redacted, webhook |
route.select | route, strategy, candidates |
cache.lookup | hit, kind (exact / semantic) |
queue.wait | provider |
upstream.request | attempt, gen_ai.system, gen_ai.request.model, http.response.status_code |
translate.request | — |
guardrails.post | — |
queue.wait spans enqueue→dequeue only: the span travels with the queued job and
the worker closes it the moment it picks the job up, so it measures the wait and
not the wait plus the upstream call. The job carries the caller’s span alongside
it, and the worker instruments the forward with it — the queue worker runs on its
own task where nothing is in scope, so without that the forwarder’s own
translate.request span becomes an orphan root in a trace of its own. Names
follow the OTel
GenAI semantic conventions
where they fit, so a backend’s built-in GenAI views work.
Spans never carry prompt or completion content, API keys, virtual-key plaintext,
or injected header values — those are credential material, and redaction stays
owned by the existing logging_settings machinery rather than a second policy.
Control-plane spans
The control plane runs the same pipelines as the gateway (telemetry::init()),
but until #845 emitted no spans of its own — everything it did was invisible
beyond what the HTTP layer produced by default.
| Span | Attributes |
|---|---|
control.request | http.route, http.request.method, http.response.status_code |
snapshot.build | config_version, payload_bytes, outcome |
snapshot.sanitize | — |
snapshot.encode | — |
http.route is the matched template (/api/v1/providers/{id}), never the
concrete path. control.request comes from one middleware rather than an
attribute on each of ~90 handlers, so a route added tomorrow is instrumented the
moment it is mounted.
snapshot.build is the one to watch. Snapshot latency is fleet-wide
config-propagation delay: every gateway waits on it, so when an operator changes
a route and the fleet serves stale config, this span is what says whether the
delay is in generation or downstream of it. config_version on the span answers
“which config was this” without putting an unbounded value on a metric.
Tenant attributes
The gateway.request span carries rolter.org.id, rolter.team.id and
rolter.project.id, recorded once the virtual key resolves. The span itself is
built by the tower layer, which runs before auth, so the fields start empty and
are filled in rather than passed at construction.
These exist so per-tenant telemetry destinations are routable. ADR-0026 decided that fan-out to tenant-owned backends belongs in an OpenTelemetry Collector rather than in-process exporters — one egress path in the gateway no matter how many tenants, and no data-plane process POSTing to operator-supplied URLs — and that rolter’s job in that design is to stamp the attribute the collector routes on. Until this landed there was nothing to stamp: the request logs in ClickHouse always carried tenant identity, but no exported span ever did, which made the routing half unimplementable.
An unattributed request — a config-defined key, which has no org — records nothing rather than an empty string. An attribute that is present-but-blank on some spans and absent on others is harder to write a routing rule against than one that is consistently absent.
The names are deliberately rolter-local. The GenAI and HTTP conventions define
nothing for tenancy, and a convention-shaped guess like tenant.id would be
worse than an obviously-local name if the spec later defines it differently.
Running it locally
The observability overlay starts a collector and a trace UI alongside the normal stack:
docker compose -f docker/docker-compose.yml \
-f docker/docker-compose.observability.yml up
Traces land at http://localhost:16686; the collector takes OTLP on 4317/4318
and re-exposes collected metrics on 8889. The overlay sets
OTEL_EXPORTER_OTLP_ENDPOINT on the gateway and control services, so
bringing it up is the only step — without it that variable is unset and tracing
stays off.
The collector binds 0.0.0.0, not localhost: one bound to loopback inside its
container is unreachable from the gateway container.
Choosing an overlay
There are two, and they are mutually exclusive — both publish OTLP on 4317/4318.
docker-compose.observability.yml (default) | docker-compose.signoz.yml | |
|---|---|---|
| backend | Jaeger v2 | SigNoz |
| signals | traces only | traces, metrics, logs |
| containers | 2 | 5, incl. its own ClickHouse + Zookeeper |
| storage | in memory, lost on restart | persistent |
| use it for | reading a waterfall, fast iteration | aggregate views, dashboards, dogfooding over time |
docker compose -f docker/docker-compose.yml \
-f docker/docker-compose.signoz.yml up # SigNoz on :8080
Jaeger is the default because it is two containers and starts in seconds, and because reading a correctly-parented waterfall is what the tracing work needed. Reach for SigNoz when “which stage is slow across all requests” matters, which Jaeger cannot answer.
Nothing in the Rust differs between them: the gateway speaks vendor-neutral OTLP
and only the destination changes. Any other OTLP backend works the same way —
repoint the exporter in infra/otel/collector.compose.yaml.
Querying SigNoz from an agent (MCP)
The SigNoz overlay also starts SigNoz’s MCP server on http://localhost:8000/mcp,
so an agent can query traces, run ClickHouse queries, and manage dashboards
against the local instance. Point an MCP client at that URL; the
SigNoz agent-skills plugin ships a
signoz server entry to fill in.
It needs a SigNoz API key, which is created in the UI (Settings → API Keys, admin only) and supplied to the server, not the client:
set -x SIGNOZ_API_KEY (pass show rolter/signoz-api-key)
docker compose -f docker/docker-compose.yml \
-f docker/docker-compose.signoz.yml up -d signoz-mcp
The key is read from the environment and never written to a tracked file. With
no key set the container still starts, but every call returns
Authorization or SIGNOZ-API-KEY header required.
The MCP dashboard tools need SigNoz v0.135.0 or newer, which is why the signoz
image is pinned ahead of the collector/ClickHouse pair.
The SigNoz overlay is a pinned equivalent of what SigNoz’s Foundry CLI generates,
since SigNoz deprecated its own compose manifests in v0.130.0. Two things to know
before touching it: its four images are a tested set and must be bumped
together (a newer migrator emits ClickHouse settings an older server rejects),
and it vendors 3.6 KB of ClickHouse config — the cluster topology and the
{shard}/{replica} macros that ReplicatedMergeTree needs — rather than
SigNoz’s full 56 KB config.xml, which the stock image defaults cover.
Metrics over OTLP
The counters and gauges rolter-gateway::metrics computes are exported over
OTLP as well as served on /metrics. The Prometheus endpoint is unchanged —
this is a second exporter over the same numbers.
Both read one list, Metrics::scalars(), so they cannot drift: a counter added
there reaches Prometheus and OTLP without a second edit. Counters export as
counters and gauges as gauges, since exporting a counter as a gauge would break
rate() on the backend.
The instruments are observable: nothing is pushed on the request path. The
SDK invokes the callback on its own schedule (OTEL_METRIC_EXPORT_INTERVAL,
default 60s) and reads the same atomics the Prometheus renderer does, so the hot
path still only does fetch_add. With no OTLP endpoint configured no meter
provider, exporter or callback is built at all.
Per-model histograms and label-bearing counters stay Prometheus-only for now; the scalar set is what OTLP carries.
Control-plane metrics
The control plane has no Prometheus registry to mirror, so these are the one place it measures itself (#845). They are real histograms — measurements taken as they happen — not observable instruments, because a duration cannot be reconstructed from a counter after the fact.
| Metric | Unit | Attributes |
|---|---|---|
rolter_snapshot_build_ms | ms | outcome (ok / not_modified / error) |
rolter_snapshot_payload_bytes | By | outcome |
rolter_control_request_ms | ms | http.route, http.request.method, http.response.status_class |
Boundaries are deliberately not the gateway’s. A gateway request is dominated by an upstream model call and is interesting out to tens of seconds; a snapshot build and a CRUD write are database work where “is this 2 ms or 40 ms” is the whole question, so reusing the gateway’s boundaries would put nearly every observation in the first bucket.
Cardinality is bounded by construction. http.route is the matched
template, so a thousand providers are one series. Status is recorded as a
class, not a code: twelve statuses across ninety routes would be over a
thousand series to answer a question five buckets answer. config_version is
unbounded — a new value on every config write — so it lives on the
snapshot.build span and never on a metric.
rolter_snapshot_payload_bytes skips the 304 case rather than recording a
zero: a not-modified poll transfers no body, and folding zeroes in would drag
the size distribution down and misreport what the fleet actually moves.
Payload size matters on its own. It is what every gateway transfers on every
poll, and it is the first thing to look at when propagation gets slower without
generation getting slower. The snapshot bench in rolter-core shows why the
encode is the stage to watch: at 1000 routes it costs ~2.8 ms against ~150 µs
for sanitize and ~120 µs for validate.
The control plane also registers the same process and runtime metrics as the gateway — it degrades for the same reasons and previously answered none of those questions either.
Logs over OTLP
Logs export alongside traces and metrics, gated on the same environment
(OTEL_EXPORTER_OTLP_ENDPOINT, or the logs-specific
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT). With neither set no exporter is built and
logging is stdout-only, exactly as before.
It is additive, not a replacement: the stdout fmt layer is untouched, so
docker logs shows what it always did and the collector additionally receives
the same records.
Records carry trace_id and span_id, which is most of the value — a log joins
the trace it came from instead of being a separate pile of text.
tracing-opentelemetry publishes the OpenTelemetry context when a span is
entered, and the logs SDK stamps it onto each record. That correlation is
asserted by a unit test rather than assumed from a dependency default, since it
would fail silently if the default ever changed.
Redaction is unaffected: this exports the same tracing events the stdout layer
renders, so anything already kept out of logs stays out of them.
Process metrics
Alongside the domain counters, the process reports its own vitals: resident and virtual memory, CPU time, open file descriptors, thread count and uptime. These are the numbers an operator reaches for first when a node degrades, and the gateway previously answered none of them.
They are observable instruments read from /proc, so nothing touches the request
path. On a platform where /proc is unavailable the instruments are not
registered at all, rather than registered and always zero — a metric that reads
zero forever is worse than an absent one, because a dashboard cannot tell it from
a healthy process. Names follow the OTel process.* conventions.
Runtime metrics
The process metrics describe the machine; these describe the scheduler.
tokio.runtime.queue.depth is the one that matters: when latency rises, no
other signal separates “the provider is slow” from “the request sat in our own
run queue before we ever called the provider”, and the two have opposite fixes.
It pairs directly with the queue.wait span. Alongside it are
tokio.runtime.workers, tokio.runtime.tasks.alive and
tokio.runtime.worker.busy.time.
Busy time is exported, not a busy ratio: a ratio computed in-process would
average over whatever interval the SDK happens to use and would not re-aggregate
across instances. As a monotonic counter the backend derives utilisation with
rate(tokio.runtime.worker.busy.time) / tokio.runtime.workers, which does.
None of this requires --cfg tokio_unstable. #834 assumed it did, and that is
true only of part of tokio’s surface: num_workers, num_alive_tasks and
global_queue_depth are stable, and worker_total_busy_duration sits behind
cfg_64bit_metrics!, which is #[cfg(target_has_atomic = "64")] — a property
of the target, not an instability gate. What remains genuinely gated is the
blocking pool and the per-worker steal/poll counters, which are therefore not
exported; the workspace-wide flag decision stays unmade rather than being
smuggled in with a telemetry change.
Outside an async runtime the instruments are not registered, for the same reason
the process metrics are absent without /proc.
Connection-pool metrics
rolter_upstream_connections_total and rolter_upstream_connect_errors_total
count connections established to providers, and attempts that never got that
far.
Pool exhaustion presents as latency with healthy providers — the failure mode
the other metrics cannot explain. reqwest and hyper expose no pool
introspection at all, so this is instrumented rather than read: a tower layer
over the connector (ClientBuilder::connector_layer) sees every connection
hyper builds because it had none to reuse. The signal is the ratio
rate(rolter_upstream_connections_total) / rate(rolter_requests_total) — near
zero when the pool is working, approaching one when every request is paying a
fresh TCP and TLS handshake.
Idle-versus-active counts stay unavailable: that needs the connection object’s
drop, and the connector layer’s response type is opaque outside reqwest.
Resource attributes
Every signal carries service.name, service.version (the crate version), and
where configured service.instance.id and deployment.environment.name.
OTEL_RESOURCE_ATTRIBUTES is honoured by the SDK for anything else.
service.instance.id deliberately uses the same precedence as the cluster
watcher — ROLTER_NODE_ID, then HOSTNAME, then nothing — so a node in
cluster_nodes and a node in the trace backend are the same node by
construction. When neither is set it is omitted rather than invented per restart,
which would churn the identity on every deploy.
Wrapping audit (#815)
OpenTelemetry’s Don’t wrap OpenTelemetry argues that a house abstraction over the instrumentation API costs performance, maintainability and developer education. Three anti-patterns are named: wrappers that force callers to allocate an attribute collection, wrappers that look an instrument up by name per measurement, and general API abstraction that teaches a proprietary interface instead of the standard.
rolter has four things sitting between its code and the OTel API. Each was audited against that post; the verdict is keep for all four, for the reasons below. The post is guidance rather than a mandate, and it asks for a refactor only where there is a measured cost or a real maintenance burden.
Note up front that rolter instruments through tracing + tracing-opentelemetry.
That is the ecosystem bridge, not a bespoke house wrapper, and it is not what the
post argues against. This audit is not a proposal to remove tracing.
| Item | Verdict | Why |
|---|---|---|
stage_span! (rolter-core/src/telemetry.rs) | keep | code generation, not a runtime wrapper |
The scalar-metrics list (Metrics::scalars()) | keep | the by-name lookup is on the export path, not the request path |
RequestHistograms::record | keep | one unavoidable allocation; the alternative is the anti-pattern |
GatewayMakeSpan (rolter-gateway/src/trace.rs) | keep | SDK/layer configuration, explicitly out of scope |
stage_span! expands to a direct tracing::info_span! call guarded by an
is_active() check, so it is closer to the code generation the post recommends
than to a runtime wrapper. It takes tracing’s own compile-time field syntax and
never asks a caller to build a Vec or a slice of attributes, so the
force-allocation anti-pattern does not apply. The guard is the point of the
macro: with no pipeline installed it yields Span::none(), which allocates
nothing.
The scalar-metrics list is the shape most at risk, since install_metrics
registers one observable instrument per scalar and each instrument’s callback
calls collect() and finds its own entry by name. That is a by-name lookup, but
it is not on a hot path: the instruments are observable, so the SDK invokes
those callbacks on its own export interval (OTEL_METRIC_EXPORT_INTERVAL,
default 60s). The request path only ever does fetch_add on a named AtomicU64
field — there is no map, no lookup and no lock between a request and its counter.
The post’s performance argument therefore does not bite here even though the
gateway hot path is where it would bite hardest.
What the export path does cost is one Vec<ScalarMetric> allocation per
instrument per cycle and a linear scan of it, so the work is quadratic in the
number of scalars. At the current eight scalars, once a minute, that is
immaterial. It is left as-is deliberately: the OTel Rust 0.32 API offers only
per-instrument with_callback, so collecting once per cycle for all instruments
is not expressible, and matching by name rather than by index keeps the callbacks
independent of the order scalars() happens to return.
RequestHistograms::record is the one place a wrapper does force an
allocation on the request path — model.to_string(), to build the single
KeyValue both histograms take. It is unavoidable rather than incidental: OTel’s
Value::String holds an owned or 'static string and model names are neither.
The obvious way to avoid it is to cache a prebuilt attribute set per model, which
would put a sharded-lock map lookup on the request path — precisely the
lookup-based anti-pattern the post names, and something AGENTS.md forbids on the
data-plane hot path. One small allocation is the cheaper of the two, and it is
paid only when an OTLP endpoint is configured.
GatewayMakeSpan is a tower_http::trace::MakeSpan implementation: layer
configuration, which the post explicitly separates from instrumentation and calls
not wrapping. Recorded here only so the audit is complete.
Turning telemetry off explicitly
ROLTER_TELEMETRY_ENABLED=false hard-disables every export — traces, metrics and
the dashboard’s browser tracing — regardless of which OTEL_* endpoints are set
(#812). Unset means enabled, which changes nothing for an existing deployment:
with no endpoint configured nothing is exported anyway.
The switch can only subtract. It never turns export on by itself, and an
unrecognized value leaves export on rather than silently blinding a deployment;
only 0, false, no and off disable it.
It exists because “off” was previously implicit — achieved by leaving an endpoint unset — which does not survive somebody setting the endpoint for one signal and gives an operator nothing to point at in a security review. It is deliberately environment-only and has no config-file equivalent; see ADR-0026, which also records why per-tenant telemetry destinations belong in the collector rather than in rolter.
Cost when tracing is off
With no OTEL_EXPORTER_OTLP_ENDPOINT (the default) behaviour and hot-path cost
are unchanged: telemetry::is_active() is a single relaxed atomic load, stage
spans are Span::none() (no allocation, and instrumenting a future with one is a
no-op), no carrier is built, and outbound trace headers are copied verbatim
exactly as before.
Exporters (OTel-compatible)
rolter emits traces and metrics via OpenTelemetry OTLP (gRPC/HTTP), so any OTel-compatible backend works without code changes — just set an endpoint and headers:
- SigNoz, Grafana Tempo/Mimir, Honeycomb, Datadog (OTLP intake or the OTel Collector
datadogexporter). - Langfuse for LLM-specific observability (prompt/response, token usage and cost as traces), ingested via its OTLP endpoint or SDK.
Recommended topology: rolter → OpenTelemetry Collector → fan-out to the chosen backends. The collector also scrapes the upstream engines’ /metrics and rolter’s /metrics, keeping vendor specifics out of rolter. Configure via env, e.g. OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_SERVICE_NAME=rolter-gateway.
infra/otel/collector.yaml is a ready-to-run
local collector example: it accepts OTLP/gRPC and OTLP/HTTP, scrapes the
compose gateway’s Prometheus endpoint, exposes collected metrics on :8889,
and prints telemetry via the debug exporter. It intentionally has no external
backend configured, keeping the default example safe for air-gapped use. Replace
the debug exporter with an internal OTLP-compatible destination for production.
Connector delivery: rendered collector config (#836)
The observability_connectors table (#511, docs/architecture/data-model.md)
lists the sinks a deployment wants its telemetry shipped to in addition to
ClickHouse, but the connector’s endpoint is never called directly by rolter.
Consistent with ADR-0026
— fan-out belongs in the collector, not N in-process SDK exporters each
carrying its own connection/queue/retry buffer, and terminating an
operator-supplied URL in the collector keeps that SSRF surface out of the data
plane — GET /api/v1/connectors/collector-config (superadmin-only) renders an
OpenTelemetry Collector config document from the enabled rows instead: one
otlphttp exporter and one traces/metrics/logs pipeline set per
connector, receiving from the otlp receiver rolter’s own
OTEL_EXPORTER_OTLP_* export targets.
Point a collector at it with the confmap HTTP provider
(otelcol --config=http://control:4001/api/v1/connectors/collector-config,
bearer-authenticated the same as any other control-plane endpoint), or fetch it
on a schedule and reload. sampling_rate becomes a probabilistic_sampler
processor scoped to that connector’s own pipeline, since sampling is now the
collector’s decision, applied independently per destination rather than once
for the whole deployment. A managed secret (managed_auth_secret) is decrypted
server-side and rendered as a literal Authorization: Bearer header; an
external reference (auth_secret_ref) instead renders as a ${env:...}
placeholder the collector’s own environment must resolve, since rolter never
holds that secret’s value.
ROLTER_TELEMETRY_ENABLED wins over every
connector row: when it is off, the rendered document has no exporters and no
pipelines regardless of what is enabled in the registry, the same way it
already silences every in-process exporter.
Backpressure to a slow or dead sink is the collector’s problem now, not rolter’s: it already queues and retries per exporter, which is the reason fan-out moved here in the first place rather than something this endpoint needs to build.
The remaining sink kinds from #511 (Datadog, Prometheus remote-write,
Langfuse) become an exporter-block case in this renderer, not a new in-process
delivery adapter — widening KINDS in connectors.rs and the kind check
constraint in migration 0062 is the only rolter-side change; everything else
is collector configuration.
Request & cost logs
- Every proxied request is logged to ClickHouse (
request_logs): identifiers, model, provider/target, status, token counts,cost_usd, latency, TTFT, cache flag, error. - Retention defaults to 90 days for metadata and seven days for captured payloads, set as the TTL in the ClickHouse schema. Both are admin-managed:
PUT /api/v1/logging-settingsacceptsretention_days(1–3650) andpayload_retention_hours(1–8760) and issues the matchingalter table … modify ttlagainst ClickHouse, which then expires parts on its own schedule. Payload retention may not exceed metadata retention, so raw prompt bodies never outlive the row they belong to. A ClickHouse failure leaves the stored policy in place and is logged rather than failing the admin write — re-saving reapplies it. - Payload capture is disabled by default. Set
[logging.payload_capture] enabled = trueto write redacted request and response payloads to the separaterequest_payloadstable, which has a seven-day TTL (versus 90 days for request metadata).max_bytesbounds each body;redact_fieldsadds recursively redacted JSON keys before storage. Optionalmodelsandvirtual_key_idsallow-lists make the deployment-level switch route- or key-specific. - Request id / trace continuation: every request carries an
x-request-id— the caller’s when supplied, otherwise a generated UUID — which is echoed on the response and stored on the log row for end-to-end correlation. An inbound W3Ctraceparentor B3 (b3/x-b3-traceid) header is parsed and its trace id stored inrequest_logs.trace_id, so gateway logs join the caller’s distributed trace instead of starting a disconnected one. - Outbound propagation: when the caller sent trace context, it is forwarded verbatim to the chosen upstream (
traceparent,tracestate, and theb3/x-b3-*family) so vLLM/SGLang/TGI continue the same trace. An untraced request adds nothing to the upstream wire — this is the caller’s own context, not a rolter fingerprint, so it preserves wire transparency. - Writes are async and batched off the hot path so logging never adds request latency.
- The dashboard queries ClickHouse for usage, spend, latency percentiles and error rates, sliced by org/team/project/key/model.
Provider health events
- Every health signal is written to ClickHouse (
provider_health_events):target_id,provider,source,outcome,status_code,latency_ms,error_kind, timestamped by ClickHouse on insert. sourcedistinguishes where the observation came from:passive(real traffic completing through the request funnel),probe(active liveness sweeps), and the opt-inllm_call/status_pagesources.outcomeisok/error/timeout;error_kindgives a coarse label (rate_limited,upstream_error,connect_error,timeout).- Writes reuse the same async, batched, off-hot-path writer and ClickHouse endpoint as
request_logs; when noclickhouse_urlis configured the sink is a no-op. - Counters
rolter_health_events_written_totalandrolter_health_events_dropped_totaltrack the writer, mirroring the request-log counters. - This event stream feeds uptime %/MTTR rollups and the dashboard health panel.
Stability rollup API
Read-only, window-bounded rollups over provider_health_events, served by the control plane when --clickhouse-url is set (otherwise 503). All accept since/until (RFC3339, default last 7 days); time bounds are passed as ClickHouse query parameters, never interpolated.
GET /api/v1/health/uptime— per provider/target: event counts,uptime,failure_rate,error_budget_burnandsla_breachedagainst anslatarget (query param, fraction in(0,1], default0.99), andlast_event.GET /api/v1/health/mttr— per provider/target mean time to recovery (mttr_seconds) and incident count, computed from downtime episodes (a run of non-okevents bounded byok).GET /api/v1/health/timeline?bucket=hour|day|week|month— bucketed ok/error/timeout counts per provider/target for the failure timeline (default buckethour).
Health
GET /healthzon both binaries for liveness probes.GET /readyzon the gateway for readiness. It returns503 drainingonce the control plane marks the node as draining (PUT /api/v1/cluster/nodes/{id}/drain), so a load balancer stops sending new traffic while in-flight requests finish;/healthzstays200because the process is healthy. The drain reaches the node on the snapshot poll it already makes, and the control plane refuses to drain the last live gateway.
Performance
Goal: beat the reference Python proxy (LiteLLM cites ~8ms P95 added latency at 1k RPS) with a much smaller per-request overhead in Rust.
Hot-path principles
- Lock-free config reads — the routing table is an
ArcSwap<Snapshot>; readers never block, even during a hot reload. - Minimal-copy streaming — upstream responses are piped to the client as a
Body::from_streamover thereqwestbyte stream; rolter does not buffer whole responses. - Connection reuse — pooled
reqwestclients with HTTP/2 keep-alive andtcp_nodelay; one client per egress-proxy target, cached. - Cheap auth — virtual-key lookup is an O(1) hash-map hit on the in-memory snapshot.
- Avoid full deserialization — only the fields needed for routing (
model,stream) are read; the body is forwarded as raw bytes. - Logging off the hot path — usage/cost rows are batched and written to ClickHouse asynchronously.
- Release profile —
lto = "thin",codegen-units = 1,strip = true.
Things to watch
- The approximate cache-aware trie is per-route in-memory state; bound its size with eviction before it grows large.
- Per-request JSON parse for
modelis small but measurable; consider a fast path / partial parse for very high RPS. - Prefer
bytes::Bytes(ref-counted) overVec<u8>copies when rewriting the model field.
Benchmarking
Two layers, neither on the per-PR gate:
- Micro-benches (
just bench) — criterion, covering balancerpickacross every strategy and the prefix trie. Compiled on every PR via clippy so they cannot bit-rot, but not run (timings are noisy on shared runners). - End-to-end load (
just bench-sim/bench-vllm/bench-sglang) —integration/engines/bench.pyagainst a real engine, measuring rolter’s added latency directly and its behaviour under sustained concurrency.
Tool decision (#847, closing #455)
The load harness extends bench.py rather than adopting
GuideLLM or driving load with
oha/k6. Recorded here because #847 required settling it before writing code.
- Air-gapped operation is a hard requirement for rolter.
bench.pyis stdlib-only — no install step, no wheels or binaries to vendor. GuideLLM brings a substantial Python dependency tree;oha/k6are external binaries. A benchmark you cannot run on the disconnected machine you are tuning is not much of a benchmark. - The direct-vs-rolter delta is the entire point of this harness, and no
general-purpose load generator produces it. They measure one endpoint;
added_latency_p50_msneeds both driven identically in the same run under the same conditions. Adopting one of them means keepingbench.pyanyway for the delta — two harnesses instead of one. - ITL needs SSE token boundaries.
ohaandk6measure bytes and requests, not tokens, so they cannot produce inter-token latency at all. GuideLLM does understand tokens, but the two points above still stand. - Reproducible without credentials — the default
simengine (llm-d-inference-sim) needs no API key, so anyone can reproduce a number.
The cost is carrying ~250 lines of stdlib Python for concurrency and knee detection. That was judged cheaper than a dependency tree plus a second harness. Revisit if the harness starts needing tokenizer-aware workload shaping, which is where GuideLLM genuinely earns its footprint.
What it measures
bench.py drives the engine directly and through rolter in the same run:
| Metric | Notes |
|---|---|
ttft_p50/p95_ms | time to first byte |
itl_p50/p95_ms | inter-token latency, streaming only, needs --max-tokens > 1 |
latency_p50/p95/p99_ms | end to end |
requests_per_second | achieved, not offered |
error_rate | non-2xx and transport failures, which is what saturation looks like |
added_latency_p50_ms | rolter minus direct — the headline overhead number |
Profiles: --concurrency holds closed-loop steady state after a warmup, and
--sweep 1,2,4,8,16 walks concurrency upward — a ramp/burst profile — reporting
max sustainable RPS: the highest achieved throughput whose p99 stayed inside
--knee-factor (default 2×) of the lowest-concurrency baseline and which was
not erroring.
That bound matters. Throughput usually keeps climbing well past the point where latency collapses, so a peak RPS with no latency condition attached describes a system nobody would actually run. The report also names the concurrency level where the knee was crossed, so a reader can see where it went rather than only that it did.
just bench-sim # sequential: per-request added latency (fast, unchanged)
just load-sim # concurrency sweep: max sustainable RPS, ITL, error rate
just test-bench # harness unit tests — stdlib only, no engine or network
load-* is deliberately a separate recipe: a sweep across five concurrency
levels takes minutes, and silently making the added-latency run that much slower
would be a bad trade. Tune with LOAD_LEVELS, LOAD_REQUESTS, LOAD_WARMUP
and LOAD_MAX_TOKENS.
Two measurement rules worth knowing when reading the JSON:
- Failed requests are counted, never timed. A connection refused in 0.1 ms
is not a fast request; letting it into the percentiles would make a saturated
system look quicker than a healthy one. They appear in
error_rateinstead. - ITL keys are absent, not zero, when nothing streamed. A reported ITL of 0 would read as “instant tokens” rather than “not measured”.
Baseline
No baseline snapshot is recorded yet. It needs a run on fixed, documented hardware, and a number taken on a laptop under thermal throttling would be worse than none — an unreproducible baseline invites false regressions. Record hardware, engine version and rolter version alongside the numbers when one is taken.
Inference engines
The ROL-238 suite checks rolter against OpenAI-compatible engine servers. Output is intentionally meaningless; this validates the HTTP, OpenAI JSON, and SSE contracts rather than model quality.
The default engine, sim, is llm-d-inference-sim:
a ~30MB multi-arch vLLM API simulator that needs no model downloads and boots
in milliseconds, which makes the suite cheap enough to run as a regular PR
check. The real CPU vLLM and SGLang profiles remain for on-demand runs; they
use trl-internal-testing/tiny-random-LlamaForCausalLM only for its
configuration/tokenizer and initialize random weights with
--load-format dummy (with a head_dim=64 override, since the CPU attention
kernels reject the model’s native head_dim=4). The CPU vLLM profile uses
eager execution to avoid expensive compilation warm-up during CI smoke runs.
It runs on CPU in Docker and therefore works on GitHub-hosted runners. Each engine profile starts two independent dummy upstreams so the gateway exercises a real target pool. Run one engine locally:
just integration-sim
just integration-vllm
just integration-sglang
Each command boots routes for every balancing strategy (round-robin, random,
power-of-two, consistent-hash, cache-aware, weighted, pipeline, cheapest, and
fastest). It verifies /v1/models, non-streaming chat, and SSE both directly
and through rolter, and explicitly confirms round-robin reaches both targets.
Logs are kept in artifacts/engines/<engine>/.
Local end-to-end run
just integration-vllm and just integration-sglang start two CPU
dummy-weight servers, render the gateway configuration, and run the OpenAI JSON
and SSE assertions. The runner cleans up all containers and child processes on
exit and preserves the combined engine log plus the gateway log under
artifacts/engines/<engine>/.
For manual inspection, start the selected two-server pool and leave it running:
docker compose -f docker/docker-compose.engines.yml --profile vllm up -d
# use profile sglang for ports 30000 and 30001
Render the gateway configuration in another terminal and start rolter:
config=$(mktemp)
sed \
-e 's/__ROLTER_PORT__/4010/g' \
-e 's/__ENGINE_1_PORT__/8000/g' \
-e 's/__ENGINE_2_PORT__/8001/g' \
integration/engines/rolter-dummy.toml.in >"$config"
cargo run -p rolter-gateway -- --config "$config"
Verify non-streaming JSON and streaming SSE through the gateway:
curl -i http://127.0.0.1:4010/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"dummy-round-robin","messages":[{"role":"user","content":"Reply with one token."}],"max_tokens":1,"temperature":0}'
curl -N http://127.0.0.1:4010/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"dummy-round-robin","stream":true,"messages":[{"role":"user","content":"Reply with one token."}],"max_tokens":1,"temperature":0}'
Exercise all configured strategies; each request must return a non-empty
OpenAI choices array:
for model in dummy-round-robin dummy-random dummy-power-of-two \
dummy-consistent-hash dummy-cache-aware dummy-weighted dummy-pipeline \
dummy-cheapest dummy-fastest; do
curl -fsS http://127.0.0.1:4010/v1/chat/completions \
-H 'content-type: application/json' \
-d "{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1}" \
| jq -e '.choices | length > 0' >/dev/null
done
Clean up the pool and temporary gateway configuration:
docker compose -f docker/docker-compose.engines.yml --profile vllm down --volumes
rm -f "$config"
For non-gating direct-versus-gateway samples, run just bench-vllm or just bench-sglang. They record non-streaming and streaming p50/p95/p99 latency and
streaming first-byte time in JSON. Results only compare runs on the same host,
CPU image, engine versions, and host configuration; throughput thresholds are
deliberately not merge gates.
The engine integration workflow runs the sim smoke as a regular check on
pull requests that touch engine paths. Dispatch it manually with
engine=vllm to smoke the real CPU engine (Actions tab, or
gh workflow run "engine integration" -f engine=vllm). SGLang remains
available through the local just integration-sglang command, but its
source-built CPU image is currently too heavy for the shared CI gate. This
suite is for compatibility, not a performance gate.
When ROL-67
lands, add the equivalent /v1/messages assertion through the gateway.
Architecture Decision Records
Lightweight decision log. Each entry: Status · Context · Decision · Consequences. Supersede rather than rewrite.
A standalone record opens with its title and a single metadata line — no table:
# Title of the decision
**Status:** Accepted · **Date:** 6 Aug 2026 · **Issues:** [#812](https://github.com/rolter-ai/rolter/issues/812)
**Supersedes:** ADR-0007 for exact vLLM modes
**Relates:** ADR-0022 (config-vs-DB tiering)
## Context
Status, Date and Issues are the line; Supersedes and Relates get their
own line and are omitted when there is nothing to say. Nothing else belongs
there — authorship and dates are what git is for, and a field whose value is
either constant across every record or a — (unassigned) placeholder is noise
that makes the actual decision harder to find. Records are English only.
ADR-0001 — Rust + Axum/Hyper/Tower for the data plane
Accepted. Need maximum proxy throughput with rich API semantics and SSE streaming. Chose Axum/Hyper/Tower on Tokio over Pingora/Actix for ecosystem fit and ergonomics. Consequence: idiomatic async stack; revisit Pingora only if profiling demands it.
ADR-0002 — Two-binary topology over shared crates
Accepted. Keep the hot proxy path lean and independently scalable from management. rolter-gateway (data plane) and rolter-control (management + UI host) share library crates. Consequence: clear seam; some duplicated wiring.
ADR-0003 — Vite + React + shadcn/ui SPA, Bun toolchain, served by Rust
Accepted. shadcn/ui (Radix + Tailwind) for the dashboard, built with Vite and managed with Bun; output is static assets served by rolter-control (no Node runtime in prod). Consequence: simple prod footprint; Bun used for install/dev/build.
ADR-0004 — Postgres + Redis + ClickHouse
Accepted. Postgres = source of truth (config/RBAC/keys/pricing); Redis = cache + rate-limit counters + config pub/sub; ClickHouse = high-volume request/cost logs. No SQLite. Consequence: three datastores to operate; each fits its job.
ADR-0005 — Org → Team → Project → Virtual Key tenancy
Accepted. Budgets and rate limits attach at any scope, most-restrictive-wins. Consequence: flexible multi-tenancy; enforcement must resolve a scope chain.
ADR-0006 — Local accounts + virtual keys + roles; SSO/LDAP later
Accepted. v1 ships local accounts (argon2id) and roles admin/member/viewer; OAuth2/OIDC and LDAP arrive as pluggable identity providers. Consequence: usable day one without an IdP.
ADR-0007 — Approximate cache-aware balancing behind a pluggable trait
Accepted. v1 uses an approximate per-target prefix trie (no engine coupling) behind LoadBalancer. Precise (KV-events) and lmcache-aware land later without API changes. Consequence: immediate wins; precise mode is additive.
ADR-0008 — Reload-free config: Postgres truth + Redis pub/sub + ArcSwap
Accepted. Control plane writes Postgres, bumps a version, publishes on Redis; gateways fetch and atomically swap an in-memory snapshot, reconciling by version. Consequence: instant fan-out, self-healing, lock-free reads.
ADR-0009 — Envelope encryption for provider secrets
Accepted. Upstream keys are AES-256-GCM envelope-encrypted with a master key from env/file; Vault/KMS backends later. Consequence: no plaintext secrets at rest; master key is the critical secret.
ADR-0010 — Packaging: maturin (uv) + cargo + Docker
Accepted. Ship a maturin-built PyPI wheel bundling the unified rolter launcher (uv tool install rolter), cargo install rolter, and a multi-stage Docker image. The rolter binary dispatches to gateway/control subcommands so one wheel/crate ships the whole system. Consequence: three distribution paths from a single named artifact.
ADR-0011 — API surface v1
Accepted. OpenAI /v1/chat/completions, /v1/completions, /v1/models and Anthropic /v1/messages. Embeddings, images, audio and other modalities follow. Consequence: drop-in for the two dominant client SDKs first.
ADR-0012 — Conventional Commits + CI PR-title lint
Accepted. Commit messages and PR titles follow Conventional Commits; enforced by commitlint, conventional-pre-commit, and a CI PR-title check. Consequence: consistent history, automatable changelogs/releases.
ADR-0013 — OpenTelemetry-based observability with engine propagation
Accepted. Export traces/metrics via OTLP to any compatible backend (SigNoz, Datadog, Grafana, Langfuse, …); propagate W3C trace context to vLLM/SGLang so engine spans join the same trace; federate upstream engine metrics. Consequence: vendor-neutral observability; an OTel Collector is the recommended hub.
ADR-0014 — Extensible API protocol translation
Accepted. Resolve translation by client/upstream protocol pair in rolter-proxy, including incremental SSE, while the gateway retains transport, caching and accounting ownership. Consequence: new provider dialects extend one translation boundary; non-equivalent modalities remain explicit and are never silently dropped.
ADR-0015 — OpenAI Responses API translation
Development. Add OpenAI Responses as a protocol pair for native OpenAI, Chat Completions and Anthropic Messages, while model-less lifecycle operations remain uniformly unsupported until tenant-scoped storage exists.
ADR-0016 — Routing OpenAI Responses resources through a tenant-scoped registry
Development. Pin Responses lifecycle operations to a bounded tenant-scoped process-local record, preserving the original provider credential while making unknown and cross-tenant IDs indistinguishable.
ADR-0017 — Provider/model addressing to disambiguate identical model names
Accepted. First-class provider-slug/model addressing coexists with named routes: a stable, URL-safe provider slug resolves provider-slug/model to a pinned (provider, upstream_model) target (avoiding LiteLLM’s base_url ambiguity). Pinning bypasses cross-provider fan-out but still balances within the provider. Slugs are org-scoped and collision-safe: deterministic reported migration backfill with -N de-dup, 409+suggestion on runtime create-conflict (never silent suffixing), soft-delete-preferred reclaim. Addendum: provider groups unify fleets (e.g. ten vLLM instances) under one group slug in the same namespace — vllm-cluster/qwen3 fans out across members with a chosen balancing strategy and passthrough model names, so no route-per-model. Consequence: new immutable slug column + proxy parsing + /v1/models + UI work + provider-groups entity (see follow-up issues).
ADR-0018 — Config mutated via granular CRUD, not whole-config replace
Accepted. The normalized Postgres store (ADR-0004, source of truth) is mutated only through the scoped CRUD API — providers, routes, targets, virtual keys — each write bumping the config version and hot-reloading gateways via the snapshot poll (ADR-0008). PostgresConfigStore::save is deliberately read-only: there is no whole-config “apply” endpoint, because a full replace would fight the normalized model and clobber concurrent edits. The dashboard Config page is a read-only effective-config viewer; the /gw reverse-proxy that fronts the gateway for the Playground is unrelated to config writes. Consequence: one live-edit path (CRUD) with instant fan-out; a raw whole-config editor would need a deliberate transactional diff/apply (guarding config-owned entries, preserving key hashes) and is out of scope (closed #494).
ADR-0019 — Per-provider egress proxy pools
Accepted. Rotate across a provider-local proxy pool, fail over only connection/tunnel failures, and quarantine repeatedly failing members. Authenticated URLs are resolved exclusively from whole-value environment references. Consequence: resilient egress without leaking credentials; health state remains process-local.
ADR-0020 — Bounded semantic response caching in Redis
Accepted. Run semantic lookup only after an exact miss, embed through an explicitly configured provider, and scan a bounded recent Redis window. Consequence: similarity reuse without another datastore; embedding and cache failures fail open and candidate search remains deliberately bounded.
ADR-0021 — External cache telemetry for routing
Accepted. Extend ADR-0007 with opt-in vLLM KV-event and LMCache occupancy scorers whose network I/O runs in background tasks. Consequence: exact/capacity-aware routing stays allocation-light and fail-open; stale or untrusted telemetry becomes neutral and least-load selection remains available.
ADR-0022 — Uniform config-vs-DB tiering for models, providers, and provider groups
Accepted. Give models, providers, and provider groups the same two-tier config wrapper over the DB (CRUD) tier: readonly entries are immutable and config-owned; default entries are seeded into the default project once at startup and then owned/editable via API/UI; pure DB rows come from CRUD. readonly wins resolution; a default colliding with a readonly key is a load-time error. Top-level [[routes]]/[[providers]]/[[provider_groups]] stay as deprecated readonly aliases for back-compat. Consequence: providers gain a seed-then-edit story and provider groups gain a full DB lifecycle (new tables/repos/seed/CRUD, tracked as follow-up PRs); the model tiering pattern is reused rather than reinvented per entity.
ADR-0023 — Propagating access-profile model policy to the data plane
Accepted. Resolve each virtual key owner’s merged access-profile policy when the snapshot is built, carry it on the key record, and enforce it on the gateway’s model and route selection; the key’s own allow-list and the owner’s policy must both permit a model. Rejects key-mint-time resolution because a later policy edit would never reach issued keys, so a revocation would silently fail to revoke. Adds bump_config_version() triggers to exactly the four tables that feed the resolution, changing migration 0058’s premise rather than its rule. Consequence: the policy /rbac/effective reports is the policy enforced, and a deployment with no access profiles is unaffected.
ADR-0024 — Dashboard UX telemetry as a structural-only event stream
Accepted. Collect dashboard usability events into a ui_events ClickHouse table beside request_logs rather than adding a product-analytics vendor, and make the schema itself the privacy guarantee: every column is a key, an enum, a duration or an id, so a form value or prompt has nowhere to land. Attribution is server-side; the ingest endpoint is guarded by CurrentUser with no capability row, because a create capability granted to any authenticated caller would break the RBAC invariant that a viewer writes nothing. Consequence: usability data with no new vendor, egress or retention policy, joinable to gateway traffic on trace_id; the cost is that any question needing a value rather than a key is unanswerable by design.
ADR-0025 — Events as logs, not span events
Accepted. OpenTelemetry is deprecating the Span Events API, so log-based events become rolter’s event model: no new explicit add_event/record_exception, and #809 (OTLP log export) sequences before #808 (GenAI conventions) because those conventions are specified as log-based events and cannot be emitted until logs are exported at all. A grep finds zero uses of the deprecated API, but tracing-opentelemetry turns every tracing event fired inside a span into a span event, so rolter is in practice an exclusive implicit user of it — which makes the migration a dependency upgrade in one bridge rather than a sweep of 57 call sites. Consequence: tracing stays; #809’s trace_id/span_id correlation becomes a blocking acceptance criterion rather than a nicety; new event types have no correct home until it lands, and should wait rather than add a span event that must later be removed.
ADR-0026 — Client control over telemetry: a kill switch now, collector-routed tenant destinations later
Accepted. Splits #812 in two. ROLTER_TELEMETRY_ENABLED ships now as one explicit off for every signal — traces, metrics, the dashboard’s browser tracing, and logs once #809 lands — that can only subtract, defaults to enabled so no existing deployment changes, and leaves export on for an unrecognized value so a typo cannot silently blind a deployment. It is environment-only despite the issue asking for a config key: telemetry::init installs the subscriber before any config file is read, so a config-file switch could not gate trace export at all. Per-tenant destinations are deferred and, when built, belong in the OpenTelemetry Collector’s routing processor keyed on an org resource attribute rather than in-process exporters — that keeps one egress path in the gateway and keeps tenant-supplied URLs, an SSRF surface, out of the data plane entirely. Consequence: operators get a reviewable “no telemetry leaves here” today; per-tenant routing later requires running a collector, which is already the recommended topology.
ADR-0027 — End-to-end test harness: Python/uv project driving a black-box stack
Accepted. The e2e suite is a Python driver on the latest CPython managed by uv, exercising the real HTTP APIs of a docker-compose stack (postgres, redis, clickhouse, control, gateway, and N llm-d-inference-sim fake-vLLM engines) with no in-process shortcuts, so the tests see exactly what an operator or tenant sees. Numbered after ADR-0026 despite its earlier date because ADR numbers are append-only and never reused. Consequence: the suite needs a container runtime rather than cargo test alone; in exchange it covers the wiring between the two binaries and the three datastores that unit tests cannot reach.
ADR-0028 — Disaggregated prefill/decode routing belongs to the engine, not the gateway
Accepted. rolter will not implement P/D disaggregation; a disaggregated fleet is one upstream and its own coordinator owns phase selection and the KV handoff. Engines move KV tensors through connector-selected transports such as NIXL/UCX. Some can coordinate that transfer through engine-specific HTTP fields, but rolter would still have to own compatible-worker topology, version-specific metadata, two upstream calls and per-request handoff state — exactly the lifecycle coupling ADR-0014 prevents. The distinguishing test is whether rolter can consume an input through a stable, engine-independent contract without joining the engine’s request lifecycle. Consequence: disaggregated fleets work with rolter today for any engine, with no code and no mid-request-handoff failure modes; revisit if a stable phase-placement contract emerges on the OpenAI or Anthropic surface.
OpenAI Responses API translation
Status: Development · Date: 13 Jul 2026 · Issues: ROL-252
Context
ROL-252 adds the public POST /v1/responses. The gateway already routes on model, and rolter-proxy already holds an isolated registry of OpenAI Chat Completions and Anthropic Messages translations with incremental SSE handling. Responses carries text, multimodal input, tools, tool results and its own streaming event model. Lifecycle operations use a response identifier without a model, so they cannot safely select a route without storing the tenant/key → upstream response association.
Options considered
Option 1 — Native passthrough only
Forward /v1/responses to every provider without translation.
flowchart LR
C[Responses client] --> G[rolter gateway]
G --> P[upstream /v1/responses]
Option 2 — Protocol registry with Responses
Add Responses as a protocol: native OpenAI gets passthrough, while Chat Completions and Anthropic Messages get adapted requests and return Responses objects or SSE events.
flowchart LR
C[Responses client] --> G[rolter gateway]
G -->|OpenAI| R["/v1/responses"]
G -->|Chat-compatible| CC["/v1/chat/completions"]
G -->|Anthropic| AM["/v1/messages"]
R --> G
CC --> G
AM --> G
Comparison
| Option | Pros | Cons |
|---|---|---|
| 1. Native passthrough only | No translation and no loss for a Responses-native upstream | Chat and Anthropic routes do not support the new surface |
| 2. Protocol registry with Responses | One public contract for OpenAI, Chat and Anthropic; transport, metrics and accounting stay shared | Not every Responses capability has an equivalent; an SSE converter has to be maintained |
Decision
Option 2 was chosen. Protocol::OpenAiResponses is added to the rolter-proxy registry. ProviderKind::Openai uses the native /v1/responses; Chat-compatible providers use /v1/chat/completions; Anthropic uses /v1/messages. Common fields are translated, and responses and streaming events are returned in the Responses representation.
Rationale
This preserves the existing architectural boundary: the gateway owns authentication, tenant scope, target selection, tracing, metrics and accounting, while rolter-proxy owns only the wire protocol. Native Responses loses no provider-specific fields, and the existing Chat and Anthropic routes stay reachable for clients on the new OpenAI SDK.
Consequences
Benefits:
- one shared Responses endpoint works across three families of upstream APIs;
- SSE is translated incrementally, without buffering the live stream;
input_tokens/output_tokensare counted by the existing cost and rate-limit accounting.
Drawbacks and risks:
background,store,previous_response_idand provider-specific reasoning have no safe Chat/Anthropic equivalent and are not forwarded there;- lifecycle for native OpenAI is implemented by a separate tenant-scoped registry in ADR-0016; translated Chat/Anthropic resources still return
501 response_lifecycle_unsupported; - supporting new Responses event types requires extending the converter and its tests.
System impact:
rolter-gateway, rolter-proxy, the OpenAPI spec and the API documentation change; the storage schema does not.
Related records
- ADR-0014 — Extensible API protocol translation
- ROL-252 — OpenAI Responses API passthrough and streaming
Open questions
- Consider a distributed backend registry for multi-replica deployments without sticky routing.
- Extend streaming translation of function-call and reasoning event types as supported upstream contracts are confirmed.
Routing OpenAI Responses resources through a tenant-scoped registry
Status: Development · Date: 13 Jul 2026 · Issues: ROL-252, ROL-264
Context
ROL-252 added OpenAI Responses creation, routed on the model field. ROL-264 adds GET and DELETE /v1/responses/{id}, POST /v1/responses/{id}/cancel and GET /v1/responses/{id}/input_items. These requests carry no model, tenant or provider, and the upstream identifier cannot safely be used to re-balance: the request could go to a different provider, target or provider key and expose another tenant’s resource.
The gateway already authenticates the virtual key, atomically refreshes the routing snapshot and selects a concrete provider credential. A safe lifecycle requires remembering that decision after a successful POST /v1/responses, streaming responses included. Translated Chat Completions and Anthropic Messages do not create a persistent upstream Responses resource.
Options considered
Option 1 — Re-select the route from the response ID
Hash response_id and use the current balancer or iterate over providers.
flowchart LR
C[Lifecycle client] --> G[rolter gateway]
G --> B{Current balancer}
B --> P1[Provider A]
B --> P2[Provider B]
Option 2 — Bounded process-local tenant registry
After a successful creation, store the composite key virtual-key digest + response_id and pin it to the provider, target, model and provider-credential fingerprint. The entry has a TTL and is removed after a successful DELETE.
sequenceDiagram
participant C as Client
participant G as Gateway
participant R as Tenant registry
participant P as Native OpenAI provider
C->>G: POST /v1/responses with model
G->>P: create via selected target and credential
P-->>G: response ID or SSE response event
G->>R: store tenant plus pinned route and capabilities
C->>G: GET, DELETE, cancel or input_items by ID
G->>R: lookup by authenticated tenant plus ID
R-->>G: pinned native route
G->>P: lifecycle request with same credential
Option 3 — Distributed registry in Redis
Store the same entries in shared Redis with a TTL, so that any gateway replica can serve a lifecycle request.
flowchart LR
C[Client] --> G1[Gateway replica 1]
C --> G2[Gateway replica 2]
G1 --> R[(Redis registry)]
G2 --> R
G1 --> P[OpenAI provider]
G2 --> P
Comparison
| Option | Pros | Cons |
|---|---|---|
| 1. Re-selection | No new state | Tenant isolation, provider and credential cannot be guaranteed; unsafe |
| 2. Process-local registry | Adds no network hop and no mandatory infrastructure to the data plane; the synchronous write is available as soon as the response completes | Requires sticky routing between replicas; entries are lost on restart |
| 3. Redis registry | Works across replicas and survives a gateway restart | Redis becomes mandatory for lifecycle; adds latency, a failure mode and a write race after SSE completion |
Decision
Option 2 was chosen. rolter-gateway keeps a bounded process-local registry. The key is composed of a peppered digest of the virtual key and the public response_id; the plaintext key is not stored. The value records the provider, target, public model, provider-native ID, the fingerprint of the selected provider credential and capability flags.
By default an entry lives for 24 hours and a single process holds at most 100,000 entries. Both are configured through [responses] registry_ttl_secs and registry_max_entries; a zero value disables registration. Expiry and overflow are cleaned up lazily on lookup/insert. A successful DELETE removes the entry immediately.
Rationale
A process-local registry closes the main security invariant without requiring Redis on the inference path. The composite key makes unknown, expired and cross-tenant lookups indistinguishable. Pinning to the provider credential prevents reaching the resource through a different upstream account. The fingerprint makes it possible to find the same credential after the key pool is reordered, without storing the secret a second time.
Translated Chat Completions and Anthropic Messages get an entry with empty lifecycle capabilities. That allows returning a precise 501 response_lifecycle_unsupported to the owner while keeping a uniform 404 response_not_found for any other tenant.
Consequences
Benefits:
- a lifecycle request is never re-balanced and always uses the original provider account;
- unknown, expired, deleted, cross-tenant and post-config-change unreachable resources all return the same
404with no route metadata; - the native upstream status and body are forwarded without translation;
- non-streaming JSON and completed SSE are registered from a single completion-observer point.
Drawbacks and risks:
- multi-replica deployments require sticky routing by client or response ID;
- a gateway restart drops the registry earlier than upstream retention;
- the full response is temporarily buffered by the existing accounting stream; the registry reuses that buffer, but the memory footprint is not reduced;
- deleting a provider, changing its kind or rotating the original credential makes the entry unreachable and returns
404.
System impact:
rolter-core (retention configuration), rolter-gateway (registry, lifecycle handlers and response observer), rolter-proxy (model-less forwarding), the OpenAPI spec, the API documentation and the engine smoke suite change. The Postgres/Redis schemas do not.
Related records
- ADR-0005 — Org → Team → Project → Virtual Key tenancy
- ADR-0014 — Extensible API protocol translation
- ADR-0015 — OpenAI Responses API translation
- ROL-252 — OpenAI Responses API passthrough and streaming
- ROL-264 — Responses API lifecycle resources
Open questions
- Add an optional Redis registry backend if lifecycle must work without sticky routing across replicas.
- Extend capability discovery once OpenAI-compatible providers reliably implement the native Responses lifecycle.
Provider/model addressing to disambiguate identical model names
Status: Accepted · Date: 14 Jul 2026 · Issues: ROL-266
Context
When several providers serve the same model name, a client has no first-class
way to say which one it wants. This is common with self-hosted OpenAI-compatible
backends: two vLLM/SGLang instances at different base URLs both serving qwen3, or
an openai-kind provider whose base_url points at a non-OpenAI upstream.
How rolter addresses models today
- A client sends
model= a route name (routes.model, unique per project). - A route fans out to one or more
route_targets, each{provider_id, upstream_model?, weight}. - The proxy rewrites the outgoing
modeltoupstream_modelwhen set (crates/rolter-proxy/src/lib.rs:333,maybe_rewrite_model); target selection and failover happen incrates/rolter-gateway/src/handlers.rs(weighted balancer overentry.route.targets). - Providers are
unique(org_id, name); there is no URL-safeslug.
flowchart LR
C[client: model = route name] --> R[route lookup]
R --> B[balancer over targets]
B --> T1["target A · provider vllm-msk · upstream qwen3"]
B --> T2["target B · provider vllm-spb · upstream qwen3"]
So disambiguation today means the operator invents distinct route names
(qwen3-msk, qwen3-spb). There is no client-facing provider/model addressing and
no convention linking a route name to a concrete provider.
Prior art
- LiteLLM — a two-tier scheme: clients send a public
model_namealias; each deployment carrieslitellm_params.modelwith a provider prefix (openai/gpt-4o) plus its ownapi_base/credentials. Multiple deployments sharing onemodel_nameare load-balanced. The prefix is a routing hint tied to a base_url, so you can pointopenai/...at a Qwen endpoint by swappingbase_url— the prefix is semantically muddy and is not a stable identity. - Envoy AI Gateway — extracts the
modelfield from the request body into anx-ai-eg-modelheader before routing, then applies ordinary header-match rules in anAIGatewayRouteto pick a backend. Selection is out-of-band (header/route config), not encoded in the model string the client sends.
The lesson: keep the address segments stable identities, not free-form hints (avoid LiteLLM’s base_url ambiguity), while still letting a route fan out.
Options considered
Option A — Status quo (distinct route names)
Operators keep inventing unique route names per provider (qwen3-msk).
- Pro: zero code; already works; route name stays the single routing key.
- Con: no convention; clients must know deployment-specific names; poor discovery; the provider is invisible in the address.
Option B — First-class provider-slug/model addressing (coexisting)
Introduce a stable, URL-safe slug on providers. A client may send either a bare
route name (unchanged) or provider-slug/model. The gateway resolves
provider-slug/model to the concrete (provider, upstream_model) pair, pinning the
provider and using model as the upstream model.
flowchart LR
C["client: model = 'vllm-spb/qwen3'"] --> P{"contains '/' and<br/>left = known provider slug?"}
P -- no --> RN[resolve as route name today]
P -- yes --> PR["pin provider vllm-spb"]
PR --> UM["upstream model = 'qwen3'"]
UM --> FW[forward to that provider only]
- Pro: unambiguous, self-describing addressing; slug is a stable identity (fixed kind + base_url), avoiding LiteLLM’s base_url muddiness; coexists with named routes, so it is additive and backward-compatible; maps cleanly to “pick a provider from the list, or add one inline, then reference its models” in the UI.
- Con: needs a new
slugcolumn (migration + uniqueness/charset rules + CRUD/UI); parsing precedence and slash-collision rules to define; a pinned provider bypasses multi-provider fan-out (see open questions — it can still fan out across that provider’s own key pool / targets).
Option C — Auto-derived provider/model aliases (read-only convenience)
No new column: derive an alias by slugifying the existing provider name and pairing
it with each target’s upstream_model, exposed only through /v1/models and accepted
on input.
- Pro: no schema change; quick.
- Con: provider
nameis mutable and onlyunique(org_id, name)— renames silently break addresses; slugifying a display name yields collisions and unstable ids. Same fragility LiteLLM has. Rejected as the primary path.
Option D — Out-of-band provider selector header
Keep model as today; add an optional x-rolter-provider: <slug> header (Envoy-style)
to pin the provider.
- Pro: no change to the
modelstring; no slash-parsing. - Con: not expressible in stock OpenAI/Anthropic SDK
modelfields, so clients that can only setmodel(the common case) cannot use it; discovery is worse. Useful as a complement to B, not a replacement.
Comparison
| Criterion | A (route names) | B (slug/model) | C (derived) | D (header) |
|---|---|---|---|---|
| Client-facing disambiguation | ✗ | ✓ | ✓ | ✓ (header only) |
| Stable identity (rename-safe) | n/a | ✓ | ✗ | ✓ |
Works via stock model field | ✓ | ✓ | ✓ | ✗ |
| Backward compatible | ✓ | ✓ | ✓ | ✓ |
| Schema/UI cost | none | slug column + CRUD/UI | none | header plumbing |
| Avoids LiteLLM base_url ambiguity | n/a | ✓ | ✗ | ✓ |
Recommendation
Adopt Option B — first-class provider-slug/model addressing that coexists with
today’s named routes — and optionally add Option D later as a complementary header
for clients that need to pin a provider without touching model.
Rationale: B gives unambiguous, self-describing, rename-safe addressing that fits the
OpenAI/Anthropic model field clients already use, and matches the desired UX (“pick an
existing provider or add one inline, then reference provider-slug/model”). It is purely
additive: existing bare-model routes keep working.
Proposed resolution semantics (to confirm)
- Precedence: try the whole
modelstring as a route name first (preserves any existing route whose name contains/). If unmatched and the string contains/, split on the first/: if the left segment is a known provider slug in the caller’s scope, treat the right segment as the upstream model and pin that provider; otherwise fall through to the normal not-found path. - Slug:
^[a-z0-9][a-z0-9-]{0,62}$,unique(org_id, slug), immutable-by-default (renaming display name never changes the slug). Backfill existing providers from a slugified name with numeric de-dup on migration. - Balancing: a
provider-slug/modelrequest pins the provider and bypasses cross-provider fan-out, but still uses that provider’s key pool, cooldowns, and (if the provider has multiple same-model targets) intra-provider selection. Bare route names keep full multi-provider balancing. /v1/models: list both — existing route ids andprovider-slug/modelids (grouped by provider in a follow-up UI), so either address is discoverable.
Slug collision handling
Slugs are unique(org_id, slug), so the DB is the final arbiter — but “just add a
unique index” leaves the behaviour around collisions unspecified. This section pins it
down for the three moments a collision can happen. Note first that slugs are org-scoped:
there is no cross-org collision, and provider-slug/model always resolves within the
caller’s org (enforced in the query where org_id = $caller), so the blast radius of any
collision is a single org.
1. Migration backfill (deterministic). Shipped in
crates/rolter-store/migrations/0014_provider_slug.sql:
- Providers are processed per org in a stable order (
idascending viarow_number() over (partition by org_id, base order by id)), so the result is reproducible. - Slugify
name: lowercase, map every run outside[a-z0-9]to a single-, trim leading/trailing-; an empty result falls back toprovider. - The first claimant of a base slug keeps it bare (truncated to 63); each subsequent
collision gets a
-Nsuffix from its row number (vllm,vllm-2,vllm-3, …), the base truncated to 58 chars sobase-Nstill fits. The migration never fails on a collision — it always converges. - Follow-up: emit a migration report (one line per adjusted provider:
org_id, provider_id, name, base_slug, final_slug) so operators can reconcile any externally-published address.
2. Runtime creation (validate, never silently mangle). Shipped in
crates/rolter-control/src/crud.rs:
- An explicit
slugis validated against^[a-z0-9][a-z0-9-]{0,62}$; an omitted one is derived fromname(a name with no ascii alphanumerics requires an explicit slug). A conflict surfaces as the DB unique-violation error — the API never auto-appends a suffix behind the operator’s back, because an address is a contract the human picks explicitly. - Follow-up: map the unique violation to 409 with the next free suggestion
(
{"error": "slug taken", "suggestion": "vllm-2"}), and have the UI pre-check availability before submit. - Slug is immutable by default (it is the stable identity). An in-place change
requires the explicit
allow_slug_change=trueopt-in on update, which warns that theprovider-slug/modeladdress changes with it.
3. Deletion & reclaim (explicit, not automatic). A hard-deleted provider frees its
slug immediately; the next create may reuse it. This is intentional but load-bearing:
reusing openai after deleting the old openai repoints that address to a new
upstream. Therefore:
- Prefer soft-delete for providers that ever served traffic, so a freed slug is not silently re-pointed; a reused slug is then an explicit operator action on a tombstoned name.
- Reclaim is a new identity, never a restore — document that
provider-slug/modelafter reclaim may resolve to different weights/base_url than before.
Decision (14 Jul 2026)
- Adopt Option B, coexisting with named routes.
provider-slug/modelresolves to a pinned(provider, upstream_model); bare route names keep full multi-provider balancing. Named routes are not replaced. - Balancing when pinned: a
provider-slug/modelrequest pins the provider and bypasses cross-provider fan-out, but still fans out within that provider — its key pool, cooldowns, and any same-model targets stay in rotation. - Precedence & slug rules as proposed above (route-name-first, then first-
/split; slug^[a-z0-9][a-z0-9-]{0,62}$,unique(org_id, slug), immutable by default). - Collision policy as in Slug collision handling above: slugs are org-scoped
(no cross-org collision); migration backfill is deterministic with
-Nde-dup (shipped in migration0014); runtime creation validates and rejects conflicts rather than silently suffixing (409 + suggestion as follow-up); slug changes need the explicitallow_slug_changeopt-in; hard-delete frees a slug immediately, but soft-delete is preferred so a reclaimed slug is an explicit operator action, not a silent re-point. - Option D (header selector): deferred, not part of the initial implementation.
Addendum (20 Jul 2026) — provider groups: unifying slugs under one address
Problem
Per-provider slugs alone push fleet operators the wrong way. Run ten vLLM instances
(distinct base_url + credentials each) and you get ten providers — vllm-1 …
vllm-10 — where pinning any single one is the opposite of what the operator wants.
Named routes can unify them, but a route binds one public model name to targets, so a
cluster serving 30 models needs 30 hand-made routes. LiteLLM’s “model group / alias”
answers the per-model case; nothing answers “this whole fleet, all of its models, one
address”.
Decision
Introduce provider groups — an org-scoped, named set of providers that shares the
slug namespace with providers (unique(org_id, slug) enforced across both), so the
left segment of X/model resolves through a single unified lookup and is never
ambiguous.
- Membership: groups are fully operator-defined — any slug, any member set — and
membership is many-to-many: one provider may belong to several groups. Overlapping
groups are the intended way to express scopes, e.g.
vllm-cluster-msk(five Moscow instances),vllm-cluster-nsk(five Novosibirsk instances), andvllm-cluster-all(all ten) — the client picks the scope by address. No group nesting (groups of groups) initially: flat membership covers the scoping cases, nesting adds cycle-detection and resolution complexity for little gain; deferred. - Resolution: precedence unchanged — whole string as route name first, then split on
first
/. The left segment is looked up in the unified slug namespace: a provider slug pins that provider (as decided above); a group slug fans out across the group’s members, balanced with the group’s configured strategy (anyrolter_balancer::LoadBalancer), honoring per-member weight, each member’s key pool, and cooldowns. - Model handling: default is passthrough — the right segment is forwarded
unchanged as the upstream model (the homogeneous-cluster case: every member serves the
same model set). An optional per-member
upstream_modelrewrite covers heterogeneous groups, mirroringroute_targets. - Credentials: unchanged. Clients authenticate with a rolter virtual key; member credentials stay per provider. A group gives one address, one client key, N upstreams with N distinct upstream creds — which is the unification the fleet operator actually needs.
- Relation to routes: a group is effectively a wildcard route family
(
vllm-cluster/*) without creating a route per model. Named routes remain the curated-alias layer for cherry-picked public names; groups cover fleets. /v1/models: group addresses are listed as the deduplicated union of member-served models (vllm-cluster/qwen3,vllm-cluster/llama4, …) alongside provider-slug and route ids.- Collision interplay: groups also soften the collision pressure from the section
above — individual instances can carry mundane slugs (
vllm-msk-1), while the group owns the meaningful address (vllm-cluster, or evenvllm).
Proposed follow-up implementation issues
- store: add immutable
slugto providers — migration,unique(org_id, slug), deterministic reported backfill with-Nde-dup, 409+suggestion on create-conflict, soft-delete-preferred reclaim, CRUD wiring. (relates to ROL-81, see Slug collision handling) - proxy/gateway:
provider-slug/modelparsing + resolution with the precedence rule and provider pinning; interaction withmaybe_rewrite_model/upstream_model; tests. - gateway: extend
/v1/modelsto surfaceprovider-slug/modelids. - ui: model-management surfaces the
provider-slug/modeladdress; inline add-provider flow. (relates to ROL-222) - (optional) proxy:
x-rolter-providerheader selector (Option D). - store/gateway/ui: provider groups —
provider_groupsentity (slug in the unified namespace, strategy, members with weights),group-slug/modelresolution with passthrough + per-member rewrite,/v1/modelsunion listing, group CRUD/UI. (see Addendum: provider groups)
Sources
- LiteLLM proxy config — model_name / litellm_params / load balancing / wildcard
- Envoy AI Gateway — model-name-based routing via
x-ai-eg-model
Per-provider egress proxy pools
Status: Accepted · Date: 18 Jul 2026 · Issues: #305
Context
A provider may need several outbound proxies for regional reachability, IP-based
rate limits, or resilience. The existing singular egress_proxy setting cannot
spread traffic or recover when its proxy becomes unavailable. Proxy credentials
must not enter database rows, config snapshots, API responses, or metrics labels.
Options considered
- Keep one proxy and rely on an external proxy load balancer.
- Select a random proxy independently for every attempt.
- Maintain a per-provider round-robin pool with local failure quarantine.
Decision
Adopt option 3. egress_proxies is the canonical ordered pool, while the legacy
egress_proxy remains a backward-compatible one-element pool. Each member owns a
cached reqwest client and may use HTTP, HTTPS, SOCKS5, or SOCKS5H. Requests start
at the next round-robin member and retry connect or tunnel failures through the
remaining eligible members. Three consecutive failures quarantine a member for
30 seconds; a successful request clears its failure streak.
Authenticated URLs are accepted only as whole-value ${ENV_VAR} references.
Metrics identify members with a redacted stable label and never expose userinfo.
Consequences
- Providers can spread traffic and survive an individual proxy failure without a separate egress load balancer.
- Existing singular configuration continues to work unchanged.
- Retry is deliberately limited to connection/tunnel failures; retrying arbitrary HTTP responses could duplicate a request whose upstream already processed it.
- Quarantine state and round-robin position are process-local, so gateway replicas may temporarily make different choices.
- Operators must inject authenticated proxy URLs into every gateway replica.
Bounded semantic response caching in Redis
Status: Accepted · Date: 18 Jul 2026 · Issues: #261
Context
The exact response cache only reuses byte-equivalent normalized requests. Similar prompts still reach an upstream model even when an existing response would be acceptable. Semantic reuse adds an embedding request and a nearest-neighbour search, but it must not make gateway availability depend on the embedding provider or Redis and must keep request-path work bounded.
Options considered
- Keep exact caching only.
- Introduce a dedicated vector database and approximate-nearest-neighbour index.
- Store embeddings alongside Redis cache entries and scan a bounded recent window.
Decision
Adopt option 3 as an opt-in route capability. Exact lookup always runs first. On
an exact miss, rolter embeds the normalized prompt using an explicitly configured
provider/model and compares cosine similarity against at most max_candidates
recent entries in the route/key-isolated Redis index. A response is reused only
when its score reaches threshold. Semantic metadata and the exact response share
the route cache TTL.
Embedding, Redis, decoding, dimension, missing-entry, and stale-entry failures all fail open to normal upstream routing. Streaming responses enter both indexes only after the stream completes successfully.
Consequences
- Similar prompts can avoid model cost and latency without a new datastore.
- Exact-cache behavior and its faster lookup remain unchanged.
- Search cost is predictable but linear in the configured candidate bound; this is not intended as a large corpus vector-search engine.
- The embedding provider adds cost and latency on semantic misses and becomes part of cache-result quality, though never gateway availability.
- Operators own the similarity threshold and must account for unsafe reuse in domains where small wording changes alter the correct answer.
External cache telemetry for routing
Status: Accepted · Date: 18 Jul 2026 · Issues: #258, #259 Supersedes: ADR-0007 for exact vLLM and LMCache-aware modes
Context
ADR-0007 deliberately introduced approximate prompt-prefix affinity without coupling the gateway to an inference engine. vLLM can now publish exact KV block residency events, while LMCache deployments can expose availability and occupancy. Using those signals can improve reuse, but blocking network calls or trusting stale telemetry on the request path would weaken gateway latency and availability.
Options considered
- Keep only the engine-independent approximate prefix trie.
- Query cache state synchronously during every routing decision.
- Consume telemetry in background tasks and expose bounded in-memory scorers.
Decision
Adopt option 3 as two opt-in strategies behind the existing balancer interface:
precise_cache_awaresubscribes to the supported vLLM V1 three-frame ZMQ msgpack KV-event protocol, derives stable token-block identities, and scores the resident leading fraction of tokenizer-aligned request blocks.lmcache_awarepolls a rolter-defined HTTP JSON signal containingoccupancyandcache_available, then scores available targets as1 - occupancy.
Both sources update bounded process-local state outside the request path. Missing request token IDs, malformed input, disconnects, stale data, and vLLM sequence gaps produce a neutral score and preserve least-load fallback. A sequence gap clears the precise index until an explicit all-blocks-cleared boundary re-establishes trust.
Consequences
- Exact vLLM residency and LMCache capacity can influence routing without request- path network I/O.
- Approximate
cache_awareremains available and engine-independent. - vLLM exact scoring requires token IDs from the same tokenizer and a compatible event protocol; version assumptions are part of the operator documentation.
- LMCache integration depends only on the small documented controller contract, not an unstable internal LMCache API.
- Telemetry state is local to each gateway replica and intentionally disposable; restart, staleness, or desynchronization temporarily reduces routing quality but does not prevent requests.
Uniform config-vs-DB tiering for models, providers, and provider groups
Status: Accepted · Date: 20 Jul 2026 · Issues: #306 Relates: ADR-0017 (provider/model addressing), ADR-0018 (granular CRUD)
Context
rolter bootstraps from a TOML config and (optionally) a Postgres store, merged at
startup via MergedConfigStore. Today the three routable entity types treat the
config/DB boundary inconsistently:
| Entity | readonly (config-owned) | default (seed→DB, editable) | API/UI (DB) |
|---|---|---|---|
| models | [[routes]] + [models.readonly] | [models.default] (seeded once) | CRUD ✓ |
| providers | [[providers]] | — (missing) | CRUD ✓ |
| provider groups | [[provider_groups]] (ADR-0017 addendum) | — | — (no store) |
Three problems:
- Providers cannot be seeded-then-edited. A
[[providers]]entry is permanently config-owned and rejected by CRUD (require_not_config_owned), so an operator who wants to bootstrap a provider from config but later edit its credentials/base_url through the UI has no path. - Provider groups have no DB tier at all — they exist only as read-only config (ADR-0017 addendum / #571), so they cannot be created via the API or UI.
- The model shape is idiosyncratic — top-level
[[routes]]and a parallel[models.readonly]both mean “immutable config route”, which is redundant and does not generalize to providers/groups.
The desired behaviour is uniform across all three: an operator can declare an entry as readonly (immutable, served straight from config), as a default (seeded into the DB once at startup, then owned and editable via API/UI), or create it purely through the API/UI (a DB row with no config presence).
Decision
Adopt one uniform two-tier config wrapper for every routable entity type — models, providers, and provider groups — layered over the existing DB (CRUD) tier:
[models]
readonly = [ /* immutable, config-owned, rejected by CRUD */ ]
default = [ /* seeded into the default project once, then DB-owned */ ]
[providers]
readonly = [ ... ]
default = [ ... ]
[provider_groups]
readonly = [ ... ]
default = [ ... ]
Semantics, identical for each entity type:
- readonly — merged into the effective running config and into the gateway
snapshot directly; tracked in
ConfigOwned; CRUD create/update/delete against a name/slug in this set is rejected. Immutable for the process lifetime. - default — seeded into the bootstrap
default/default/defaulttenancy exactly once (idempotent: an existing row by natural key is never overwritten on restart), then it is an ordinary DB row — editable and deletable via API/UI, and not config-owned. Mirrors today’sseed_default_models. - DB (API/UI) — pure store rows with no config presence, already the CRUD path.
Resolution precedence when the same natural key (routes.model, providers.slug,
group slug) appears in more than one tier: readonly wins (it is immutable and
must never be shadowed by a DB row), then the DB row, then a default that has not
yet been claimed as a DB row. Seeding a default whose key already exists as
readonly is a config error, surfaced at load time (as models.default already does
against readonly routes).
Migration & back-compat
- The current top-level
[[routes]]and[[providers]]arrays are kept as deprecated aliases for theirreadonlytier, so existing configs and the bootstraprolter.example.tomlkeep working unchanged. New docs steer operators to[models.readonly]/[providers.readonly]. [[provider_groups]](introduced in the ADR-0017 addendum / #572, before this ADR) becomes theprovider_groups.readonlyalias; the config-only groups slice is forward-compatible with this decision.- Slugs remain the stable identity across tiers (ADR-0017): a
defaultprovider or group keeps its slug when it becomes a DB row, so itsprovider-slug/model/group-slug/modeladdress is stable across the config→DB transition.
Consequences
- One mental model and one code path shape (
readonlymerge +defaultseed +ConfigOwnedguard) for all three entity types; the provider-groups and provider work reuses the model tiering rather than inventing per-entity rules. - Providers gain a seed-then-edit story; provider groups gain a full DB lifecycle.
- New store tables/repos are required for provider groups (
provider_groups+ membership), plus seed functions forproviders.defaultandprovider_groups.default, plus group CRUD — tracked as separate PRs (see below). - The deprecated top-level arrays add a small amount of parse-time aliasing to maintain until a future breaking release removes them.
Follow-up implementation issues
- core: uniform tier wrapper for providers + provider groups (readonly/default),
with top-level
[[routes]]/[[providers]]/[[provider_groups]]kept as deprecated readonly aliases; load-time validation that adefaultnever collides with a readonly key. - store/control:
providers.defaultseed tier — seed function mirroringseed_default_models; providers seeded this way are DB-owned, not config-owned. - store:
provider_groups+ membership tables, repo, andMergedConfigStorewiring so DB-defined groups reach the gateway snapshot. - control: provider-group CRUD (create/update/delete, slug validation shared
with providers, 409+suggestion on slug conflict) +
provider_groups.defaultseed. - ui: provider-group management screens (create/edit/membership/strategy), surfaced alongside providers and models. (separate issue — not in the initial backend PRs)
Propagating access-profile model policy to the data plane
Status: Accepted · Date: 4 Aug 2026 · Issues: #791, #534 Relates: ADR-0022 (config-vs-DB tiering)
Context
#534 added org-scoped access
profiles that can carry a model and route allow/deny policy. The control plane
resolves the profiles a caller holds, merges their policies, and reports the
result on GET /api/v1/rbac/effective — but nothing enforced it on the
gateway, so the policy described access it did not actually control.
Enforcement was deferred because the obvious mechanism does not work. The RBAC
tables deliberately carry no bump_config_version() trigger: control-plane
authorization is evaluated per request against the live database, so there is
nothing to propagate, and bumping the version on every role edit would wake the
whole gateway fleet for a change it could not observe. Migration 0058 records
that reasoning and states the tables must never grow such a trigger.
The gateway, meanwhile, never sees a user. It authenticates a virtual key and knows only what the snapshot told it about that key. A policy is a property of a person; a request carries a credential. Something has to bridge the two.
Decision
Resolve the merged policy per key owner when the snapshot is built, carry it on the virtual-key record, and enforce it on the gateway’s model and route selection.
Concretely:
rolter_core::ModelPolicybecomes the single definition of the policy shape, its merge rule, and its allow/deny matching. The control plane’sMergedPolicyis now an alias for it.load_virtual_keysresolves each key’screated_byuser to a merged policy in one batched query for the whole key set, and publishes it onVirtualKeyRecord::access_policy.Nonemeans unrestricted.KeyMeta::model_permittedandKeyMeta::route_permittedgate the request path. The key’s own model allow-list and the owner’s policy must both permit a model: they are separate grants and neither may widen the other.- Migration
0060addsbump_config_version()triggers to exactly the four tables that feed this resolution:access_profile_policies,access_profile_assignments,access_profiles, andmemberships.
Why not resolve at key-mint time
#791 floated stamping the
policy onto the key when it is minted. That is unsound: a profile edited after
the mint would never reach keys already issued, so revoking a model would not
revoke it. A policy change that fails to restrict is a security bug, and the
failure is silent — the control plane would report the new policy on
/rbac/effective while the gateway kept honouring the old one. Snapshot
resolution has the opposite failure mode: the worst case is a bounded staleness
window that the existing config-version machinery already closes.
Why this does not contradict migration 0058
0058 says these tables must never bump the config version because the data
plane does not consume them. This ADR changes that premise rather than
overruling the rule: the gateway now consumes them, and the repository’s
standing convention is that any table the data plane reads must bump the
version inside the write transaction. 0060 therefore applies the rule to the
tables whose premise changed, and only those.
custom_roles and custom_role_grants stay untriggered. They decide
control-plane authorization, which is still evaluated live per request, so
0058’s reasoning holds for them unchanged.
memberships is included because it is the second path by which a profile
reaches a user: a profile assigned to a team applies to everyone in it, so
adding a member changes that user’s effective policy without any row in the
profile tables changing. Membership is not a hot table, the trigger is
statement-level, and the gateway coalesces on the version number — so even a
bulk SCIM sync costs one snapshot poll rather than one per member.
Consequences
- The policy
/rbac/effectivereports is now the policy that is enforced. One implementation of “deny beats allow” serves the control plane, the store and the gateway, so the reported and enforced answers cannot drift. - A deployment with no access profiles is bit-for-bit unaffected: every key
carries
None, andmodel_permittedreduces to the pre-existingmodel_allowedcheck. - Editing a profile or a team membership now bumps the config version, so those edits wake the gateway fleet. This is the intended cost of making the policy observable; it is bounded to four tables, none of them on a request path.
- Enforcement is keyed on
created_by. A key with no owner — admin-created and config-defined keys — carries no policy, because there is no person whose profiles could apply. Restricting those remains the key’s own model list. - The staleness window is the snapshot poll interval, as it is for every other DB-backed config. A revocation is not instantaneous; it is as fast as a provider or route change.
Dashboard UX telemetry as a structural-only event stream
Status: Accepted · Date: 6 Aug 2026 · Issues: #805 Relates: ADR-0023 (access-policy propagation)
Context
Browser tracing (#807) tells us how long a dashboard screen took and whether it threw. It does not tell us whether the screen was usable: which screens are slow to become interactive, where people back out, which forms get abandoned, which error and empty states are actually reached. Those are the questions that decide what to build next, and spans cannot answer them.
Collecting that is ordinarily where a product-analytics vendor is added. For a self-hosted gateway that sits in the request path of its operators’ LLM traffic, a second data processor is a hard sell: a different retention policy, a different redaction story, a different jurisdiction, and an outbound connection operators did not ask for. Several rolter deployments are air-gapped, where it simply does not work at all.
There is also a real hazard specific to this kind of telemetry. UX event streams are where free text leaks. An “abandoned form” event is one careless field away from carrying what was typed into the form, and the dashboard’s forms hold provider API keys, virtual keys and prompts.
Decision
Collect UX events in-house, into a ui_events ClickHouse table beside
request_logs, and make the schema the privacy guarantee.
Structural only. Every column is a key, an enum, a duration or an id. There
is deliberately no column a form value, prompt or free-text body could be
written into. target names the form, control or validation rule; it never
carries what failed the rule. A future column that could hold free text is a
change to this ADR, not a routine migration.
This is chosen over the usual alternative — accept arbitrary payloads and redact
on write — because redaction is a policy that has to keep being correct as the
dashboard grows, while a schema with nowhere to put a value is correct by
construction. logging.payload_capture already exists for the case where
someone genuinely wants raw bodies, and it is off by default; this stream is
deliberately not that.
Server-side attribution. user_id is taken from the authenticated session
and a client-supplied one is ignored, so a caller cannot file events against
another user.
Authentication, not authorization. The ingest endpoint is guarded by
CurrentUser alone, following me.rs, and has no row in the capability table.
The RBAC model holds that a viewer writes nothing — rbac_matrix.rs asserts
exactly that — and a ui_event:create capability granted to any authenticated
caller breaks the invariant. A viewer who could not file their own screen views
would be absent from every funnel, which makes the data quietly wrong rather
than absent. Keeping this off the capability table states plainly that it is not
an operator surface.
On by default, with two off switches. Absent clickhouse_url the stream is
inert. logging.ui_events = false is a deployment-level opt-out. It defaults on
— unlike payload_capture — precisely because the schema cannot carry sensitive
content, so the usual reason to make telemetry opt-in does not apply.
Consequences
Operators get usability data with no new vendor, no new egress and no new
retention policy: same deployment, same TTL, same 90-day partitioning as request
logs. Because events carry trace_id, a slow screen and the gateway request
behind it are one join rather than two systems.
The cost is expressiveness. Any question that needs a value rather than a key is unanswerable by design, and some genuinely useful analyses are foreclosed. That is the intended trade: the class of incident this prevents — dashboard secrets in an analytics table — is worse than the analyses it forbids.
Adding a screen means adding a stable screen key rather than letting a URL
through. screen, target and from_screen are LowCardinality columns
capped at 96 characters, which a real URL does not fit; sending one would
degrade the table, so the bound fails the write instead.
Per-tenant routing of this stream is out of scope and tracked separately (#812).
Events as logs, not span events
Status: Accepted · Date: 6 Aug 2026 · Issues: #814, #808, #809, #805 Relates: ADR-0024 (dashboard UX telemetry)
Context
OpenTelemetry is deprecating the Span Events
API — Span.AddEvent,
Span.RecordException and friends. Maintaining two overlapping ways to emit a
correlated event (span events via the Tracing API, log events via the Logs API)
produced split guidance for instrumentation authors and a slower-evolving event
model. The replacement is events as logs: emitted through the Logs API,
named, and correlated to the active span through context rather than attached to
it. Semantic-convention authors are now explicitly told to document events as
log-based events.
Nothing is being removed soon. The deprecation is phased, existing data stays valid in the OTLP trace model, and compatibility layers are a stated priority. This is direction-setting, not an outage.
What rolter emits today
A grep for the deprecated API finds nothing:
$ grep -rn "add_event\|record_exception" --include='*.rs' crates/
(no matches)
That result is misleading, and reading the bridge is what the audit actually
required. rolter instruments through tracing + tracing-opentelemetry, and
that layer’s on_event turns every tracing event fired inside an active span
into an OTel span event (tracing-opentelemetry-0.33.0/src/layer.rs:1468,
span.add_event(...); the pre-export path pushes onto builder.events
identically). An ERROR-level event additionally sets the span status to error.
So rolter has zero explicit uses of the deprecated API and is, in practice, an
exclusive user of it: the ~57 tracing event macros across rolter-gateway,
rolter-proxy and rolter-core become span events whenever they fire inside one
of the pipeline spans. The migration surface is the bridge, not our call sites.
Why the ordering between #808 and #809 changes
The GenAI semantic conventions (#808) are precisely the area moving to log-based events. Implementing them against span events would be building onto a deprecated API on the day it is written.
Emitting log-based events requires exporting logs at all, and rolter does not:
there is no OTLP logs exporter in the workspace and signoz_logs is empty by
construction. #809 therefore
stops being an independent nice-to-have and becomes a prerequisite.
Decision
Log-based events are rolter’s event model. New instrumentation targets the Logs
API; span events are legacy, produced only incidentally by the tracing bridge.
Concretely:
- No new explicit span events.
add_eventandrecord_exceptionare not to be called directly. Nothing calls them today and nothing should start. - #809 (OTLP log export) sequences before #808 (GenAI conventions). The conventions #808 implements are specified as log-based events, so the log pipeline has to exist before they can be emitted correctly. #808’s acceptance criteria adopt log-based events as the target.
tracingstays. The bridge is idiomatic Rust and the ecosystem’s own integration, not a bespoke house wrapper — the same distinction drawn in the #815 wrapping audit. It is where the span-event mapping lives, so it is also the single place a future migration happens: whentracing-opentelemetryroutes events through the Logs API, rolter follows by upgrading, without touching 57 call sites.- Correlation is the acceptance bar. A log-based event is only a replacement
for a span event if it carries
trace_idandspan_id, which is already called out as most of the value in #809. An exported log that cannot be joined back to its span is a regression against what the bridge does today. - Content stays off. The GenAI opt-in content attributes
(
gen_ai.input.messages/gen_ai.output.messages) remain disabled whether they are carried as span events or as log records. Moving event model does not move the privacy line.
Consequences
- The migration is a dependency upgrade rather than a code sweep, because rolter
never adopted the deprecated API directly. This is the payoff of having gone
through
tracinginstead of calling OTel by hand. - #809 gains a second justification beyond “logs are missing”: it unblocks #808.
Its
trace_id/span_idcorrelation requirement is now load-bearing rather than a nicety, and should be treated as a blocking acceptance criterion. - Until #809 lands, error detail continues to reach backends as span events via the bridge. That is fine — the deprecation is phased and the data stays valid — but it means the GenAI work should not start early and improvise its own event channel.
- Anything that wants a new event type before #809 has no correct home. The answer is to wait rather than to add a span event that will have to be removed.
Alternatives considered
Implement #808 now against span events, migrate later. Rejected. The conventions are specified as log-based events, so this builds a second migration into work that has not shipped yet, on the exact attributes most likely to be re-specified.
Emit both span events and log events during a transition. Rejected. Duplicate
correlated events on both signals is the split-guidance problem OpenTelemetry is
deprecating span events to escape, and it doubles egress for data that already
joins on trace_id.
Drop tracing and call the OTel Logs API directly. Rejected. It is a large
rewrite of every call site to avoid a bridge that will itself be updated
upstream, and it contradicts the #815 finding that the ecosystem bridge is not
the wrapping the “don’t wrap” guidance targets.
Client control over telemetry: a kill switch now, collector-routed tenant destinations later
Status: Accepted · Date: 6 Aug 2026 · Issues: #812, #805, #809 Relates: ADR-0019 (egress proxy pools), ADR-0025 (events as logs)
Context
Telemetry is currently an operator decision baked into the deployment:
OTEL_EXPORTER_OTLP_ENDPOINT for the backend exporter and
ROLTER_UI_OTEL_ENDPOINT for the dashboard. A client of the gateway has no say
in either — they cannot turn tracing off for their own traffic, and they cannot
have their spans delivered to a backend they own.
#812 raises two things of very different size, and filed them together so the relationship is on the record. This ADR splits them.
The switch. “Off” is implicit today: you achieve it by leaving an endpoint unset. That works but is not discoverable, does not survive somebody setting the endpoint for one signal, and gives an operator nothing to point at in a security review.
Per-tenant destinations. rolter is multi-tenant (org / team / project, virtual keys, RBAC) while telemetry is single-destination, which does not match. A team running rolter as shared infrastructure wants its spans in its backend; some tenants want no export at all for data-residency reasons; a customer debugging their own integration should not have to ask the operator to fetch traces. This half raises questions — scope, fan-out, egress safety, cardinality — that are design work rather than implementation.
Decision
Ship the kill switch now as an environment variable. Do not build per-tenant fan-out in rolter; route it in the collector when it is built.
1. ROLTER_TELEMETRY_ENABLED
One switch covering traces, metrics, the dashboard’s browser tracing, and logs once #809 lands.
- It can only subtract. When false, no exporter is built regardless of which
OTEL_*endpoints are set. It is never a second way to turn export on. - Unset means enabled, so no existing deployment changes behaviour. That is not a weaker default than it sounds: with no endpoint configured nothing is exported anyway, so the effective default remains “exports nothing”.
- An unrecognized value leaves export on. A typo must not silently blind a
deployment; only the explicit falsy spellings (
0,false,no,off) disable it. - Environment-only, with no config-file key, despite #812 asking for “the
matching config key”.
telemetry::initinstalls the subscriber inmainbefore any config file is read — the gateway has to be able to log a config parse failure — so a config-file switch could not gate trace export at all. Honouring one only for the signals initialized later would mean the same key meant different things depending on which signal you asked about, which is worse than not having it. TheOTEL_*contract this composes with is environment-based for the same reason.
2. Per-tenant destinations belong in the collector
When the second half is built, it is configuration of an OpenTelemetry Collector that sits in front of the tenants’ backends, not a fan-out inside rolter.
- Scope: org. It is the easiest to administer and the coarsest unit anyone actually asks about (“my team’s spans in my team’s Honeycomb”). Virtual key is finer and matches how traffic is attributed, but a destination per key is a cardinality problem with no matching demand. rolter’s job is to stamp the attribute; the routing key can be refined later without changing where fan-out happens.
- Fan-out: the collector’s routing processor. Multiple SDK exporters
in-process does not scale with tenant count — each is a connection, a queue and
a retry buffer, all inside the request-serving process. rolter emits one OTLP
stream to one collector with a resource attribute naming the tenant, and the
collector’s
routingprocessor sends each tenant’s data onward. This keeps exactly one egress path in the gateway and makes per-tenant destinations an operator’s configuration change rather than a rolter deploy. - Egress safety follows from that. A tenant-supplied endpoint is an arbitrary
URL, and the deciding argument against in-process fan-out is that it would make
the gateway POST to one. Terminating tenant endpoints in the collector keeps
that SSRF surface out of the data plane entirely. If a tenant-supplied endpoint
is ever accepted through the API, it must go through the same
EgressPolicythat providerapi_basevalues do (ADR-0019), and its credentials through the encrypted-secret path — never plaintext config. - Opt-out is the switch, one level down. A tenant wanting no export at all is a routing rule that drops, not a second mechanism.
Consequences
- An operator gets a single, documentable, reviewable “no telemetry leaves this deployment” today, without waiting on the design work.
- The gateway keeps exactly one exporter and one egress path no matter how many tenants exist. Cardinality and buffer growth become the collector’s problem, where they are a sizing question rather than a data-plane risk.
- The cost is that per-tenant destinations require running a collector. That is
already the recommended topology (
docs/architecture/observability.md), so it is not a new dependency for anyone following it — but a deployment exporting straight to a vendor endpoint would have to add one. - Nothing here is blocked on #809, but when OTLP log export lands it inherits both decisions automatically: the switch covers it, and its records carry the same resource attributes the routing processor keys on. That is why the switch is worded per-deployment rather than per-signal.
ROLTER_TELEMETRY_ENABLEDis now part of the public configuration surface and cannot quietly change meaning. In particular it must stay “can only subtract” — making it required-true would break every existing deployment on upgrade.
Alternatives considered
A config-file [telemetry] enabled key. Rejected on initialization order, as
above. Worth restating because it is the obvious request and the reason it does
not work is not obvious.
Per-signal switches (ROLTER_TRACES_ENABLED, …). Rejected. The problem #812
names is that “off” is currently spread across several variables; adding more
variables reproduces it. Per-signal control already exists through the
signal-specific OTEL_*_ENDPOINT variables.
In-process per-tenant exporters. Rejected on all three of scaling, egress safety and cardinality, as above.
Reflecting a tenant-supplied endpoint straight from a virtual key. Rejected outright: it turns every key holder into someone who can aim the gateway’s egress, which is the SSRF primitive ADR-0019 exists to prevent.
End-to-end test harness: Python/uv project driving a black-box stack
Status: Accepted · Date: 21 Jul 2026 · Issues: #613, #614
Context
Per-crate unit tests and the two narrow suites we already have (#414 in-process integration, #449 compose smoke) never exercise rolter as a whole: a freshly booted stack, multiple tenants, live RBAC changes, a fleet of upstream engines, and balancing across clusters. #613 needs a broad governance/routing E2E suite, and #614 is the harness every other scenario (RBAC matrix, balancing, key lifecycle, security) builds on.
Two decisions had to be made: what drives the stack, and how the driver code is packaged.
Options considered
- Rust in-process integration test — spin control+gateway in-process against testcontainers. Fast and CI-native, but not a real deployment: it bypasses the HTTP edge, the compose network, and the container boundaries where isolation and auth actually matter.
- Bash script driving docker-compose — closest to “run fresh rolter”, but bash grows unmaintainable for a table-driven RBAC matrix with JSON assertions.
- Python driver over docker-compose — real HTTP against a real composed stack, with a proper test framework for the matrix.
For packaging the Python driver:
- PEP 723 inline script metadata (
uv add --script) — deps live in a single file header,uv run file.pyauto-installs. Ideal for one-file scripts. - A uv project (
pyproject.toml+uv.lock) — deps and a lockfile for a multi-file package.
Decision
Adopt option 3: a Python driver, latest CPython managed by uv, hitting the
real HTTP APIs of a stack brought up by docker-compose (postgres, redis,
clickhouse, control, gateway, and N llm-d-inference-sim fake-vLLM engines). No
in-process shortcuts — the tests see exactly what an operator/tenant sees.
Package it as a uv project under integration/e2e/, not inline PEP 723
script metadata. The harness is a pytest package: a shared helper/client library
plus multiple scenario modules (test_rbac.py, test_balancing.py,
test_keys.py, test_security.py) sharing fixtures. Inline metadata is
per-single-file and cannot express cross-module fixtures or a lockfile, so it is
the wrong grain here. Inline PEP 723 remains fine for any standalone one-off
helper run by hand.
The harness project is kept separate from the root maturin pyproject.toml:
that one packages the shipping wheel at requires-python >=3.9; the test harness
has no such floor and pins the latest stable CPython via uv python pin.
RBAC only enforces when ROLTER_ADMIN_TOKEN is set (otherwise the control plane
runs in open/superadmin mode, per #454/ROL-250), so the harness compose sets the
admin token and shares ROLTER_KEY_PEPPER/ROLTER_SESSION_PEPPER between
control and gateway. Bootstrap uses the admin token as superadmin; per-role
scenarios create local accounts and drive them via session tokens.
The suite is heavy, so it is gated (manual dispatch + nightly), never on the default per-PR gate.
Consequences
- One harness backs every #613 sub-issue; scenarios are Python modules, not new bespoke rigs.
- Tests run against the real container edge, so cross-tenant isolation, auth, and balancing are exercised where they actually live.
- Fake engines only (
llm-d-inference-sim) — offline/air-gapped safe, no secrets, no model downloads, deterministic. - A lockfile makes the harness reproducible; the cost is a second Python project in the tree, deliberately isolated from the wheel’s packaging metadata.
- Full stack-boot verification requires Docker and building the rolter images, so it lives in CI (dispatch/nightly), not the fast PR path.
Disaggregated prefill/decode routing belongs to the engine, not the gateway
Status: Accepted · Date: 11 Aug 2026 · Issues: #853 Relates: ADR-0014 (protocol translation boundary), ADR-0007 (cache-aware balancing)
Context
#853 carried three unclaimed ideas from llm-d. The second — routing by inference phase to specialised pools — was flagged as “the largest of the three and the one most coupled to how the fleet is deployed”, with the note that it may not fit rolter’s position in the stack and that the useful output in that case is a written “no, because”. This is that note.
Disaggregated serving splits inference in two. Prefill processes the prompt, is compute-bound, and produces a KV cache. Decode generates tokens, is memory-bandwidth-bound, and consumes that KV cache. Running them on separately sized pools improves utilisation, because the two phases stress different resources and otherwise contend on the same accelerator.
Making that work requires, per request: selecting a prefill worker, running prefill there, transferring the resulting KV cache to a decode worker, and then streaming generated tokens from the decode worker.
What rolter is
ADR-0014 fixed the boundary: rolter-gateway owns authentication, tenancy,
target selection, tracing, metrics and accounting; rolter-proxy owns the wire
protocol. rolter forwards one client HTTP request to one upstream and streams
the response back. Its routing inputs come from gateway-owned configuration,
the client request, and bounded telemetry adapters; it does not participate in
an engine’s multi-stage request lifecycle.
Options considered
Option 1 — Implement P/D disaggregation in rolter
The gateway selects a prefill target and a decode target, drives both, and coordinates the KV handoff between them.
Option 2 — Treat a disaggregated fleet as one upstream
The engine-side coordinator owns phase routing and the KV transfer. rolter balances across the fleet’s entry points exactly as it does for any other pool, and does not know a request was split.
Option 3 — Hybrid: rolter routes phases, engine transfers KV
rolter picks both workers and issues two calls, but the KV cache moves over the engine’s own sidechannel rather than through rolter.
Comparison
| Option | Pros | Cons |
|---|---|---|
| 1. Implement in rolter | Full control over phase placement; phase-aware scoring composes with existing scorers | Requires carrying KV tensors over an engine-specific connector and transport such as NIXL/UCX; the data plane would hold per-request state across a handoff; couples rolter to one engine’s internals and version cadence; duplicates work vLLM and llm-d already do |
| 2. One upstream | Preserves the ADR-0014 boundary; works today with no code; every engine’s own disaggregation implementation is usable, including future ones | rolter cannot influence phase placement, so it cannot improve on the engine’s own decisions |
| 3. Hybrid | Avoids moving KV through rolter; an engine-specific HTTP extension can coordinate a connector-managed transfer | Still needs rolter to know which workers are connector-compatible, emit version-specific control fields and metadata, issue two upstream calls, and keep per-request state across them; a mid-request failure has no clean recovery |
Decision
Option 2. rolter will not implement disaggregated prefill/decode routing. A P/D-disaggregated deployment is a single upstream fleet from rolter’s point of view, and its own coordinator owns phase selection and the KV handoff.
Rationale
The KV handoff is the central mechanism. The tensors do not travel in an OpenAI or Anthropic response; engines move them through connector-selected transports such as NIXL/UCX. A gateway that carried the tensors itself would therefore have to join that engine-specific data path.
Coordination can still be initiated over HTTP. For example, vLLM’s experimental
disaggregated-prefill API accepts kv_transfer_params on separate prefill and
decode requests while its connector moves the KV data out of band. That makes
option 3 technically possible, but does not create a stable, engine-independent
contract: rolter would still own engine-specific fields, compatible-worker
topology, two-call orchestration and state across the handoff. That is precisely
the version coupling and lifecycle ownership ADR-0014 was written to prevent.
It would also be redundant. vLLM ships disaggregated serving with a connector API, and llm-d’s router is co-designed with its engine and scheduler — it can assume things about worker topology and KV placement that a general-purpose gateway in front of heterogeneous providers cannot. rolter’s value is that it sits in front of many engines and hosted APIs; a feature that only works for one engine, at one version, with one transport configured, is not a good trade against that.
The distinguishing question is: can rolter consume the input through a stable, engine-independent contract without joining the engine’s request lifecycle? The answer is yes for prefix-cache affinity, queue depth, KV-event residency, LMCache occupancy and LoRA-adapter affinity, which fit the bounded scorer and telemetry interfaces behind ADR-0007 and ADR-0021. P/D placement does not: the placement decision and transfer metadata are part of one engine-specific, multi-call lifecycle.
Consequences
Benefits:
- the ADR-0014 boundary is preserved: no engine-specific KV connector or phase-control extension in the data plane, and no per-request state held across an upstream handoff;
- disaggregated fleets are usable with rolter today, with no code, for any engine that implements disaggregation — including engines that do not exist yet;
- rolter does not inherit the failure modes of a mid-request handoff, which has no clean recovery once prefill has completed and decode is unreachable.
Drawbacks and risks:
- rolter cannot improve on the engine’s phase placement, and cannot report on it beyond whatever the fleet exposes over its metrics endpoint;
- if a stable, engine-independent phase-placement contract emerges on the OpenAI or Anthropic surface, this decision should be revisited, because the portability and lifecycle objections would no longer hold.
System impact:
None. This is a decision not to build something; no code changes.
Related records
- ADR-0007 — Approximate cache-aware balancing behind a pluggable trait
- ADR-0014 — Extensible API protocol translation
- ADR-0021 — External cache telemetry for routing
Open questions
- Should rolter document a recommended topology for putting a disaggregated fleet behind it (which entry point to target, and how to configure health checks against a coordinator rather than a worker)? That is documentation rather than a routing feature, and would be useful independently of this decision.
API surface
rolter speaks the OpenAI and Anthropic HTTP APIs so existing SDKs work unchanged — point them at the gateway base URL and use a rolter virtual key.
Authentication
- OpenAI-style:
Authorization: Bearer <virtual-key> - Anthropic-style:
x-api-key: <virtual-key>
When no virtual keys are configured the gateway runs open (useful for local dev).
Endpoints (v1)
| Method | Path | Notes |
|---|---|---|
| POST | /v1/chat/completions | OpenAI chat; streaming via "stream": true (SSE) |
| POST | /v1/completions | OpenAI legacy completions |
| POST | /v1/responses | OpenAI Responses; provider-native passthrough, streaming supported |
| GET, DELETE | /v1/responses/{id} | retrieve or delete a tenant-scoped native Responses resource |
| POST | /v1/responses/{id}/cancel | cancel a tenant-scoped native Responses resource |
| GET | /v1/responses/{id}/input_items | list input items for a tenant-scoped native Responses resource |
| POST | /v1/messages | Anthropic Messages; streaming supported |
| POST | /v1/embeddings | OpenAI embeddings; non-streaming |
| POST | /v1/rerank | Cohere/Jina rerank; non-streaming |
| POST | /v1/images/generations | OpenAI image generation; non-streaming |
| POST | /v1/audio/speech | OpenAI text-to-speech; binary audio response |
| POST | /v1/audio/transcriptions | OpenAI speech-to-text; multipart/form-data upload |
| POST | /v1/audio/translations | OpenAI audio translation; multipart/form-data upload |
| GET | /v1/realtime?model=… | OpenAI-compatible Realtime API; WebSocket relay |
| GET, POST, DELETE | /mcp/{server}/{path…} | authenticated Streamable HTTP/SSE MCP proxy |
| GET | /v1/models | lists configured public model names |
| GET | /openapi.json | OpenAPI 3.1 description of this request surface (self-contained, no external assets) |
| GET | /docs | interactive Scalar API reference (assets embedded in the binary — works air-gapped) |
| GET | / | service-info landing (version + links to docs/openapi/health) |
| GET | /healthz | liveness — process is up, no dependency checks |
| GET | /readyz | readiness — 503 while draining (see Health & readiness) |
| GET | /metrics | Prometheus exposition |
Realtime WebSocket
Connect with the usual gateway bearer key and the public route model as a query parameter:
wss://gateway.example.com/v1/realtime?model=gpt-realtime
rolter authenticates and selects an upstream before accepting the client upgrade, then pins that upstream and its selected provider key for the session. Text, binary audio and WebSocket control frames are relayed in both directions without application-level buffering. If the upstream drops, the client must reconnect; rolter does not fail a live session over to another target because replaying audio or tool events is unsafe.
The WebSocket-first implementation supports the OpenAI Realtime event stream, including session.update, input_audio_buffer.*, response.*, and function-call events. WebRTC/browser ephemeral-token handoff is not exposed by the gateway yet.
MCP gateway
/mcp/{server} and any path beneath it proxy an organization-scoped MCP
server registered in the control plane. The caller supplies a normal rolter
virtual key. Unlike LLM routes, MCP calls require a database-backed key minted
by a user: that owner must have a live, unrevoked and unexpired OAuth session
for the named server, and its scopes must cover the server’s
required_scopes. The gateway fails closed before connecting when any owner,
server or scope check fails.
The caller’s virtual key is never forwarded. Rolter replaces it with the
session’s OAuth bearer token and preserves end-to-end MCP headers, body,
query, status and response stream. Streamable HTTP and legacy SSE registrations
use this path; stdio and WebSocket registrations currently return
mcp_transport_unsupported rather than bypassing authorization.
Routing
The model field in the body selects a route. The route’s strategy picks a target; rolter rewrites model to the target’s upstream model id and forwards with the provider’s credentials. Session affinity uses x-session-id when present.
When the selected upstream speaks the other chat protocol, rolter translates OpenAI Chat Completions and Anthropic Messages in both directions. Translation includes system/developer instructions, sampling and stop parameters, function tools and tool results, token usage, finish reasons, and live SSE events. Image and document inputs retain URL, base64 media type/data, and file references. Blocks with no equivalent in the target protocol (for example OpenAI input audio sent to an Anthropic Messages upstream) are preserved as opaque content blocks; the target may reject them rather than rolter silently dropping data.
The Gemini dialects — generateContent and Interactions — are the exception:
their wire formats are typed part unions with no opaque carrier, so an
unrecognized part cannot be passed through. Rather than drop it, rolter fails
the request closed with 400 unsupported_content_part, naming the part type
and the upstream dialect:
{
"error": {
"message": "the interactions upstream has no equivalent for content part type 'input_audio'; remove it or route this model to a provider that accepts it",
"type": "invalid_request_error",
"code": "unsupported_content_part",
"param": "messages"
}
}
This applies to every client dialect (Chat Completions, Messages, Responses) and
covers new part types a client SDK starts sending. Only text/input_text and
image_url/input_image have Gemini equivalents today; route models that need
other modalities to a provider whose dialect carries them.
OpenAI Responses
POST /v1/responses is routed by its required model field. Native OpenAI
providers receive the request and SSE events unchanged. For Chat Completions or
Anthropic Messages upstreams, rolter translates the common text, multimodal,
function-tool, tool-result, sampling, and usage fields in both directions and
emits Responses-shaped events to the caller. Responses-only features without a
wire equivalent (for example background, store, previous_response_id,
and provider-specific reasoning controls) are not forwarded to those older
surfaces; use a native Responses provider when those features are required.
For native OpenAI providers, rolter records the selected provider, target,
upstream model, provider credential fingerprint, and native response ID after a
successful creation. GET/DELETE /v1/responses/{id}, cancellation, and
input-item retrieval are then pinned to that record. Records are isolated by
virtual key, retained for 24 hours by default, bounded to 100,000 entries per
gateway process, and removed after a successful delete. Configure these limits
with [responses] registry_ttl_secs and registry_max_entries; setting either
to 0 disables registration.
The registry is process-local. Multi-replica deployments must keep lifecycle
requests sticky to the gateway replica that accepted creation; records do not
survive a gateway restart. Route changes do not retarget an existing response.
If its provider is removed, its provider kind changes, or its credential is
rotated away, the record becomes unavailable. Unknown, expired, deleted,
cross-key, and unavailable records all return the same 404 response_not_found
error so route ownership is not leaked.
Responses translated through Chat Completions or Anthropic Messages retain an
ownership record but expose no lifecycle capabilities, because those upstream
contracts do not retain an OpenAI Responses resource. Their lifecycle calls
return 501 response_lifecycle_unsupported. Compaction and input-token counting
remain unsupported for all providers.
Examples
# openai chat (streaming)
curl -N http://localhost:4000/v1/chat/completions \
-H "Authorization: Bearer sk-rolter-dev" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"hi"}]}'
# anthropic messages
curl http://localhost:4000/v1/messages \
-H "x-api-key: sk-rolter-dev" \
-H "Content-Type: application/json" \
-d '{"model":"claude","max_tokens":256,"messages":[{"role":"user","content":"hi"}]}'
# openai embeddings
curl http://localhost:4000/v1/embeddings \
-H "Authorization: Bearer sk-rolter-dev" \
-H "Content-Type: application/json" \
-d '{"model":"text-embedding-3-small","input":["hello","world"]}'
# self-hosted vllm pool via a public model name
curl http://localhost:4000/v1/chat/completions \
-H "Authorization: Bearer sk-rolter-dev" \
-H "x-session-id: user-123" \
-H "Content-Type: application/json" \
-d '{"model":"llama","messages":[{"role":"user","content":"hi"}]}'
Multipart audio (
/v1/audio/transcriptions,/v1/audio/translations) forwards the upload verbatim and routes on themodelform field; the route target’s upstream model name is not rewritten into the multipart body, and variant routing / per-model param defaults (JSON-only) do not apply.
Development setup
Prerequisites
- Rust (stable) via rustup — the workspace pins the toolchain in
rust-toolchain.toml. - Bun for the UI —
curl -fsSL https://bun.sh/install | bash. - prek for repository Git hooks — install with
uv tool install prekorbrew install prek. - Docker + Compose for Postgres/Redis/ClickHouse.
- uv (optional) for the PyPI-wheel install path and tooling.
Clone & build
git clone https://github.com/rolter-ai/rolter.git
cd rolter
cargo build --workspace
cargo nextest run --workspace # or `cargo test --workspace`; install: cargo install cargo-nextest
Run the gateway (no external services needed)
cp rolter.example.toml rolter.toml
export OPENAI_API_KEY=sk-... # referenced by api_key_env in the config
cargo run -p rolter-gateway -- --config rolter.toml
# -> http://localhost:4000 (/healthz, /metrics, /v1/*)
Run the control plane + UI
cargo run -p rolter-control # http://localhost:4001
cd ui && bun install && bun run dev # http://localhost:3000 (proxies /api -> :4001)
Run the full stack
docker compose -f docker/docker-compose.yml up -d # postgres, redis, clickhouse, gateway, control
Handy tasks
just wraps the common commands:
just build | just test | just fmt | just lint
just gateway | just control | just ui-dev | just up
Build these docs
This book is mdBook. Diagrams are ```mermaid fences rendered by the
mdbook-mermaid preprocessor, so both tools have to be on PATH — with
mdbook alone the build still succeeds and every diagram silently ships as a
plain code block.
cargo install mdbook mdbook-mermaid --locked
just docs # build to docs/book/ (gitignored)
just docs-serve # live-reloading preview on http://localhost:3001
The mermaid runtime is vendored at docs/mermaid.min.js and
docs/mermaid-init.js so the book renders air-gapped. mdbook-mermaid install docs regenerates both, but mermaid-init.js carries a local fix — upstream
still binds theme buttons by their pre-0.5 ids (ayu, navy, …), which now
throws and leaves diagrams on the light palette after a theme switch — so
re-apply it after any refresh. Write labels in
mermaid’s own dialect rather than GitHub’s — quote any label containing /
(R["/v1/responses"], since [/…] is parallelogram syntax) and break lines
with <br/>, never \n.
Before committing
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo nextest run --workspace && cargo test --doc --workspace # or `just test`
prek install --prepare-hooks
prek run --all-files
prek run --all-files --hook-stage pre-push
The hooks add staged-file hygiene and secret scanning, Conventional Commit validation, Rust/workflow/TOML/spelling checks, workspace tests, dependency policy checks, and UI lint/build checks. Install the system tools used by the project-specific hooks:
brew install actionlint taplo typos-cli
cargo install cargo-nextest cargo-deny
cargo-nextest is recommended but optional for the push hook; it falls back to
cargo test. CI remains authoritative for database-backed tests that need
ROLTER_TEST_DATABASE_URL.
Testing
Run
Tests run under nextest (the same runner CI uses), plus a separate doc-test pass since nextest does not run doc tests:
cargo nextest run --workspace # unit + integration tests
cargo test --doc --workspace # doc tests
cd ui && bun run lint # ui typecheck
Install the runner once with cargo install cargo-nextest (or see the
nextest install docs). just test runs
both Rust passes for you. Plain cargo test --workspace still works if you
haven’t installed nextest, but CI runs nextest so prefer it locally.
The Ollama Cloud live smoke sends a billed request and is ignored by default:
OLLAMA_API_KEY=... ROLTER_OLLAMA_LIVE_MODEL=gpt-oss:20b \
cargo test -p rolter-gateway --test ollama_cloud live_smoke -- --ignored
The Gemini Interactions smoke is gated the same way. It exists because Google
publishes no full JSON schema for the interactions wire format, so parts of the
adapter — the multimodal part field names and some step.delta variants —
were inferred from prose docs and only a real request confirms them (#764):
GEMINI_API_KEY=... ROLTER_GEMINI_LIVE_MODEL=gemini-3.6-flash \
cargo test -p rolter-gateway --test gemini_interactions_live -- --ignored
Both run in CI only from dispatch-gated workflows, never the per-PR gate:
quality.yml takes no secrets by design (#734) so dependabot and fork PRs pass
exactly the same checks. Assertions in the live suites carry the upstream
response body in their failure message — with an inferred field name, the
provider’s complaint is the finding, and a bare status-code assertion would
throw it away.
Configuring the Gemini smoke
gemini-interactions-smoke.yml needs one secret before it can verify anything:
- Create a
live-providersrepository environment (Settings → Environments). Keeping the key there rather than at repository scope means a run against a billed provider is reviewable, not something any workflow can reach for. - Add
GEMINI_API_KEYto that environment. - Dispatch the workflow (
gh workflow run gemini-interactions-smoke.yml), optionally with-f model=<id>.
Until the secret exists the workflow fails rather than skipping. A green
tick from a run that made no request reads as “the wire format is still
confirmed” when nothing was checked — worse than no sweep at all. Pass
-f allow_unconfigured=true for a deliberate dry run of the workflow itself.
Each run records the wire shapes it observed into the job summary and uploads the full log as an artifact. A billable run should leave evidence behind: the next question about a field name is then answered by reading the last run rather than by spending another call.
What the suite covers, and why each probe exists:
| Probe | Confirms |
|---|---|
| text turn | turn mapping, system_instruction, generation_config, usage — all documented |
| inline image part | the inferred mime_type/data inline part shape |
| remote image part | the inferred file_uri shape — the other branch, which the inline probe never reaches |
| tool call round trip | function_call out, function_result back, and call_id correlation |
| interaction threading | the id rolter surfaces as the response id is the one Google accepts back |
| every client dialect | Chat Completions, Messages and Responses have separate response translators |
| every client dialect, streaming | the inferred step.delta variants, through all three separate SSE emitters |
A content part the dialect cannot carry is rejected at the gateway with
400 unsupported_content_part rather than being dropped (#882), so an
unconfirmed part shape fails loudly instead of producing a shortened body.
Test grouping is configured in .config/nextest.toml:
the Postgres-backed rolter-store/rolter-control suites share one database and
reset the schema per test, so they run in a single-threaded group to avoid
clobbering each other.
Layout
- Unit tests live next to the code in
#[cfg(test)] mod tests. Current coverage: balancer strategies (round-robin cycling, consistent-hash stability, cache-aware affinity, empty targets), the prefix trie, config parsing, model rewrite, auth checks, and the in-memory store. - Keep the pure crates (
rolter-core,rolter-balancer,rolter-auth) fully unit-testable without I/O.
Strategy as the project grows
- Integration tests for the gateway: spin up the Axum app with a mock upstream (
wiremock/httpmock) and assert routing, auth, model rewrite, error mapping and streaming passthrough. - Property tests (
proptest) for the balancer: distribution fairness, affinity invariants. - DB tests for
rolter-storePostgres backend behind a feature, using a disposable container. - Load tests (
oha/k6) against a mock upstream to track added latency and max RPS (see performance.md).
Chaos & resilience contracts
Resilience is asserted in two places, split by what each harness can drive deterministically.
The e2e chaos suite (integration/e2e/tests/test_chaos.py, compose chaos
profile) drives a static-config gateway against mock upstreams whose failure mode
is fixed by an env var. It covers what is observable purely over the wire: retry
and failover on 5xx/429, a clean 5xx when every target is down, the request
timeout bound on a slow upstream, the circuit breaker’s OPEN transition (asserted
via rolter_breaker_opened_total) and flap degrade/recovery.
The gateway chaos tests (crates/rolter-gateway/tests/chaos.rs) cover the two
contracts that need a request pinned at a known point inside the gateway, which a
black-box harness can only approximate with sleeps:
- bounded-queue backpressure — with
queue.capacity = 1,queue.workers = 1andbackpressure = "error", one request pins the sole worker, exactly one surplus request takes the queue slot, and the rest must come back as429witherror.code = "queue_full"whilerolter_provider_queue_rejections_totaladvances. Memory is bounded by the queue, not by client burst size. - graceful SIGTERM drain — a real
rolter-gatewaychild process is sentSIGTERMwhile a request is pinned upstream. The in-flight request must still return200, new connections must be refused, and the process must exit0.
Both use a mock upstream that blocks on a semaphore the test owns, so every step is driven by a signal rather than by elapsed time — there are no sleeps to race. Run them with:
cargo test -p rolter-gateway --test chaos
Benchmarks
Hot-path micro-benchmarks run under criterion. They live in crates/<crate>/benches/ with a [[bench]] harness = false entry per file, and cover the per-request cost that shows up as pure gateway overhead:
just bench # cargo bench --workspace
cargo bench -p rolter-balancer # just the balancer benches
cargo bench -p rolter-balancer --bench pick # one bench target
Current coverage:
rolter-balancer
pick—LoadBalancer::pickfor every built-in strategy over a ~24-target pool with a populatedRouteContext.trie— prefix-trieinsert(bounded/unbounded, so LRU eviction is measured) andlongest_prefixon a warm trie.
rolter-core
snapshot— the CPU side of config-snapshot generation at 10/100/1000 routes:sanitize_for_snapshot,validate, and the JSON encode./internal/snapshotis polled by every gateway in the fleet, so this cost is paid fleet-wide on every poll. The encode dominates — ~2.8 ms at 1000 routes against ~150 µs for sanitize — which is why payload size is its own metric (#845).
rolter-gateway
admission— the two registries every upstream attempt consults before anything else:Breaker::allowsandCooldowns::is_parked, plus the outcome-recording calls beside them. Covers the healthy steady state (no entries recorded), a warm fleet, a tripped/parked target and a 64-model fleet, so the cost is measured where a real gateway actually sits rather than only in the worst case (#1050).
criterion writes HTML reports to target/criterion/. Benches are not run in CI (timings are noisy on shared runners), but cargo clippy --workspace --all-targets -- -D warnings compiles them on every PR, so they cannot silently bit-rot. Use just bench-check (cargo bench --workspace --no-run) to compile them locally without running.
Coverage
Workspace line coverage is measured with
cargo llvm-cov:
cargo install cargo-llvm-cov
cargo llvm-cov --workspace --all-features --summary-only # quick %
cargo llvm-cov --workspace --all-features --html # browsable report
CI runs coverage in the coverage job of quality.yml and enforces a
ratcheting baseline: the committed baseline lives in
.github/coverage-baseline.txt, and
.github/scripts/coverage-ratchet.sh
fails the step if the current percentage drops more than
COVERAGE_TOLERANCE points (default 0.5) below it. The job also uploads the
lcov.info report as a CI artifact.
Policy (ROL-246):
- New code must not push coverage below
baseline − tolerance. If a PR legitimately lowers coverage, edit.github/coverage-baseline.txtin the same PR and explain why. - When coverage climbs well above the baseline, raise the baseline to lock in the gain (the ratchet only goes up).
- The job is informational (
continue-on-error: true) until the baseline is trusted; promote it to blocking by removing that flag on thecoveragejob.
CI
.github/workflows/ci.yml delegates to the shared quality.yml gate, which runs cargo fmt --check, cargo clippy -D warnings, cargo nextest run --workspace --all-features plus a cargo test --doc pass, the feature matrix, cargo doc (warnings as errors), cargo-deny, gitleaks, the UI lint/build, and a Conventional Commit PR-title check on every push/PR.
Secret scanning
The gitleaks job runs the gitleaks CLI from a digest-pinned container, not
gitleaks-action. The action gates org-owned repositories behind a license key,
and license secrets are invisible to both dependabot runs (a separate secret
store) and fork PRs (no secrets at all), so every such PR failed the job and with
it ci-ok. The CLI is free and unrestricted, so quality.yml now takes no
secrets and behaves identically for forks, dependabot and direct pushes.
Two passes run with the shared .github/config/gitleaks.toml policy: gitleaks dir over the working tree (everything the commit ships) and, on PRs, gitleaks git --log-opts base..head over the branch history (catches a secret added and
then removed inside the same PR). The pinned digest is v8.30.1 — the version
prek.toml already uses for the staged-content hook, so local and CI scans agree.
Reproduce a CI run locally:
docker run --rm -v "$PWD:/repo" -w /repo \
ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \
dir . --config .github/config/gitleaks.toml --redact --exit-code 1
Storybook play tests
The storybook job builds the static Storybook, serves it, and runs the
interaction (play) tests with @storybook/test-runner against a headless
chromium. It is a merge gate (#753): a failing play test fails quality,
which fails ci-ok. Locally:
cd ui
bunx playwright install --with-deps chromium chromium-headless-shell
bun run build-storybook
python3 -m http.server 6006 --directory storybook-static &
bun run test-storybook --url http://127.0.0.1:6006
ui/package.json pins playwright and playwright-core through overrides.
The test-runner declares its own loose playwright range, so without the pin it
resolves a different version from @playwright/test and launches a browser
revision playwright install never downloaded — the test-runner then fails at
launch and the play tests silently stop running (#737). Keep both on one version,
and install chromium-headless-shell alongside chromium, since the test-runner
launches the shell rather than the full build.
The job is informational (continue-on-error: true) pending the ROL-124
promotion path.
The screen-story harness
A screen story renders the real page component against a stubbed fetch, so it
exercises the same query wiring, empty/error branches and editor sheets that
ship. ui/src/pages/story-harness.tsx holds the shared pieces — it is not a
.stories.tsx file, so Storybook never tries to render it as a screen:
| Helper | What it is for |
|---|---|
Harness | swaps globalThis.fetch, clears the persisted scope, renders under a fresh QueryClient with retry: false |
scoped(handler) | answers the org → team → project chain every scoped screen resolves first, then defers to handler |
routes([...]) | fragment-matched routing table, matched in order so a longer path can precede the prefix it shares |
pending | a stub that never settles, for the loading state |
clickWhenEnabled | waits for a button to be enabled, not merely present |
sheet() / expectSheetClosed() | the editor sheet, which portals to document.body rather than into the canvas |
withConfirm / expectClosesWithoutPrompting | the discard guard from #868, asserted in both answers |
Two traps this encodes. Scope endpoints are matched on the whole pathname: a
screen’s own endpoint often contains one of them (/api/v1/projects/{id}/virtual-keys),
and a substring match would answer it with the project list. And most screens
disable their primary action until the three-request scope chain resolves, so
findByRole followed by a click races and throws pointer-events: none —
clickWhenEnabled is the fix.
Each screen should carry Loaded, Loading, Empty and an error/forbidden
story, one interaction story that opens the primary editor and saves, and at
least one story exercising the discard guard. Where a sheet opens pre-filled
(budgets seed 100 / 30d), assert the seed too: its dirty flag means “differs
from the seed”, not “is non-empty”, and getting that backwards makes an
untouched form prompt on every close.
Full-stack compose smoke
The compose-smoke job boots the production-shaped Docker Compose topology
(Postgres, Redis, ClickHouse, gateway, control) and exercises it end-to-end. Run
it locally with the same script CI uses:
bash docker/smoke/smoke.sh
It layers docker/docker-compose.ci.yml
over the base compose file: the overlay mounts
docker/smoke/rolter.smoke.toml (a
keyless open gateway config) so the built-in fake-llm model answers without any
provider secret. The script waits for both /healthz endpoints, checks
/v1/models and fake-llm chat (non-streaming + SSE) on the gateway and the
postgres-backed /internal/snapshot on the control plane, then always dumps
compose logs and runs down -v. It is informational (continue-on-error)
until the image-build cost and flake profile are trusted (ROL-245).
Contributing
Thanks for helping build rolter.
Workflow
- Branch from
master:feat/<issue-number>-<short>orfix/<issue-number>-<short>. - Make focused changes; add unit tests next to the code.
- Run
cargo fmt --all,cargo clippy --workspace --all-targets -- -D warnings, and the tests (just test, orcargo nextest run --workspace+cargo test --doc --workspace). - Use Conventional Commits for messages and the PR title.
- Link issues with
Closes #N/Refs #N. - Open a PR; fill in the template; CI must be green.
For parallel human or agent work, use the Worktrunk worktree workflow so each task has an isolated branch, index, and directory.
Code standards
- Rust 2021;
rustfmtdefaults; clippy clean with-D warnings. thiserrorfor library errors,anyhowin binaries.- Keep the data-plane hot path allocation-light; never block on locks (use
arc-swap). - Code comments start lowercase, no trailing punctuation;
///doc comments use normal prose. - New balancing strategy → implement
rolter_balancer::LoadBalancer+ wire intobuild(). - New storage backend → implement
rolter_storetraits behind a cargo feature.
Agent commits
Automated contributions include the trailer:
Co-Authored-By: Oz <oz-agent@warp.dev>
Don’t
- Don’t commit secrets (use env / the encrypted store).
- Don’t force-push
master; force-push is fine on your own feature branches. - Don’t
--amendafter pushing shared history.
Parallel development with Worktrunk
Rolter uses Worktrunk as a thin lifecycle and visibility layer over standard Git worktrees. Each development agent gets an independent directory, index, and branch while normal Git history and GitHub remain authoritative.
The workflow is agent-neutral. Codex, Claude, Z.ai, Warp, and other agents all use the same worktree layout and branch rules. Tool-specific Worktrunk plugins are optional local integrations; they are not required by the repository.
Install
On macOS or Linux with Homebrew:
brew install worktrunk
wt config shell install
Alternatively, install the Rust binary:
cargo install worktrunk
wt config shell install
Restart the shell, then confirm that the shell wrapper is active:
type wt
wt --version
wt config show
The repository’s .config/wt.toml identifies GitHub as the forge and starts a
background copy-on-write cache transfer for entries in .worktreeinclude.
Only reproducible build caches are selected. Credentials and .env files must
be configured independently in each worktree and are never copied by the
repository hook.
Project hooks require one-time approval. Review the rendered command before
approving it; agents should use --yes only after that review.
Start an independent task
Fetch first, then create the issue branch from the remote default branch:
git fetch origin master
wt switch --create fix/123-short-description --base origin/master
Use the repository branch format
<type>/<issue-number>-<short-description>. Never add an agent or person name
as a prefix.
Start the chosen agent inside the worktree that wt switch selected. An
orchestrator without shell integration can obtain paths from structured output:
wt list --format=json
Automation that persists this output should explicitly select Worktrunk’s new schema until it becomes the default:
wt --config-set list.json-schema=2 list --format=json
Every agent must own exactly one branch and worktree. Never let two agents push the same feature branch. Worktrees isolate files and indexes, but branch refs and remote-tracking refs are shared by the repository.
Dependent tasks
Use an explicit parent branch as the base:
wt switch --create feat/124-dependent-change --base feat/123-foundation
The child pull request targets the parent branch. After the parent merges,
fetch origin/master, rebase the child in its own worktree, validate it, and
push with --force-with-lease. Do not run repository-wide branch synchronizers
across active worktrees.
Inspect the agent fleet
wt list
wt list --full
Before assigning or cleaning work, inspect dirty state, divergence, conflicts, CI status, duplicate branches, and prunable registrations. Activity markers from Worktrunk plugins are advisory: a crashed or disconnected agent may leave a stale marker.
Commit and publish
Worktrunk manages worktree lifecycle only. Use normal repository commands for commits and publication:
prek run --all-files
git push --set-upstream origin HEAD
gh pr create --base master --draft
Use the immediate parent instead of master for a stacked pull request. Fill
the PR template, use a Conventional Commit title, and link the issue with
Closes #N only when the PR completes its acceptance criteria.
Do not use wt merge, wt step commit, wt step squash, or wt step push for
Rolter delivery. Merge through GitHub only after hosted ci-ok, review, and
acceptance-criteria verification. Worktrunk hooks are convenience automation,
not a security boundary, and --no-hooks can bypass them.
Remove completed work
Confirm the PR is merged and the worktree is clean before removal:
wt list --full
wt remove <branch>
The shared pre-remove hook runs cargo clean inside that worktree before
Worktrunk deletes it. This reclaims the copied target/ cache while the path
still exists; source files and other worktrees are unaffected.
Worktrunk deletes a branch only when it can prove the branch adds no changes to the default branch. When the merge state is uncertain, preserve the branch:
wt remove --no-delete-branch <branch>
Never use --force or --force-delete in an automated cleanup path. Treat
prunable legacy registrations separately: inspect git worktree prune --dry-run, verify every path, and only then run git worktree prune.
Optional local integrations
Install only the plugins for agents used on a particular machine:
wt config plugins codex install
wt config plugins claude install
wt config plugins opencode install
wt config plugins gemini install
Agents without a Worktrunk plugin, including Z.ai or Warp-based agents, simply
run inside the path created by wt switch. Repository behavior must never
depend on a specific agent plugin being installed.
Dashboard localization (i18n)
The dashboard SPA under ui/ is translated with i18next + react-i18next.
English is the base catalog; Russian ships alongside it. Switching language is
instant — react-i18next re-renders the tree, nothing reloads and no route
changes.
Everything is bundled. There is no CDN, no language-detection service and no runtime fetch to a translation host, because rolter must run fully air-gapped.
Layout
| Path | What it is |
|---|---|
ui/src/lib/i18n/index.ts | i18next init, locale detection, setLocale() |
ui/src/lib/i18n/locales/en.json | base catalog — the source of truth for keys |
ui/src/lib/i18n/locales/ru.json | Russian catalog |
ui/src/lib/i18n/format.ts | useFormat() — Intl number/currency/date bound to the active locale |
ui/src/lib/i18n/parity.ts | the parity rules, shared by the test and the CI gate |
ui/src/lib/i18n/literals.ts | the hardcoded-literal rules, likewise shared |
ui/src/lib/i18n/literals-baseline.json | the recorded pre-existing literals — debt, written down |
ui/src/components/LocalePicker.tsx | the switcher in the sidebar footer |
ui/scripts/check-i18n.ts | bun run check:i18n — the catalog-parity CI gate |
ui/scripts/check-literals.ts | bun run check:literals — the hardcoded-literal CI gate |
en is imported statically because it is the fallback and must always resolve.
Every other locale is a dynamic import(), so Vite emits it as its own chunk
and a user only downloads the language they actually pick.
Key naming
Keys are nested and grouped by where the copy lives:
| Prefix | Holds |
|---|---|
common.* | words reused everywhere — Cancel, Delete, Search… |
shell.* | the app frame: sidebar chrome, user menu, screen header |
nav.<screen-key> | one sidebar label per nav entry |
screens.<screen-key>.title / .subtitle | the screen header, also used by the stub |
pages.<screen-key>.* | copy inside a screen |
auth.*, scope.*, locale.* | the login screen, scope switcher, language picker |
<screen-key> is the nav key, which is also the route path — so a screen’s
label, header and body copy are all found by the same string.
Keep keys semantic (pages.dashboard.noTraffic), never the English text
itself: re-wording the English copy should not force a key rename.
Writing translatable copy
import { useTranslation } from "react-i18next";
const { t } = useTranslation();
return <h2>{t("pages.dashboard.recentTitle")}</h2>;
Interpolation — name the value, never concatenate:
// en.json
"roleWithOrg": "Admin · {{org}}"
t("shell.roleWithOrg", { org: orgName })
Plurals — use count, and let i18next pick the form. English needs two
forms, Russian needs four; the catalogs differ on purpose and the gate knows
the difference:
// en.json // ru.json
"errors_one": "{{count}} error", "errors_one": "{{count}} ошибка",
"errors_other": "{{count}} errors" "errors_few": "{{count}} ошибки",
"errors_many": "{{count}} ошибок",
"errors_other": "{{count}} ошибки"
t("pages.dashboard.errors", { count: errors })
Markup inside a sentence — use <Trans> with numbered slots, so each
language can put the emphasised part where its grammar wants it:
"deleteHint": "This removes <0>{{name}}</0>. This cannot be undone."
<Trans
i18nKey="scope.deleteHint"
values={{ name }}
components={[<span key="name" className="font-mono" />]}
/>
Never interpolate a noun into a sentence frame. "Add {{level}}" cannot be
declined correctly in Russian (and most inflected languages). Spell each case
out as its own key — scope.addOrg, scope.addTeam, scope.addProject — and
select it with a lookup table in the component.
Numbers, money and dates
Use useFormat(), never bare toLocaleString(). A bare call follows the
browser locale, so a ru-RU browser rendered Russian separators inside an
otherwise English panel.
const fmt = useFormat();
fmt.number(1234567); // 1,234,567 / 1 234 567
fmt.currency(0.0004); // $0.0004 (sub-cent costs stay visible)
fmt.percent(0.734); // 73.4%
fmt.dateTime(row.ts);
Adding a locale
- Copy
ui/src/lib/i18n/locales/en.jsonto<code>.jsonand translate it. Leavelocale.en/locale.ru/ … as native names in every catalog — a language is always listed in its own language, so a user who picked the wrong one can find their way back. - Register it in
ui/src/lib/i18n/index.ts: add the code toLOCALES, a native name toLOCALE_NAMES, a two-letter badge toLOCALE_SHORT, and a dynamic import toloaders. - Run
bun run check:i18n— it will list every key you still owe, and every plural form the language requires.
No other file changes. The picker builds itself from LOCALES.
Locale selection
detectLocale() resolves, in order:
- the stored choice in
localStorage["rolter.locale"], - the browser’s
navigator.languages, matched on the primary subtag soru-RUresolves toru, en.
Picking a language persists the choice and sets <html lang>. This is
dashboard-only state — it is not written to the account, so it needs no
control-plane change. (Per-user server-side persistence is tracked separately.)
Keeping catalogs honest
cd ui
bun run check:i18n # the CI gate
bun test src/lib/i18n # the same rules, plus runtime behaviour
The gate fails on: a key missing from a locale (which would silently fall back
to English mid-sentence), an orphaned key whose screen is gone, an empty
string, a dropped {{placeholder}} or <0> slot, and an incomplete set of
plural forms. Both entry points share ui/src/lib/i18n/parity.ts, so bun test and CI cannot disagree.
When you add or re-word copy, update en.json and every other catalog in
the same PR — a half-translated catalog is a CI failure, not a follow-up.
Catching strings that never reach a catalog
Catalog parity is a property of the catalogs. It cannot see the failure that
actually happens: a string hardcoded in JSX, which is not in any catalog by
definition. That is how EditorSheet shipped window.confirm("Discard unsaved changes?") and a default "Cancel" label with a green merge gate (#871).
cd ui
bun run check:literals # fail on any literal not in the baseline
bun run check:literals --update # re-record the baseline (only to shrink it)
It scans src/components/** and src/pages/** for three shapes:
| Kind | What it matches |
|---|---|
dialog | a string literal passed to window.confirm / alert / prompt |
prop | prose assigned to a user-facing prop or destructured default — title, label, placeholder, description, aria-label, cancelLabel, … |
text | prose sitting directly between JSX tags |
A line containing t( is skipped, because that line is already doing the right
thing. Acronyms, identifiers, URLs and bare numbers are not copy and are
ignored. Stories and tests are not scanned: a story’s job is to render a
component with concrete sample text, and routing that through the catalogs would
make the stories test the catalogs instead of the component.
Why there is a baseline
The rule is repo-wide and the debt predates it — several hundred literals
already exist. Failing on all of them would mean either a several-hundred-string
translation PR nobody asked for, or a gate that is permanently red and therefore
ignored. So the existing set is recorded in literals-baseline.json and the gate
fails on anything new.
The baseline only shrinks. When you translate a recorded literal, the gate tells you the entry is stale and asks you to drop it, so the same string cannot come back unnoticed.
It is a lexical scan, not a TypeScript parse. The question is “does a
user-visible English string appear where a t() call belongs”, and every
candidate is a string literal or a JSX text node — both of which a regex reads
accurately enough to answer it, without a compiler dependency in a script that
runs on every push.
Dashboard error states
A screen that cannot load its data has to say why. Before #962 every screen
rendered the same sentence — Failed to load X. — for causes needing entirely
different responses, so it pointed at none of them.
That is not a hypothetical cost. During the #924 dogfooding pass the Keys screen
showed “Failed to load your keys.” while the real cause was every
/api/v1/me/* route returning 401 (#942). The message sent the operator to
check their key configuration; the actual cause was found afterwards by reading
traces. An error that cannot separate you are not signed in from the server
is down costs more time than no error at all, because it invites a wrong
hypothesis and the operator spends their attention there first.
The rule
Never render a load failure by hand. Use LoadError:
{keys.error && (
<LoadError
error={keys.error}
resource={t("errors.resources.virtualKeys")}
onRetry={() => keys.refetch()}
/>
)}
resource is the translated noun for what failed — it is interpolated into the
title, so it reads as a sentence in every locale. Pass onRetry whenever the
caller holds a query handle; the component decides whether offering it is
honest.
What it distinguishes
classifyLoadError in ui/src/lib/load-error.ts maps a thrown value to one of
six kinds. ApiError already carries status and the control plane’s code,
so no screen has to parse a message to find out what happened.
| kind | cause | recovery offered |
|---|---|---|
unauthenticated | 401 | sign in again |
forbidden | 403 | none — ask an administrator |
openMode | 401 with code open_mode_no_session | none — set ROLTER_ADMIN_TOKEN |
unreachable | the thrown value is not an ApiError, so fetch never connected | retry |
server | 5xx | retry |
unknown | any other non-ok status | retry |
Two of these are easy to collapse and must not be. A plain 401 is fixed by
signing in; open_mode_no_session is a control plane running with no admin
token, which has no accounts to sign into at all — signing in again is exactly
the wrong advice. And a retry button on a 403 suggests the failure was transient
when it was a permission, so isRetryable withholds it.
Two things that are not this component
An empty result is not a failure. A successful request returning zero rows renders an empty state. Routing it here would tell an operator something is broken when nothing is.
The control plane’s own message is never swallowed. LoadError prints it
beneath the summary. The dashboard’s classification is a helpful gloss, not a
replacement — #962 happened because the gloss was the only thing on screen and
it was wrong.
Adding a screen
Add the resource noun to errors.resources.* in every catalog under
ui/src/lib/i18n/locales/ (see i18n) and use it as above. The six
errors.load.* kinds already exist; a new screen needs no new error copy.
Commit conventions
rolter uses Conventional Commits for commit messages and PR titles. CI checks PR titles; the conventional-pre-commit hook managed by prek checks local messages.
Format
<type>(<scope>): <subject>
<body>
<footer>
- type (required):
feat,fix,perf,refactor,docs,test,build,ci,chore,revert - scope (recommended):
gateway,balancer,proxy,core,store,auth,control,ui,docs,infra,ci,deps,release - subject: imperative, lowercase, ≤ 72 chars, no trailing period
- breaking change: add
!after the scope and aBREAKING CHANGE:footer
Examples
feat(balancer): add precise kv-event cache-aware scorer
fix(gateway): stream anthropic sse without buffering
perf(proxy): reuse pooled client per egress proxy
docs(architecture): document reload-free config propagation
refactor(core)!: rename ModelRoute.targets to upstreams
BREAKING CHANGE: config field `targets` is now `upstreams`.
Issues & PRs
- Link issues from the body/footer:
Closes #123,Refs #123. - PR title must be a single valid Conventional Commit line (enforced by CI via
amannn/action-semantic-pull-request). - Squash-merge so the PR title becomes the commit on
master; keeps history releasable and changelog-friendly.
Tooling
.config/commitlint.config.mjs— rules (types, scopes, lowercase subject, 72-char header).prek.toml— fast commit-time hygiene, secret scanning, formatting/linting, commit-message validation, and pre-push test/security gates.- Install all configured hook stages with
prek install --prepare-hooks. The configuration installspre-commit,commit-msg, andpre-pushshims. - Run commit-time checks manually with
prek run --all-files. - Run the push gate manually with
prek run --all-files --hook-stage pre-push.
The commit stage uses prek’s built-in checks plus pinned Gitleaks and
Conventional Commit hooks. Project-specific checks require actionlint,
taplo, and typos on PATH. The push stage also requires cargo-deny and
Bun when UI files are part of the push. Install cargo-nextest for CI-equivalent
test execution; the hook falls back to cargo test when it is unavailable.
Packaging & distribution
rolter ships three ways.
The unified rolter binary dispatches to both planes via subcommands:
rolter gateway --config rolter.toml # data plane
rolter control --database-url postgres://… # control plane + UI host
The standalone rolter-gateway / rolter-control binaries remain available.
cargo
cargo install rolter # unified launcher (from crates.io)
# or from source:
cargo install --path crates/rolter
uv (PyPI wheel via maturin)
The wheel bundles the compiled rolter launcher so Python users can install the CLI with uv. pyproject.toml uses the maturin backend (bindings = "bin", manifest-path = crates/rolter/Cargo.toml).
Each release publishes five wheels plus a source distribution:
| artifact | built on |
|---|---|
manylinux…x86_64 | ubuntu-latest, target: x86_64 |
manylinux…aarch64 | ubuntu-latest, target: aarch64 |
macosx…arm64 | macos-latest (Apple Silicon, native) |
macosx…x86_64 | macos-latest, cross-compiled target: x86_64-apple-darwin |
win_amd64 | windows-latest |
.tar.gz (sdist) | ubuntu-latest, command: sdist |
The macOS x86_64 wheel is cross-compiled rather than built on an Intel runner —
the macOS SDK carries both architectures, so it needs no extra runner. The sdist
is the fallback for anything with no matching wheel: without one, pip install rolter fails outright on an unlisted platform instead of building from source.
verify-parity asserts all six are present for the version, so a silently
missing platform fails the release rather than reaching a user.
uv tool install maturin # one-time
uvx maturin build --release # build a wheel into target/wheels/
uv tool install rolter # once published to PyPI
Docker
Multi-stage docker/Dockerfile builds the Rust binaries and the Bun-built UI, then assembles a slim runtime:
docker build -f docker/Dockerfile -t rolter:dev .
docker compose -f docker/docker-compose.yml up -d # full stack with postgres/redis/clickhouse
Release pipeline
Releases are fully automated from Conventional Commits. Two workflows do the work, and the handoff between them is the part worth understanding.
merge to master
│
▼
release-plz.yml ── release-pr ──► "Release PR" (version bump + changelogs)
│
│ (that PR is merged)
▼
release-plz.yml ── verify ──► release ──► crates.io publish
tag v{version}
github release
│
│ workflow_dispatch -f tag=v{version} ← dispatch-artifact-release
▼
release.yml
│
├─ gate ──── verify-external-checks (ci-ok + CodeQL green for the tagged sha)
│
├─ build ─── build-wheels (5 wheels + sdist)
│ build-image (per arch, pushed as untagged digests)
│
├─ smoke ─── smoke-wheels (install each wheel, run `rolter --version`)
│ smoke-image (run each image digest, check its version)
│
├─ publish ─ publish-pypi (trusted publishing, OIDC)
│ publish-docker (assemble tag manifests: GHCR + Docker Hub)
│
└─ check ─── verify-parity (all channels serve {version})
The stages are a barrier, not decoration. Every publish job depends on every
build and smoke job, so a release is all-or-nothing: a failed wheel can no
longer leave container images published against a version that has nothing on
PyPI. Before this split, publish-docker did not depend on build-wheels at
all, and exactly that partial release was possible.
Two properties make the barrier real:
- Images are built as untagged digests (
push-by-digest). A digest nobody can resolve by tag is not a release;publish-dockeronly assembles the:{version}and:latestmanifests once everything else has passed, from those same digests — so the image is never rebuilt. - Nothing is published untested. The smoke stage installs each wheel and
runs each image digest, asserting
rolter --versionmatches the tag, while the artifacts are still private. The wheel install uses--no-index, so it can only resolve from the freshly builtdist/and can never pass by silently pulling an older rolter from PyPI.
Each build is its own job, so a single flaky platform can be re-run on its own without re-publishing anything that already succeeded.
Why the explicit dispatch
release.yml also has a push: tags trigger, but it never fires for a real
release. release-plz creates the tag with the repository GITHUB_TOKEN, and
GitHub suppresses downstream workflow events for token-created refs
to prevent recursive runs. workflow_dispatch is the documented exception — it
always creates a run, even from the GITHUB_TOKEN — so release-plz.yml ends
with a dispatch-artifact-release job that calls
gh workflow run release.yml -f tag=vX.Y.Z. That job holds actions: write and
nothing else, and it fails if the dispatch produces no run.
Without it the pipeline half-works in the worst way: the GitHub release and
crates.io advance while no wheel is ever built, and every job stays green. That
is how v0.0.6 through v0.0.10 shipped while PyPI sat on 0.0.5 (#903).
scripts/check-release-handoff.sh (a merge gate in quality.yml and a prek
hook) asserts the wiring is still in place.
Which tag the dispatch carries
releases, the output release-plz hands back, contains a tag for every
published crate — not only the one with git_tag_enable. The crates.io-only
members get a derived <crate>-v<version> string that was never pushed to git.
Only rolter-gateway is configured with git_tag_name = "v{{ version }}", so
resolve release tag selects that entry by package name; an empty result
means nothing was released on this push and the dispatch job is skipped.
Taking the first tagged entry instead is what broke v0.0.11 (#1026): the array
starts with rolter-core, the dispatch carried rolter-core-v0.0.11, and
release.yml failed at checkout on a ref that does not exist — so v0.0.11 went
to crates.io and GitHub Releases with no wheel, and PyPI stayed on 0.0.10. The
step now also rejects any resolved tag that is not vX.Y.Z, so a bad ref fails
before it is dispatched rather than halfway through the artifact build.
Parity gate
verify-parity runs with always() at the end of release.yml and asserts
that every enabled channel actually serves the tagged version: the GitHub
release exists, crates.io has rolter {version}, PyPI has it (when
PYPI_PUBLISH_ENABLED is true), and GHCR has the manifest (when
DOCKER_PUBLISH_ENABLED is true). A skipped or failed publish turns the run
red instead of quietly leaving a channel behind.
Publishing gates
| Gate | Effect |
|---|---|
verify-external-checks | ci-ok and CodeQL recorded success for the tagged commit; fail-closed |
RELEASE_REQUIRED_CHECKS repo variable | exact check-run names the gate above requires (comma-separated) |
PYPI_PUBLISH_ENABLED repo variable | must be "true" or the PyPI publish is skipped |
DOCKER_PUBLISH_ENABLED repo variable | must be "true" or the image publish is skipped |
pypi environment | PyPI trusted publishing via OIDC; no long-lived token is stored |
Wheels are built with maturin-action but uploaded with pypa/gh-action-pypi-publish:
maturin upload is deprecated and slated for removal (PyO3/maturin#2334). The
publisher identity PyPI matches on is the repository, workflow filename and
environment — not the tool — so the swap is transparent to the trusted-publisher
config, and it adds PEP 740 attestations on the id-token grant the job already
holds.
The gate is asserted, not re-run
release.yml does not run quality.yml itself. It asserts that the tagged
commit already passed it, by requiring ci-ok among the check-runs recorded for
that SHA. That is deliberate, and it is what makes the gate correct:
A local reusable workflow (uses: ./…) always checks out the caller’s ref. On
a workflow_dispatch the caller ref is master, while build-wheels checks out
inputs.tag — so a re-run verified master and shipped the tag (#988). It
passed, and told you nothing about what was being packaged. Threading the tag
into quality.yml fixes that but makes the shared workflow check out an
arbitrary dispatch-supplied ref in a default-branch context, whose caches
trusted runs later restore — cache poisoning, and CodeQL flags it.
Asserting settles both. Every commit on master carries a ci-ok check-run from
ci.yml, and release-plz re-runs the gate on the release commit before tagging,
so a tagged commit is verified by construction. The assertion binds to the
tagged SHA — which re-running never did — costs no duplicate 20-minute run,
and checks out nothing.
Because release-plz dispatches the moment it finishes tagging, ci-ok is often
still running for that commit. A pending check is therefore expected, not a
failure: the job waits up to 45 minutes for a verdict, fails immediately on a
real non-success, and fails closed if a required check never appears.
RELEASE_REQUIRED_CHECKS holds exact check-run names, so it rots whenever a
scanner is renamed or reconfigured — and since the gate is fail-closed, a stale
name silently blocks every release instead of failing at the source. This bit
rolter once already: the variable still named the CodeQL default setup jobs
(Analyze (rust), …) after the repo moved to advanced setup (codeql (rust),
…), so no release could publish even with a working tag dispatch. If the gate
reports “required check … not found”, compare it against the check-run names
the job log prints and update the variable.
Releasing a tag by hand
For a backfill, or if a dispatch was lost, run the artifact half yourself from the default branch:
gh workflow run release.yml -f tag=v0.0.10
gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')"
The upload uses --skip-existing, so re-running a tag that partly published is
safe. Verify the result:
curl -s https://pypi.org/pypi/rolter/json | jq -r .info.version
uv tool install rolter && rolter --version
Backfill policy
Only the current release is backfilled to PyPI. The versions the broken
handoff skipped (v0.0.6 – v0.0.9) stay unpublished: they are superseded pre-1.0
releases, uv tool install rolter / pip install rolter resolve to the latest
version regardless, and publishing them retroactively would put four versions on
the index that no user ever pinned, dated years after their tags. Anyone who
needs one of them can build from the tag or cargo install rolter@0.0.x from
crates.io, which has the complete series.
Zero-config quickstart: no keys, add providers at runtime
rolter starts with zero LLM credentials and serves the built-in fake-llm
model out of the box. Real providers, models, and upstream API keys are added
later — at runtime, over the management API, persisted in Postgres, and
picked up by the gateway without a restart.
1. Start with no credentials
rolter easy-up
That’s it. No provider keys, no database, no config file (one is created from the bundled example on first run). The gateway answers immediately:
curl http://localhost:4000/v1/chat/completions \
-H "Authorization: Bearer sk-rolter-dev" \
-H "Content-Type: application/json" \
-d '{"model": "fake-llm", "messages": [{"role": "user", "content": "hi"}]}'
sk-rolter-dev is the local-dev virtual key from the generated
rolter.toml; delete the [[virtual_keys]] section to run open, or replace
it before exposing the gateway anywhere.
2. Switch on runtime management (Postgres mode)
Runtime CRUD over providers/models/keys needs the database-backed control plane:
export ROLTER_ADMIN_TOKEN="$(openssl rand -hex 24)" # protects the management API
export ROLTER_KEK="$(openssl rand -hex 32)" # encrypts provider keys at rest
rolter easy-up --database-url postgres://user:pass@localhost:5432/rolter
easy-up migrates, seeds a default org/team/project, imports the bootstrap
toml, and starts both planes. The gateway port (4000) now also serves the
management API: /admin/* proxies to the control plane’s /api/v1/*.
Two deployment secrets matter here:
ROLTER_ADMIN_TOKEN— bearer token required on the management API and the internal snapshot endpoint. Without it those endpoints are open (fine on localhost; a startup warning reminds you).ROLTER_KEK— key-encryption key. Provider API keys submitted over the API are sealed with AES-256-GCM before they reach Postgres; the KEK never leaves the process environment. Set the same value on the control plane and gateway (witheasy-upit is one process, so one export). Without a KEK, requests that include anapi_keyare rejected — there is no plaintext fallback.
3. Add your first real provider — with its key — via curl
BASE=http://localhost:4000/admin
# ids seeded by easy-up
ORG=$(curl -s $BASE/orgs -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" | jq -r '.[] | select(.name=="default") | .id')
TEAM=$(curl -s $BASE/orgs/$ORG/teams -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" | jq -r '.[0].id')
PROJECT=$(curl -s $BASE/teams/$TEAM/projects -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" | jq -r '.[0].id')
# provider + upstream credential (sealed at rest; never returned by the API)
PROVIDER=$(curl -s -X POST $BASE/orgs/$ORG/providers \
-H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "openai", "kind": "openai", "api_base": "https://api.openai.com", "api_key": "sk-..."}' \
| jq -r .id)
# public model name + target
ROUTE=$(curl -s -X POST $BASE/projects/$PROJECT/routes \
-H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "strategy": "round_robin"}' | jq -r .id)
curl -s -X POST $BASE/routes/$ROUTE/targets \
-H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"provider_id\": \"$PROVIDER\", \"upstream_model\": \"gpt-4o\"}"
Within the snapshot poll interval (instantly with --redis-url) the gateway
serves the new model — no restart:
curl http://localhost:4000/v1/models -H "Authorization: Bearer sk-rolter-dev"
4. Rotate or remove a credential
PUT /admin/providers/{id} updates a provider in place. For api_key,
api_key_env, and egress_proxy: omit the field to leave it unchanged, send
an empty string to clear it, send a value to set/rotate it.
# rotate
curl -X PUT $BASE/providers/$PROVIDER \
-H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"api_key": "sk-new-key"}'
# remove the stored key (falls back to api_key_env, if set)
curl -X PUT $BASE/providers/$PROVIDER \
-H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"api_key": ""}'
How the pieces fit
- Persistence — providers/routes/keys live in Postgres; credentials in
provider_keys, AES-256-GCM-sealed with theROLTER_KEK-derived key. - Propagation — every write bumps
config_version(a database trigger, transactional with the write). Gateways pollGET /internal/snapshot?version=Nand hot-swap their routing snapshot; with Redis configured the control plane also publishes a bump for instant refetch. See Config & hot reload. - Two surfaces, one API —
/admin/*on the gateway is a thin reverse proxy to the control plane’s/api/v1/*(enable on a standalone gateway with--admin-url http://control:4001); pointing tooling at either works identically. Authentication is enforced by the control plane in both cases. - Config file still wins — anything declared in the bootstrap
rolter.tomlis a read-only “config model” (LiteLLM-style): the API rejects runtime mutations to it with409. - Reads never leak secrets —
GET /api/v1/config(the dashboard read) redactsapi_key; only the token-guarded snapshot endpoint carries decrypted keys, because the gateway needs them to call upstreams.
Configuration reference
The gateway boots from a TOML file (--config, default rolter.toml); see rolter.example.toml. At runtime, the control plane is the source of truth and applies changes without a restart (config-and-hot-reload.md).
Schema
[server]
host(string, default0.0.0.0)port(u16, default4000)metrics_path(string, default/metrics) — path the Prometheus metrics endpoint is served on; change it to avoid colliding with an upstream app or sidecar that already owns/metrics. Must be rooted (/…) and must not collide with a built-in route (/healthz,/v1/*).
[tls]
ca_bundles(string[], default[]) — PEM CA-bundle files added to the normal public-root trust store for outbound upstream TLS.ROLTER_CA_BUNDLEreplaces this global list with a single deployment-local path. Files are checked for missing, unreadable, empty, and malformed content while config is loaded.
[[providers]]
name(string, unique) — referenced by route targetskind(openai|anthropic|openai_compatible|ollama|ollama_cloud|llama_cpp|openrouter|tei|azure_openai|bedrock|vertex|gemini|gemini_native|gemini_interactions|mistral|groq|xai|meta_llama_api|cohere|perplexity|together|fireworks|databricks|aleph_alpha|nebius|ovhcloud|scaleway|deepseek|qwen|zhipu|kimi|ernie|doubao|hunyuan|yi|minimax|baichuan|gigachat|yandex_gpt|cloud_ru|mts_ai|naver|upstage|rinna|rakuten|sarvam|krutrim|falcon)api_base(string) — base URL, no trailing slashapi_key(string, optional) — preferapi_key_envapi_key_env(string, optional) — environment variable to read the key fromrole_profile(openai|system_only|anthropic, optional) — explicit instruction-role semantics. The default isopenaiforkind = "openai",anthropicforkind = "anthropic", and conservativesystem_onlyfor every OpenAI-compatible kind.system_onlyconverts leadingdevelopermessages tosystemin place; it rejects asystemordevelopermessage after a user/assistant/tool turn withrole_capability_unsupportedrather than silently changing it.model_role_profiles(table, optional) — upstream-model-specificrole_profileoverrides. Use this only for a custom template whose developer-role support is explicitly known; rolter never probes a vLLM template at runtime.
Role-capability profiles
openai_compatible describes the HTTP surface only. vLLM, in particular,
renders roles using the selected model’s chat template, so an endpoint’s role
support must not be inferred from its /v1 API. The default system_only
profile is suitable for Qwen-style templates that do not define developer.
Set role_profile = "openai" or a model_role_profiles entry only after
confirming that the deployed template supports distinct developer messages.
Anthropic targets collect leading OpenAI developer and system messages into
ordered top-level system blocks. Instruction messages placed after a
conversation turn are rejected for anthropic and system_only profiles;
rolter returns an OpenAI-style 400 with code
role_capability_unsupported instead of dropping or reclassifying them.
Ollama: local daemon vs Cloud
Use ollama for a local/self-hosted daemon such as http://localhost:11434 (no authentication). Use ollama_cloud for direct programmatic Cloud access. Cloud requires api_key_env (normally OLLAMA_API_KEY); inline keys and key pools are rejected. Configure api_base = "https://ollama.com"; rolter uses the OpenAI-compatible /v1/chat/completions and /v1/models endpoints with bearer authentication. Ollama’s native /api/* endpoints are distinct.
[[providers]]
name = "ollama-cloud"
kind = "ollama_cloud"
api_base = "https://ollama.com"
api_key_env = "OLLAMA_API_KEY"
Azure OpenAI, Amazon Bedrock, and Vertex AI
These providers use their current OpenAI-compatible APIs. Set api_base to the
provider’s OpenAI-compatible prefix and use an environment-sourced credential:
[[providers]]
name = "azure"
kind = "azure_openai"
api_base = "https://RESOURCE.openai.azure.com/openai/v1"
api_key_env = "AZURE_OPENAI_API_KEY"
[[providers]]
name = "bedrock"
kind = "bedrock"
api_base = "https://bedrock-runtime.us-east-1.amazonaws.com/v1"
api_key_env = "AWS_BEARER_TOKEN_BEDROCK"
[[providers]]
name = "vertex"
kind = "vertex"
api_base = "https://aiplatform.googleapis.com/v1/projects/PROJECT/locations/global/endpoints/openapi"
api_key_env = "VERTEX_ACCESS_TOKEN"
Azure credentials are sent in the api-key header. Bedrock and Vertex
credentials are sent as bearer tokens. The default active-health probes use
Azure’s model list, Bedrock ListFoundationModels, and Vertex’s publisher model
list, respectively; none invokes a model.
Google Gemini, Mistral, Groq, and xAI (Grok) expose hosted OpenAI-compatible
APIs. Their
api_base already carries the version segment, so rolter strips the leading
/v1 from the gateway path before appending it. Keys are bearer tokens sourced
from api_key_env (inline keys are rejected); the free health probe lists
{api_base}/models.
gemini_native targets Gemini’s native generateContent surface instead of its
OpenAI-compatible shim. rolter translates OpenAI Chat / Anthropic Messages /
OpenAI Responses requests into Gemini’s contents/parts wire format and
converts the response (and SSE stream) back, so clients keep speaking their
usual protocol. The model and method are embedded in the URL
({api_base}/models/{model}:generateContent, or :streamGenerateContent?alt=sse
for streaming), the key is sent as x-goog-api-key, and api_base points at the
version root with no /openai suffix.
gemini_interactions targets Gemini’s stateful Interactions API. Every
chat-shaped request is translated onto the single {api_base}/interactions
endpoint: turns become input items, system/developer messages become
system_instruction, tool calls and tool results become function_call /
function_result items, and sampling parameters become generation_config. The
response steps[] (and the interaction.created / step.delta /
interaction.completed SSE events) are converted back into the client’s dialect.
The thread is client-driven: the interaction id is returned as the response id,
and a client resumes it by sending previous_response_id (OpenAI Responses) or
previous_interaction_id — rolter keeps no interaction state of its own. Auth is
x-goog-api-key, and api_base points at the version root. Endpoints with no
interactions equivalent (embeddings, audio) are rejected rather than forwarded.
[[providers]]
name = "gemini"
kind = "gemini"
api_base = "https://generativelanguage.googleapis.com/v1beta/openai"
api_key_env = "GEMINI_API_KEY"
# native generateContent wire format (translated from OpenAI/Anthropic)
[[providers]]
name = "gemini-native"
kind = "gemini_native"
api_base = "https://generativelanguage.googleapis.com/v1beta"
api_key_env = "GEMINI_API_KEY"
# stateful Interactions API (translated from OpenAI/Anthropic/Responses)
[[providers]]
name = "gemini-interactions"
kind = "gemini_interactions"
api_base = "https://generativelanguage.googleapis.com/v1beta"
api_key_env = "GEMINI_API_KEY"
[[providers]]
name = "mistral"
kind = "mistral"
api_base = "https://api.mistral.ai/v1"
api_key_env = "MISTRAL_API_KEY"
[[providers]]
name = "groq"
kind = "groq"
api_base = "https://api.groq.com/openai/v1"
api_key_env = "GROQ_API_KEY"
[[providers]]
name = "xai"
kind = "xai"
api_base = "https://api.x.ai/v1"
api_key_env = "XAI_API_KEY"
[[providers.api_keys]](optional) — multiple weighted API keys for one provider; when present it takes precedence over the singleapi_key/api_key_envpair. Providers cap throughput per key, so rotating across keys multiplies effective RPM/TPMkey(string, optional) — inline key value; preferenvenv(string, optional) — environment variable to read the key fromweight(u32, default1) — relative selection weight
api_key_env(string, optional) — env var to read the key fromegress_proxy(string, optional) — legacy single HTTP/HTTPS/SOCKS5 outbound proxy; treated as a one-element poolegress_proxies(string[], optional) — round-robin HTTP, HTTPS, SOCKS5, or SOCKS5H proxy pool. A connect/tunnel failure retries the next member; three consecutive failures quarantine a member for 30 seconds. Authenticated proxy URLs must be supplied as whole-value environment references such as"${PROVIDER_PROXY_EU}", keeping credentials out of config snapshots and database/API outputca_bundles(string[], optional) — provider-specific replacement for global[tls].ca_bundles;[]explicitly selects public roots only[providers.kv_events](optional) — vLLM V1 ZMQ KV-event source forprecise_cache_aware:endpoint(tcp://…),topic(defaultkv-events),max_blocks(default 1,000,000), andstale_secs(default 30)[providers.lmcache](optional) — LMCache controller signal forlmcache_aware:endpoint(HTTP JSON occupancy signal),refresh_secs(default 2), andstale_secs(default 10)also_track_via_llm_call(bool, defaultfalse) — when set, active health checks send a realmax_tokens = 1completion to this provider instead of the free/v1/modelsliveness probe, so a healthy result proves end-to-end inference. This burns a few tokens on every sweep (interval_secs); leave it off unless you need inference-level health. Recorded assource = llm_callinprovider_health_events.llm_probe_model(string, optional) — the upstream model id thealso_track_via_llm_callcompletion targets (e.g.gpt-4o-mini). Required when the flag is on; without it (or an api key) the checker logs a warning and falls back to the free probe.status_page_url(string, optional) — statuspage.io-stylestatus.jsonURL (e.g.https://status.anthropic.com/api/v2/status.json). When set, a slow background poll records the provider’s public status as a secondarystatus_pagehealth signal — it surfaces inprovider_health_events, the dashboard androlter_status_page_degraded_total, but never marks the provider unhealthy or affects routing on its own. Parse/transport failures are logged and skipped.
See Custom CA bundles for rotation behavior and Docker/Kubernetes mount examples.
[[routes]]
model(string) — public model name clients requeststrategy(round_robin|random|power_of_two|consistent_hash|cache_aware|weighted|pipeline|cheapest|fastest|precise_cache_aware|lmcache_aware|adaptive|lora_aware|predicted_latency, defaultround_robin)[[routes.targets]]provider(string) — a providernamemodel(string, optional) — upstream model id; defaults to the requested modelweight(u32, default1)
[routes.params](table, optional) — admin default inference params injected into the request body (e.g.temperature,max_tokens,stop). Provider-agnostic: keys are whatever the upstream accepts. An unset param passes through untouched.[routes.param_policy]— whether callers may override theparamsdefaultsmode(allow|deny, defaultallow) — baseline override policyallow(string[], default[]) — params callers may override whenmode = "deny"deny(string[], default[]) — params callers may not override whenmode = "allow"- when an override is denied and the caller sends the param anyway, the admin default silently wins
[[routes.variants]](optional) — weighted variants for A/B, canary, and key-split traffic. When present, the route ignores the top-leveltargetspool: a request samples one variant by weight (the primary) and, on failure, falls over to the remaining variants in declared order. Within a variant the route’sstrategypicks which target leads; the remaining targets follow in declared order as the deterministic fallback tail.name(string) — variant identifier, attributed in request logs (thevariantcolumn)weight(u32, default1) — relative traffic share for the primary draw[[routes.variants.targets]]— same shape as[[routes.targets]][routes.variants.params](table, optional) — variant-scoped param defaults, layered over[routes.params](the variant wins) under the route’sparam_policy
[routes.advanced.guardrails](optional) — per-route selection layered over the global[guardrails]rule set. Rules are named, so adding one globally still reaches every route that has not opted out of it by name.disable(string[], default[]) — rules that do not apply on this routeenable(string[], default[]) — rules that apply on this route; wins overdisableon a conflict- a name matching no configured rule fails validation rather than being ignored — a typo in
disablewould otherwise read as “this rule is off here” while the rule kept running
[[virtual_keys]]
key(string) — the bearer token clients presentname(string, optional)models(string[], default[]) — allow-list; empty = all
[adaptive_routing]
Deployment-wide policy for routes using the adaptive strategy. See load balancing.
enabled(bool, defaultfalse) — kill switch; while off, everyadaptiveroute serves thepipelinestacklatency_weight(f32, default1.0),cost_weight(f32, default0.5),load_weight(f32, default0.25) — blend weights; negatives are clamped to0, and all-zero disables the blendexploration_ratio(f32, default0.05) — share of picks made at random to keep latency samples fresh; clamped to[0, 0.5]min_samples(u32, default50) — requests a route must serve before the blend engages
[logging]
clickhouse_url(string, optional)
[health]
enabled(bool, defaultfalse) — master switch for active upstream probinginterval_secs(u64, default10) — seconds between probe sweepstimeout_secs(u64, default2) — per-probe timeoutpath(string, default/) — probe path; the default resolves to each provider kind’s free liveness endpoint (normally/v1/models, or the provider-native Azure, Bedrock, or Vertex model-list endpoint)probe_concurrency(usize, default2) — max probes in flight at once during a sweep, so probing never stampedes upstreamsconsecutive_failure_threshold(u32, default3) — consecutive probe failures before a provider is marked unhealthyrecovery_success_threshold(u32, default2) — consecutive successes before an unhealthy provider recoversstatus_page_interval_secs(u64, default60) — seconds between provider status-page polls; only providers with astatus_page_urlare polled, and the poller runs even whenenabled = false- probes are jittered across the first quarter of the interval (per-provider stable offset), and a
429on the probe itself pauses that provider’s probing with exponential backoff (1, 2, 4, 8 sweeps) without marking it unhealthy
[realtime]
Guardrails for persistent /v1/realtime WebSocket sessions. All limits are per gateway process; set a value to 0 to disable that limit.
max_connections(u64, default1000) — concurrent sessions admitted by this gateway instancemax_session_secs(u64, default3600) — hard session-duration limitidle_timeout_secs(u64, default300) — closes a session when neither side sends a frame
[egress]
Where the gateway may send upstream traffic. A provider’s api_base (and any egress_proxy) is operator-supplied, so without a destination policy a crafted or compromised provider row turns the gateway into an SSRF primitive pointed at whatever sits inside its network position — cloud instance metadata being the classic target.
block_link_local(bool, defaulttrue) — deny169.254.0.0/16andfe80::/10. This is the cloud instance-metadata range; leave it on unless you have a concrete reasonblock_loopback(bool, defaultfalse) — deny127.0.0.0/8and::1. Off by default because sidecar and single-host deployments legitimately serve models on localhostblock_private(bool, defaultfalse) — deny10.0.0.0/8,172.16.0.0/12,192.168.0.0/16andfc00::/7. Off by default because on-prem clusters are the common caseallow_hosts(string[], default[]) — hosts exempt from every check above, matched verbatim against the URL host (an IP literal or a hostname). The escape hatch for a deployment that must reach one otherwise-denied address
Enforcement happens twice. IP literals are classified during config validation, so a bad api_base is rejected at startup or when the control plane writes it. Hostnames are classified at connect time, against the address DNS actually returned — validation never resolves DNS itself (that would make config validation depend on a live resolver and break air-gapped deployments), and a connect-time check is what makes the policy DNS-rebinding-safe. A denied address fails the request; it is not silently retried elsewhere.
[egress]
block_link_local = true
block_private = true
allow_hosts = ["vllm.internal.example.com"] # the one private upstream we mean to reach
[guardrails]
Built-in, zero-dependency regex guardrails and PII redaction, evaluated inside the gateway with no external service and no network hop. Disabled by default; a disabled or empty block adds no hot-path cost. Complements — never replaces — the custom guardrail webhook (ROL-257) and external PII engines (ROL-258).
enabled(bool, defaultfalse) — master switchmax_scan_bytes(usize, default262144) — cap on total request text scanned per request; oversized content is forwarded unscanned so work stays boundedstreaming_post_call(string, defaultreject) — what a streamed request does on a route that haspost_callrules; see Output masking and streaming
Each [[guardrails.rules]] entry:
name(string, required) — stable, unique; surfaced in metrics, never carries match textbuiltin(string) — one ofemail,phone,api_token,payment_card; orpattern(string) for a custom regex. Set exactly one.stage(string, defaultpre_call) —pre_callscans request content before proxying;post_callmasks the response body before it reaches the clientaction(string, defaultannotate) —annotate(count only, forward unchanged),block(reject with an OpenAI-compatibleguardrail_blockederror), orredact(replace each match withreplacement)replacement(string) — redaction token; defaults to the built-in entity token (e.g.[REDACTED:EMAIL]) or[REDACTED]include_system(bool, defaultfalse) — also scan operator-authoredsystem/developermessages; excluded by default
default_onwas removed. It documented a per-request client opt-in that was never implemented, so it never selected anything: every configured rule applied regardless of its value. Existing configs that still set it keep loading — the key is ignored — and behaviour is unchanged. To turn a rule off somewhere, name it in that route’s[routes.advanced.guardrails]disablelist (see[[routes]]).
Patterns use the linear-time (RE2-style) regex engine with no catastrophic backtracking, and are compiled under a bounded program size during config validation — an invalid or unbounded pattern fails at startup/snapshot validation, never on the request path. The request path never logs raw matched values; metrics expose rolter_guardrail_blocks_total and rolter_guardrail_redactions_total only.
Scanned surfaces: OpenAI /v1/chat/completions and /v1/responses (messages + input), /v1/completions (prompt), and Anthropic /v1/messages (system + messages). String, string-array, and typed text parts are all covered.
[guardrails]
enabled = true
[[guardrails.rules]]
name = "email"
builtin = "email"
action = "redact"
[[guardrails.rules]]
name = "card"
builtin = "payment_card"
action = "block"
[[guardrails.rules]]
name = "leaked-key"
builtin = "api_token"
stage = "post_call"
action = "redact"
Output masking and streaming
A post_call rule runs on the response body after the upstream replies and before the client sees it. redact rewrites the matched text in place; block withholds the completion and returns a 403 with code guardrail_blocked and the rule name — nothing was malformed and no upstream failed, so it is neither a 400 nor a 5xx, and it stays out of the range clients retry on.
Masked surfaces: choices[].message.content and choices[].text (OpenAI chat and legacy completions), the top-level content parts array (Anthropic /v1/messages), and output[].content[].text plus output_text (/v1/responses).
Tool-call arguments are scanned too. An address the model passes to a send_email tool has left the completion just as surely as one it printed. Arguments that arrive as a JSON document inside a JSON string (choices[].message.tool_calls[].function.arguments, the legacy function_call.arguments, and /v1/responses output[].arguments) are parsed, masked and re-encoded, so a replacement token containing a quote or a backslash cannot turn valid arguments into something the client fails to parse; non-string values are untouched. Anthropic tool_use parts are masked through their input object. Arguments that are not valid JSON are masked as plain text.
Output masking requires the whole completion. A match can straddle any number of token boundaries, so a rule redacting a@b.com cannot act on a frame holding only a@b, and buffering the full stream would remove the only property streaming has. streaming_post_call therefore decides what a streamed request does on a route with post_call rules:
reject(default) — refuse with a400carrying codeguardrail_streaming_unsupported, counted inrolter_guardrail_stream_rejections_total. The default fails closed: a masking rule that silently stops applying because the client passed"stream": trueis the failure mode worth ruling out.passthrough— serve the stream with output rules not applied.pre_callrules still run on the request.
Non-streamed responses are buffered by the gateway when (and only when) a post_call rule applies to the route, so a route without them keeps its existing forwarding behaviour. Cached responses are stored as the upstream returned them and masked on every delivery, not once at store time — so a rule added after an entry was cached still applies to it, and an entry shared by two routes is masked per the route serving it. Output metrics: rolter_guardrail_output_redactions_total and rolter_guardrail_output_blocks_total.
[guardrail_webhook]
A vendor-neutral hook to a self-hosted semantic guardrail service (e.g. Guardrails AI, LLM Guard). Before proxying, the gateway POSTs a stable JSON envelope to the configured endpoint; the service replies with an allow/block/transform/annotate decision. Disabled by default; complements the built-in regex guardrails.
enabled(bool, defaultfalse) — master switchurl(string) — http(s) endpoint the envelope is POSTed to (required when enabled)stage(string, defaultpre_call) —pre_callinspects the request.post_callis validated but not yet enforced (output/SSE stage deferred).timeout_ms(u64, default2000) — per-call timeoutmax_retries(u32, default0) — extra attempts on a transient failure (connect/timeout/non-2xx)failure_mode(string, defaultfail_open) —fail_openforwards unchanged when the service is unreachable;fail_closedrejects with an OpenAI-compatible errormax_body_bytes(usize, default65536) — cap on the content forwarded; oversized content is sent as a truncated preview withtruncated: trueauth— optional credential resolved from the environment at call time, never inlined:{ bearer = { token_env = "GUARD_TOKEN" } }→Authorization: Bearer <env>{ shared_secret = { secret_env = "GUARD_SECRET" } }→X-Rolter-Guardrail-Secret: <env>
Contract. Request envelope: { direction, stage, model, route, trace_id, tenant: { org, team, project, key }, truncated, content }. Only these fields are sent; prompt content is never logged by the gateway. Response: { "action": "allow" | "block" | "transform" | "annotate", "content"?, "reason"?, "annotations"? }. An unrecognized or malformed decision defaults to allow (transport failures are governed by failure_mode). Metrics: rolter_guardrail_webhook_blocks_total, _transforms_total, _errors_total; the trace id is propagated in the X-Rolter-Trace-Id header.
[guardrail_webhook]
enabled = true
url = "https://guard.internal/check"
failure_mode = "fail_closed"
auth = { bearer = { token_env = "GUARD_TOKEN" } }
[prompt_templates]
Centrally-managed, versioned prompt templates and deterministic route decorators (ROL-256). Applications reuse approved system instructions through a named template without being granted arbitrary prompt-authoring privileges. Disabled by default; an empty or disabled block adds no hot-path cost.
enabled(bool, defaultfalse) — master switch
Each [[prompt_templates.templates]] entry is one immutable version:
id(string, required) — stable identifier surfaced in safe metadata, never in content logsversion(u32, required, ≥ 1) — immutable version; the operator lists exactly the versions to activate.(id, version)must be unique.routes(array of string, default all) — public model names this template applies to; empty means every route[[prompt_templates.templates.variables]]— a named variable a decorator may reference as{{ name }}:name(string,[A-Za-z_][A-Za-z0-9_]*)required(bool, defaultfalse) — the caller must supply it; mutually exclusive withdefaultdefault(string) — value used when the caller omits it
[[prompt_templates.templates.decorators]]— a message injected around the caller’s own messages:role(string, defaultsystem) —system,assistant, oruserposition(string, defaultprepend) —prepend(before the caller’s messages) orappend(after), both in declared ordercontent(string) — message text, with optional{{ variable }}placeholders
Variables and escaping. Callers pass values in a rolter_template_vars object on the request body; it is always stripped before forwarding upstream. A caller value overrides the declared default; an unknown variable, a missing required variable, or an oversized value (variable > 4 KiB, rendered message > 16 KiB) is rejected with an invalid_prompt_template error. Substitution is structural: each rendered message is emitted as a JSON string through the serializer, never string-concatenated into raw JSON, so a variable value can never break out of its string or inject additional messages. Every {{ placeholder }} is validated at config-load time to reference a declared variable.
Surfaces and ordering. Applied to /v1/chat/completions, /v1/responses, and Anthropic /v1/messages. Prepend decorators wrap before, append after, preserving the caller’s own message order and semantics. For Anthropic, system decorators fold into the top-level system field (joined by blank lines); assistant/user decorators wrap the messages array. Surfaces without a chat message array (e.g. /v1/completions) are not decorated. The gateway applies only the configured immutable version from its reload-free snapshot. Applied template id/version and decoration count are recorded in safe metadata; metrics expose rolter_prompt_template_decorations_total and rolter_prompt_template_rejections_total.
[prompt_templates]
enabled = true
[[prompt_templates.templates]]
id = "support-preamble"
version = 3
routes = ["gpt-4o"]
[[prompt_templates.templates.variables]]
name = "persona"
default = "a helpful support assistant"
[[prompt_templates.templates.decorators]]
role = "system"
position = "prepend"
content = "You are {{persona}}. Follow the company policy and be concise."
A caller then supplies variables per request:
{
"model": "gpt-4o",
"messages": [{ "role": "user", "content": "hi" }],
"rolter_template_vars": { "persona": "a billing specialist" }
}
When the control plane uses PostgreSQL, prompt templates can also be authored
through the org-scoped /api/v1/orgs/{org_id}/prompt-templates endpoints. Draft
versions are immutable once published, publication and rollback update the
reload-free snapshot, and activation can be limited to an organization, project,
route, or virtual key. Seed import creates missing versions idempotently and
refuses to overwrite an existing immutable version with different content.
Organization skills use the adjacent
/api/v1/orgs/{org_id}/skills endpoints. A skill has mutable metadata and access
policy, immutable inline or reference-backed versions, explicit
publish/rollback/retire lifecycle, and a deterministic
/resolve/{slug} endpoint. Reference-backed versions accept https, git+https,
oci, and s3 references without embedded credentials; secret-bearing metadata
keys are rejected.
Environment variables
ROLTER_CONFIG,ROLTER_HOST,ROLTER_PORT— gatewayROLTER_CONTROL_HOST,ROLTER_CONTROL_PORT,ROLTER_UI_DIR— control planeROLTER_KEK— AES-256-GCM KEK for provider-secret encryptionROLTER_PUBLIC_URL— the control plane’s externally reachable base URL (defaulthttp://localhost:4001). The OIDC redirect URI is derived from it, so single sign-on needs it set correctly behind a proxy; see Single sign-onDATABASE_URL,REDIS_URL,CLICKHOUSE_URL— datastoresRUST_LOG— tracing filter (e.g.info,rolter_gateway=debug)- provider key vars referenced by
api_key_env(e.g.OPENAI_API_KEY)
CLI flags override env, which override file values.
Secure configuration (rolter init and rolter check)
rolter has two deployment paths, and they are deliberately not converging.
Local (rolter easy-up) is frictionless and may stay loose on security: no
database, no provider keys, no secrets at all, answering immediately on the
built-in fake-llm model. It prompts for nothing, and rolter check does not
change that.
Production is guided and should be hard to misconfigure. rolter check
validates a production deployment before the process starts, so a
misconfiguration fails loudly instead of starting degraded.
rolter init # generate a production config and its secrets
rolter check # validate the current environment
rolter check --config rolter.toml # also parse and validate the config file
rolter check --strict # treat warnings as failures too
rolter check --connect # also probe that the datastores accept a connection
The two are a pair. check tells an operator what is wrong; init tells them
what to write. A test in the tree renders a fresh production environment and
runs the full check suite over it, so what the generator emits is exactly what
the gate accepts — if they ever drift, an operator would follow the documented
path and still be told their deployment is broken.
It exits non-zero when anything fatal is found, which makes it usable as a container entrypoint step, a Kubernetes init container, or a Helm pre-install hook.
Why this exists
The motivating case is worth stating plainly, because it is the shape of every problem this command is meant to catch.
Provider credentials are encrypted at rest with a key-encryption key read from
ROLTER_KEK. When that variable is missing, the control plane’s
default-provider seed does not fail — it logs a warning and stores the inline
api_key unsealed. Nothing goes red. The deployment serves traffic
normally. A credential the operator believed was encrypted at rest simply is
not, and there is no signal saying so.
Until recently several docs pages — including the production deployment guide
and .env.example — told operators to set ROLTER_MASTER_KEY, a variable
nothing in the codebase has ever read. An operator who followed those
instructions exactly ended up in precisely that degraded state. Those pages are
now corrected, and rolter check reports the mistake by name if the old
variable is still set anywhere.
That is the class of failure this gate targets: not a crash, but a deployment that looks healthy while quietly not doing what it was configured to do.
What it checks
| Check | Severity | Why |
|---|---|---|
ROLTER_KEK present | error | Without it provider credentials are stored unsealed, with only a warning |
ROLTER_KEK length ≥ 16 | error | The KEK is stretched through SHA-256, so a weak secret still yields a valid — and brute-forceable — key |
ROLTER_ADMIN_TOKEN present | error | Otherwise the management API and /internal/snapshot are unauthenticated |
ROLTER_DATABASE_URL present and postgres:// | error | Otherwise the control plane falls back to an in-memory store and loses all config on restart |
| No example values survive | error | The example database credentials and the e2e throwaway KEK are published in the repository |
ROLTER_REDIS_URL present | warning | Without it rate limits and budgets are enforced per replica, not per deployment |
Control plane not bound to 0.0.0.0 | warning | The management API should not be reachable on every interface |
ROLTER_KEY_PEPPER present | warning | Without it a leaked virtual-key digest is usable as-is against this deployment |
CORS does not allow * | error | The dashboard is served same-origin; a wildcard lets any page a logged-in operator visits drive the management API as them |
Datastores accept a connection (with --connect) | error / warning | A URL that parses but resolves to nothing fails at first use rather than at rollout |
Config file parses (with --config) | error | A config that fails to load leaves the gateway on whatever it last had |
--connect is opt-in because a check that opens sockets cannot be the default
for a command meant to run offline and without side effects. It is a TCP
connect, not a protocol handshake: it needs no database driver and answers the
question that actually goes wrong during a rollout — the name does not resolve,
or nothing is listening. A datastore that accepts TCP but rejects the
credentials is a different failure, and one the process reports loudly on its
own.
Redis and the bind address are warnings rather than errors because a
single-replica deployment behind an ingress is legitimately fine without either.
Use --strict in an environment where they are not.
Anything resembling credentials in a URL is redacted before printing, since this output routinely lands in CI logs.
Generating the configuration (rolter init)
rolter check failing on a missing ROLTER_KEK and suggesting openssl rand -hex 32 still leaves an operator to invent the rest of the file around it.
rolter init writes it.
rolter init # rolter.toml + .env, production profile
rolter init --profile local # the loose defaults, for development
rolter init --print # stdout only, for piping into a secret manager
rolter init --env-only --database-url ... # just the environment
It generates four distinct secrets — the KEK, the admin token, the internal token and the virtual-key pepper — each 256 bits from the system CSPRNG. They are distinct by construction and a test asserts it: reusing one value in two roles would mean a leaked admin token also decrypts every stored credential.
Nothing here is interactive. A generator that prompts cannot run in the
Dockerfile, the Helm hook or the CI job where a production deployment is
actually assembled, and an operator who has to answer six questions will
hand-write a .env instead. Flags carry the choices and the defaults are the
secure ones.
It will not overwrite without --force, and the refusal says why:
regenerating ROLTER_KEK strands every credential encrypted under the old one.
That failure is total, delayed, and looks like data corruption.
On unix the generated files are created 0600 at creation time rather than
chmod’ed afterwards — a .env holding the KEK is a credential, and fixing the
mode after the fact leaves a window.
What the profiles differ on
--profile production (default) | --profile local | |
|---|---|---|
ROLTER_CONTROL_HOST | 127.0.0.1 — the management plane is an admin surface | 0.0.0.0 |
[server] require_auth | true — revoking the last key closes the data plane | unset, defers to the deployment shape |
Passes rolter check | yes, cleanly | no, and deliberately so |
The data plane binds 0.0.0.0 under both: it is the public surface.
In a container
Run it as a pre-start step so the container never reaches a serving state while misconfigured:
CMD ["sh", "-c", "rolter check --strict && rolter control"]
Docker Compose
The bundled docker/docker-compose.yml is the local stack and is
deliberately loose — example postgres credentials, no KEK, management plane wide
open. Gating local bring-up on production rules would break the one path that is
meant to have no friction, so the check lives behind a profile and never runs on
docker compose up:
docker compose -f docker/docker-compose.yml --profile preflight \
run --rm --env-file /path/to/production.env preflight
Kubernetes and Helm
The chart ships the init container already, enabled by default:
preflight:
enabled: true
strict: true # a warning means the deployment works but is not what you meant
connect: false # opt in to also probe the datastores
It renders with the exact env block the workload container gets — both come
from one shared template, because three deployment paths that each re-implement
“is this configured safely” will drift, and the one that drifts is the one
nobody notices until a credential was stored unencrypted. A helm chart CI job
lints the chart and renders every branch of that template.
If you are writing your own manifests, prefer an init container so the failure is visible as a distinct pod status rather than a crash-looping main container:
initContainers:
- name: preflight
image: ghcr.io/rolter-ai/rolter:latest
command: ["/usr/local/bin/rolter", "check", "--strict"]
envFrom:
- secretRef:
name: rolter-secrets
Custom CA bundles
Rolter can add private CA certificates to the normal public-root trust store for outbound HTTPS connections to upstream providers. Certificate-chain and hostname verification remain enabled; this feature does not affect inbound TLS or configure mTLS client certificates.
Minimal air-gapped configuration
Mount a PEM file containing one or more CA certificates, then use either the environment variable:
ROLTER_CA_BUNDLE=/etc/rolter/ca/private-root.pem rolter-gateway --config /app/rolter.toml
or the matching global TOML field:
[tls]
ca_bundles = ["/etc/rolter/ca/root.pem", "/etc/rolter/ca/intermediate.pem"]
[[providers]]
name = "private-vllm"
kind = "openai_compatible"
api_base = "https://llm.internal.example"
ROLTER_CA_BUNDLE replaces the global TOML list. A provider can replace the global selection independently:
[[providers]]
name = "isolated-cluster"
kind = "openai_compatible"
api_base = "https://llm.cluster.internal"
ca_bundles = ["/etc/rolter/ca/cluster-root.pem"]
Other providers keep using the global private roots plus the built-in public roots. Set a provider’s ca_bundles = [] to use public roots only.
Docker Compose
Mount the bundle read-only and pass its in-container path:
services:
gateway:
environment:
ROLTER_CA_BUNDLE: /etc/rolter/ca/private-root.pem
volumes:
- ./pki/private-root.pem:/etc/rolter/ca/private-root.pem:ro
Kubernetes
Store the public CA certificate in a ConfigMap or Secret and mount it read-only:
apiVersion: v1
kind: ConfigMap
metadata:
name: rolter-upstream-ca
data:
private-root.pem: |
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rolter-gateway
spec:
template:
spec:
containers:
- name: gateway
image: rolter:latest
env:
- name: ROLTER_CA_BUNDLE
value: /etc/rolter/ca/private-root.pem
volumeMounts:
- name: upstream-ca
mountPath: /etc/rolter/ca
readOnly: true
volumes:
- name: upstream-ca
configMap:
name: rolter-upstream-ca
Validation and rotation
Startup fails with the bundle path and an actionable error when a configured file is missing, unreadable, contains no certificates, or has malformed PEM. Snapshot updates are rejected under the same conditions.
HTTP clients capture trust roots when their connection pool is created. After replacing a mounted certificate, publish or fetch a new configuration snapshot—even if the path is unchanged—to clear configured pools and rebuild them from the new bundle. With static bootstrap configuration, restart the gateway. Existing in-flight connections finish with their original trust configuration; subsequent connections use the rotated bundle.
Self-hosted Ollama
rolter supports a local or privately hosted Ollama daemon through Ollama’s OpenAI-compatible API. This provider does not require an API key.
Native setup
Install Ollama, start the daemon, and pull a small smoke-test model:
ollama serve
ollama pull qwen2.5:0.5b
Configure the daemon origin, without /v1 (rolter appends endpoint paths):
[[providers]]
name = "ollama-local"
kind = "ollama"
api_base = "http://localhost:11434"
[[routes]]
model = "local-qwen"
strategy = "round_robin"
[[routes.targets]]
provider = "ollama-local"
model = "qwen2.5:0.5b"
Start rolter and exercise model discovery, chat, legacy completions, embeddings, and streaming:
curl http://localhost:4000/v1/models
curl http://localhost:4000/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"local-qwen","messages":[{"role":"user","content":"hello"}]}'
curl http://localhost:4000/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"local-qwen","stream":true,"messages":[{"role":"user","content":"hello"}]}'
curl http://localhost:4000/v1/completions \
-H 'content-type: application/json' \
-d '{"model":"local-qwen","prompt":"hello"}'
curl http://localhost:4000/v1/embeddings \
-H 'content-type: application/json' \
-d '{"model":"local-qwen","input":"hello"}'
/v1/models lists rolter’s configured public route names, so the example
returns local-qwen; it does not expose unrelated models installed in Ollama.
Docker setup
Containers must address Ollama by its Compose service name:
services:
ollama:
image: ollama/ollama:0.9.6
volumes:
- ollama-data:/root/.ollama
Use api_base = "http://ollama:11434" in the gateway container’s config. The
opt-in smoke suite under integration/ollama/ provides a complete reproducible
Compose setup and pulls qwen2.5:0.5b automatically.
Compatibility and known gaps
rolter passes OpenAI request JSON and response bodies through unchanged (apart
from the configured model-name rewrite), preserving retry, cooldown, health,
logging, error mapping, routing, and SSE semantics. Ollama currently documents
chat and legacy completions, streaming, JSON mode (response_format), tools,
vision message content, seed, and usage fields. The gateway also passes
stream_options through, though Ollama may ignore unsupported options.
Support depends on the installed Ollama release and model: tool calling and
vision require capable models, JSON schemas are not guaranteed to be obeyed by
every model, and some OpenAI fields are accepted but ignored. Ollama’s
OpenAI-compatible embeddings endpoint accepts models with embedding support;
for production, route it to a dedicated embedding model. Ollama’s native
/api/* endpoints and Ollama Cloud authentication are outside this provider’s
scope.
OpenRouter
rolter’s openrouter provider targets OpenRouter’s OpenAI-compatible API while
keeping OpenRouter model identifiers and routing controls intact.
Configuration
Create an API key in OpenRouter and expose it only through the environment:
export OPENROUTER_API_KEY='...'
[[providers]]
name = "openrouter"
kind = "openrouter"
api_base = "https://openrouter.ai/api/v1"
api_key_env = "OPENROUTER_API_KEY"
[[routes]]
model = "router-chat"
strategy = "round_robin"
[[routes.targets]]
provider = "openrouter"
model = "anthropic/claude-sonnet-4"
The public rolter model (router-chat) is rewritten only to the target model
override. OpenRouter identifiers such as anthropic/claude-sonnet-4, including
their provider prefix and optional variants, are otherwise forwarded verbatim.
rolter fallback chooses another configured target after a retryable failure;
OpenRouter’s own provider request object then controls routing among upstreams
inside the selected OpenRouter target.
For example, this body preserves OpenRouter’s provider ordering and fallback policy:
{
"model": "router-chat",
"messages": [{"role": "user", "content": "hello"}],
"provider": {
"order": ["Anthropic", "Google"],
"allow_fallbacks": true,
"data_collection": "deny"
}
}
Chat completions, SSE chunks, usage/cost fields, response metadata, and
OpenRouter error JSON pass through without normalization. rolter still applies
its normal authentication, route policy, retries, cooldowns, health checks,
request logging, and routing headers. /v1/models lists configured rolter route
aliases rather than exposing every model in OpenRouter’s catalog.
Attribution headers
OpenRouter recommends HTTP-Referer and X-Title for application attribution;
they are not required for authentication. rolter omits them by default so it
does not disclose deployment identity. Set either explicitly when desired:
export OPENROUTER_HTTP_REFERER='https://example.com'
export OPENROUTER_X_TITLE='Example gateway'
These values are forwarded only by openrouter providers. Never put API keys,
user identifiers, or private internal hostnames in attribution headers.
Live smoke
The ignored live test makes a billable request and therefore requires both a credential and an explicitly selected model:
OPENROUTER_API_KEY=... ROLTER_OPENROUTER_LIVE_MODEL=openai/gpt-4.1-mini \
cargo test -p rolter-gateway --test openrouter live_openrouter_smoke -- --ignored
Docker deployment
Compose (full stack)
docker/docker-compose.yml brings up Postgres, Redis, ClickHouse, and the rolter gateway + control services.
cp .env.example .env # set OPENAI_API_KEY etc.
docker compose -f docker/docker-compose.yml up -d
docker compose -f docker/docker-compose.yml logs -f gateway
- Gateway: http://localhost:4000
- Control + UI: http://localhost:4001
- Postgres
5432, Redis6379, ClickHouse8123/9000
DB schemas auto-apply on first start, by two different routes. The Postgres
schema is owned by sqlx::migrate!, which rolter-control and rolter-seed
both run on startup; Compose deliberately does not mount migrations/ into
the container’s docker-entrypoint-initdb.d, because letting Postgres apply
those files itself would bypass sqlx’s _sqlx_migrations bookkeeping and make
the next startup replay every migration against a populated database (#499).
ClickHouse has no such runner, so clickhouse/ is mounted into its initdb
directory and is that schema’s only provisioning path.
Image
The multi-stage docker/Dockerfile produces a slim Debian runtime with both
binaries and the built UI at /app/ui/dist. Its default command is rolter easy-up, so one image serves the gateway and dashboard with the built-in
fake-llm model — no compose file, provider key, or config mount required.
docker build -f docker/Dockerfile -t rolter:dev .
docker run --rm -p 4000:4000 -p 4001:4001 rolter:dev
Then open http://localhost:4001 and verify the data plane with:
curl -s http://localhost:4000/v1/chat/completions \
-H 'Authorization: Bearer sk-rolter-dev' \
-H 'Content-Type: application/json' \
-d '{"model":"fake-llm","messages":[{"role":"user","content":"hello"}]}'
Override the command to run just the gateway or control plane:
docker run --rm -p 4000:4000 rolter:dev rolter-gateway --config /app/rolter.toml
docker run --rm -p 4001:4001 rolter:dev rolter-control
Published images
Release tags publish an image to GHCR (and, when configured, Docker Hub) under the same repo name and tags. Each release is tagged with its version and latest:
docker pull ghcr.io/<owner>/rolter:latest
docker pull ghcr.io/<owner>/rolter:0.0.4
Publishing is fail-closed and opt-in, mirroring the PyPI flow. The publish-docker job in .github/workflows/release.yml runs only when:
- repo variable
DOCKER_PUBLISH_ENABLED=true, and - the verify + external-check gates pass for the tagged commit.
GHCR always publishes via the built-in GITHUB_TOKEN. To also push to Docker Hub, set repo variable DOCKERHUB_IMAGE (e.g. docker.io/acme/rolter) and secrets DOCKERHUB_USERNAME / DOCKERHUB_TOKEN; the same tag set is applied to both registries. (Multi-arch images are a separate roadmap item — releases currently ship linux/amd64.)
Production notes
- Put the gateway behind TLS (ingress/load balancer); keep the control plane private.
- Set a strong
ROLTER_KEK; provide DB/Redis/ClickHouse URLs via env or a secrets manager. - Scale
gatewayhorizontally; all replicas hot-reload config from Redis. ClickHouse and Postgres are shared. - Kubernetes deployments are supported through the rolter Helm chart.
Air-gapped
Running fully offline behind an internal mirror (Nexus/Artifactory/Harbor)? See Air-gapped install & operation.
Kubernetes and Helm
The supported chart is in charts/rolter. It deploys separate gateway and control-plane workloads and services, with health probes, hardened pod defaults, resource requests, an optional HPA, disruption budget, and ingress.
helm upgrade --install rolter ./charts/rolter --namespace rolter --create-namespace \
--set env.databaseUrl='postgres://rolter:secret@postgres.example/rolter' \
--set env.redisUrl='redis://redis.example:6379'
PostgreSQL, Redis, and ClickHouse are external by design. Supply provider credentials through Kubernetes Secrets using secretEnv; do not place credentials in config.file or Helm values committed to source control. For GitOps-managed configuration, create a ConfigMap containing rolter.toml and set config.existingConfigMap.
The gateway defaults to two replicas. The control plane defaults to one replica; scale it only after validating the database migration and UI-hosting behavior for your deployment. Configure TLS at the ingress or service-mesh boundary.
llama.cpp (llama-server)
Rolter’s llama_cpp provider preset targets the OpenAI-compatible API exposed
by llama-server. It needs no API key by default and works with local CPU or
GPU GGUF deployments.
Start llama-server
Choose a GGUF whose license permits your intended use and whose quantization
fits available RAM/VRAM. Q4_K_M is a practical starting point for local use;
smaller quantizations use less memory at the cost of quality.
With a native llama.cpp build:
llama-server -m /models/model.gguf --host 0.0.0.0 --port 8080
Or with the upstream Docker image:
docker run --rm -p 8080:8080 -v "$PWD/models:/models" \
ghcr.io/ggml-org/llama.cpp:server \
-m /models/model.gguf --host 0.0.0.0 --port 8080
Configure Rolter
api_base is the server origin, without /v1. model on the target is the
model identifier reported by llama-server; the public route can be a stable
alias.
[[providers]]
name = "local-llama"
kind = "llama_cpp"
api_base = "http://127.0.0.1:8080"
[[routes]]
model = "local-chat"
strategy = "round_robin"
[[routes.targets]]
provider = "local-llama"
model = "model.gguf"
Rolter forwards /v1/chat/completions and /v1/completions, including SSE,
sampling fields, grammar, and OpenAI response_format. /v1/models lists
Rolter’s public route aliases. Routing headers, retries, cooldowns, and active
health checks behave like other providers; the default health probe calls the
upstream /v1/models endpoint.
llama.cpp-native routes such as /completion, /tokenize, /detokenize, and
slot/metrics administration are intentionally not exposed by Rolter’s
generic OpenAI API. Call llama-server directly for those endpoints.
Smoke test
With llama-server running and the model id from its /v1/models response:
integration/llama-cpp-smoke.sh http://127.0.0.1:8080 model.gguf
The script starts a temporary Rolter gateway, verifies model listing, non-streaming completion, SSE, and routing headers, then cleans up.
Hugging Face Text Embeddings Inference (TEI)
The tei provider targets TEI’s OpenAI-compatible POST /v1/embeddings
endpoint. Self-hosted TEI is keyless by default; api_key or api_key_env can
add bearer authentication when TEI sits behind an authenticated proxy.
Run TEI
For a reproducible CPU deployment with a small embedding model:
docker run --rm -p 8080:80 -v "$PWD/data:/data" \
ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 \
--model-id sentence-transformers/all-MiniLM-L6-v2
On Apple Silicon, install and run the native server:
brew install text-embeddings-inference
text-embeddings-router \
--model-id sentence-transformers/all-MiniLM-L6-v2 --port 8080
Configure Rolter
Use the server origin as api_base, without /v1:
[[providers]]
name = "tei-local"
kind = "tei"
api_base = "http://127.0.0.1:8080"
[[routes]]
model = "embed-local"
strategy = "round_robin"
[[routes.targets]]
provider = "tei-local"
model = "sentence-transformers/all-MiniLM-L6-v2"
Rolter preserves OpenAI string, string-array, token-array, and token-array-batch
inputs, plus encoding_format, dimensions, user, embedding vectors, usage,
and upstream error JSON. Normal routing headers, retries, cooldowns, logging,
and health behavior apply. The default active probe uses TEI’s /health route.
Only /v1/embeddings is part of this adapter. TEI-native /embed, /rerank,
/embed_sparse, /predict, /tokenize, /health, and /metrics are not
exposed through Rolter’s generic OpenAI surface. Call TEI directly for them.
Smoke test
The opt-in Compose test starts TEI, downloads the small model, starts Rolter, and verifies batch embeddings, optional fields, usage, and routing headers:
integration/tei/run.sh
Air-gapped installation & operation
rolter is designed to run in fully air-gapped environments — no public internet at build or run time. The runtime is egress-free by default: it talks only to the backends you configure (upstream providers, Postgres, Redis, ClickHouse) and to nothing else. This page covers how to install rolter behind an internal mirror, what the runtime does and does not reach, and how to verify the deployment offline.
Runtime egress guarantees
rolter makes outbound network calls only to endpoints you configure:
- Upstream providers — the
base_urlof each configured provider. - Postgres / Redis / ClickHouse — only when their URLs are set
(
DATABASE_URL/ROLTER_DATABASE_URL,REDIS_URL,clickhouse_url). - Control-plane snapshot — only when the gateway is started with
--snapshot-url(and Redis pub/sub only with--redis-url). - OTLP traces — only when an
OTEL_EXPORTER_OTLP_ENDPOINT/OTEL_EXPORTER_OTLP_TRACES_ENDPOINTis set. With noOTEL_*env, no exporter is built and there is zero tracing egress.
Everything else is self-contained:
- The interactive API reference at
/docsembeds the Scalar JS bundle in the binary and setswithDefaultFonts: false, so it never reaches a CDN orfonts.scalar.com. This is asserted by thedocs_page_is_self_containedandscalar_bundle_is_embeddedtests incrates/rolter-gateway/src/openapi.rs. - The dashboard SPA is served as static assets by the control plane; it loads no third-party scripts, fonts, or styles.
Caveat — status-page pollers. If a provider sets
status_page_url, the gateway periodically fetches that URL as a secondary health signal. Leavestatus_page_urlunset in air-gapped configs (or point it at an internal mirror) so the poller stays inside the enclave.
Install paths through a mirroring proxy
Air-gapped sites usually proxy public registries through an internal mirror (Sonatype Nexus, JFrog Artifactory, Harbor, …). Pick the path that matches how you ship rolter.
Docker image (recommended)
Pull through a registry that proxies GHCR/Docker Hub:
docker pull registry.internal.example/rolter/rolter:latest
Or transfer a fully offline image with docker save / docker load:
# on a connected host
docker pull ghcr.io/rolter-ai/rolter:latest
docker save ghcr.io/rolter-ai/rolter:latest -o rolter.tar
# copy rolter.tar into the enclave, then
docker load -i rolter.tar
PyPI wheel (uv tool install / pip)
Install through a Nexus/Artifactory PyPI proxy:
uv tool install rolter --index-url https://nexus.internal.example/repository/pypi/simple
# or
pip install rolter --index-url https://nexus.internal.example/repository/pypi/simple
Or install a downloaded wheel with no index at all:
uv tool install ./rolter-<version>-py3-none-any.whl
# or
pip install --no-index ./rolter-<version>-py3-none-any.whl
crates.io (cargo install)
Point Cargo at a registry mirror or vendored sources via .cargo/config.toml:
# .cargo/config.toml
[source.crates-io]
replace-with = "internal"
[source.internal]
registry = "sparse+https://nexus.internal.example/repository/cargo/"
For a fully offline build, vendor the dependency sources on a connected host and copy them in:
cargo vendor vendor/ # connected host, writes a [source] snippet
# copy vendor/ into the enclave, add the printed snippet to .cargo/config.toml
cargo build --workspace --offline
Building from source (cargo + bun)
The Rust build follows the crates.io section above. The UI needs an internal npm
mirror for bun install:
# ui/bunfig.toml
[install]
registry = "https://nexus.internal.example/repository/npm/"
# alternatively ui/.npmrc
registry=https://nexus.internal.example/repository/npm/
cd ui && bun install && bun run build
cargo build --workspace --release --offline
Operator checklist
Must be reachable inside the enclave:
- Every configured provider
base_url(your internal model servers or a proxied provider endpoint). - Postgres, Redis, and ClickHouse hosts — only for the features you enable.
- The control plane, if the gateway runs with
--snapshot-url.
Must NOT be required:
- Public package registries at run time (only at install/build time, through the mirror).
- CDNs (
cdn.jsdelivr.net,fonts.scalar.com, npm/unpkg) — rolter references none. - Telemetry endpoints — unless you deliberately set
OTEL_*to an internal collector. - Provider status pages — leave
status_page_urlunset.
Offline smoke test
Verify a running gateway with zero external providers using the built-in
fake-llm model (deterministic, no upstream or secrets needed):
rolter gateway --port 4000 &
curl -s http://localhost:4000/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"fake-llm","messages":[{"role":"user","content":"hello"}]}'
A 200 with a lorem-ipsum completion confirms the gateway serves traffic with no
outbound calls. Open http://localhost:4000/docs and confirm the API reference
renders with no network requests leaving the host.