Dynamic Routes
Define named, versioned routing flows with conditions, percentage splits, and model targets, and invoke them via dynamic/<name> in the model field.
Dynamic Routes
Dynamic routes let you move routing logic out of your application code and into the gateway. Instead of hardcoding a model, you define a named decision graph — conditions on the request, percentage-based traffic splits, and model targets — and invoke it by putting dynamic/<name> in the model field of any OpenAI-compatible request:
curl https://api.offrail.ai/v1/chat/completions \
-H "Authorization: Bearer $OFFRAIL_API_KEY" \
-H "x-user-tier: paid" \
-d '{
"model": "dynamic/support",
"messages": [{"role": "user", "content": "Hello!"}]
}'The graph is evaluated on every request. Once it resolves to a model, the request flows through the same smart routing as any other request: weighted provider scoring, sticky sessions, and automatic cross-provider fallback all apply to the resolved target.
Dynamic routes are available on the Enterprise plan. Manage them under Project Settings → Dynamic Routes in the dashboard, either in a visual drag-and-drop editor or as raw JSON.
The route graph
A route is a JSON document with an entry node id and a list of nodes. Evaluation starts at entry and follows branches until it reaches a model node (route the request) or an end node (reject the request with a 400).
{
"entry": "tier",
"nodes": [
{
"id": "tier",
"type": "conditional",
"conditions": [
{
"field": { "source": "header", "path": "x-user-tier" },
"op": "eq",
"value": "paid",
"next": "premium"
}
],
"else": "split"
},
{ "id": "premium", "type": "model", "model": "claude-sonnet-4-6" },
{
"id": "split",
"type": "percentage",
"splits": [
{ "weight": 80, "next": "stable" },
{ "weight": 20, "next": "experiment" }
]
},
{
"id": "stable",
"type": "model",
"model": "gpt-5-nano",
"providers": ["openai"]
},
{ "id": "experiment", "type": "model", "model": "gemini-2.5-flash" }
]
}Node ids may contain letters, digits, hyphens, and underscores.
Node types
conditional
Evaluates its conditions top to bottom; the first match wins and the request follows that condition's next. When nothing matches, the request follows else.
Each condition reads one field from the request:
field.source | What field.path means |
|---|---|
header | A request header name (case-insensitive), e.g. x-user-tier |
body | A dot-path into the JSON request body, e.g. metadata.segment or max_tokens |
metadata | A gateway-provided request attribute: orgId, projectId, apiKeyId, or plan |
Supported operators:
| Operator | Matches when | Value type |
|---|---|---|
eq | The field equals the value (compared as strings) | string / number / boolean |
neq | The field differs from the value — also matches when the field is missing | string / number / boolean |
in | The field equals any entry in the value array | array of strings |
contains | The field's string form contains the value | string |
gt, lt | The field is numerically greater / less than the value | number |
exists | The field is present (no value) | — |
percentage
Splits traffic across branches by relative weight. The draw is deterministic per session: the split key is the request's session id (x-session-id and the other sticky session routing signals), so a conversation keeps its assignment across requests instead of flip-flopping between experiment arms. Requests without a session id get an independent draw per request.
Weights are relative — { 80, 20 } and { 4, 1 } produce the same split.
model
Terminates evaluation and routes the request to a catalog model (see the models page for available ids). Optional providers restricts routing to those providers and doubles as the ordered fallback preference; when omitted, every provider serving the model is a candidate and weighted smart routing picks the best one.
end
Terminates evaluation and rejects the request with a 400. Useful as an explicit deny branch — for example, refusing traffic that doesn't carry a required header.
Validation
Graphs are validated when you save a draft and again when you publish:
- every referenced node must exist and be reachable from
entry - cycles are rejected — evaluation is deterministic per request, so a revisited node would loop forever
- model ids and provider ids must exist in the catalog, and each listed provider must actually serve the model
- operator/value mismatches (e.g.
gtwith a non-numeric value) are rejected
An invalid graph can never be saved or published, and publishing re-validates against the live model catalog so a stale draft can't resurrect a removed model.
Versions and rollback
Edits always go to the route's draft. Publishing snapshots the draft as an immutable, numbered version and points the route at it; the published version is what serves traffic. Rolling back re-points the route at any previous version instantly — no data migration, no re-deploy.
A route only serves requests when it is enabled and has a published version; otherwise requests using it are rejected with a 404.
Observability
Each request served through a dynamic route records the route name, published version, and the node path the evaluation took in its routing metadata, alongside the usual provider scoring details — visible in the activity log detail view.
Reliability
Published routes are cached in the gateway and carry a stale fallback: when the database is temporarily unreachable, recently used routes keep resolving from cache, the same way API keys and project settings do.
How is this guide?