Skip to main content

Routing overview

Routing is how the Ferro Labs AI Gateway decides which provider answers a request. One strategy governs the whole gateway: it takes each request and returns an ordered list of targets to try. A strategy decides order and nothing else β€” retry, circuit breaking, failure classification and capability checks all live once in the request pipeline, so chat, streaming, embeddings, images, rerank, moderation, transcription and speech route identically: the same config and the same health produce the same candidate order on every surface. Ferro Labs ships eight strategies across two families.

In plain words

You tell the gateway which providers it may use (targets) and one rule for choosing between them (strategy.mode). A request arrives asking for a model; the gateway works out which of your providers can serve it, tries them in the order the rule gives, and returns one answer. What happens when a provider fails depends on why it was chosen: picked as one of several interchangeable options, the request quietly moves on to the next; named on purpose, the request stays inside what was named β€” a rule can name an ordered chain of stand-ins, and never reaches past it. Every answer says which provider, target and model served it, and how many attempts that took.

Which one do I pick?​

You want…ModeLooks like
One provider, nothing cleversingleEverything goes to OpenAI; the gateway adds keys, limits, plugins and logs.
A main provider and a backupfallbackOpenAI first; if it is down or overloaded, Anthropic answers the same request.
Traffic shared between providersloadbalance70 % OpenAI, 30 % Azure OpenAI; weight: 0 drains one before you remove it.
Whoever answers fastest right nowleast-latencyGroq, OpenAI and Anthropic all serve the model; the quickest one keeps getting the traffic.
The cheapest provider per requestcost-optimizedDeepSeek, OpenAI and Anthropic serve the model; the cheapest listed price wins.
Some models pinned to one providerconditionalclaude-* only ever goes to Anthropic, whatever else is configured.
Routing by what the prompt sayscontent-basedCoding questions to a coding model, translations to a cheap one, the rest to a default.
Two options compared on live trafficab-test90 % control, 10 % challenger, every answer labelled for analysis afterwards.

What a strategy is​

A strategy implements a single method, SelectTargets(req), which returns exactly the ordered virtual keys the pipeline may try for a request, most-preferred first β€” a pool mode's whole pool, a rule's chain, single's one target. That ordering is the whole of its job.

This follows Envoy's split: the route table and load balancer say where a request may go, while the retry policy hangs off the route and the circuit breaker off the cluster. In the gateway, the strategy names order; the pipeline (routeTargets) owns retry, the circuit breaker, the concurrency limiter, failure classification and "nothing here can serve this". Stating order once and executing it once is why a config orders its targets the same way whether the request streams or not, and the same way on embeddings or speech as on chat: a target that cannot serve a surface is simply not a candidate there.

  • A nil result means no configured target serves the requested model β€” the caller reports 404 model_not_found.
  • Only cost-optimized (in skip mode) can return an error; every other strategy returns a nil error.

Configure routing​

Routing is two top-level blocks in config.yaml (or JSON): a strategy: block that sets the mode plus that mode's own keys, and a flat targets: list. There is no per-route nesting β€” one strategy governs the entire gateway.

strategy:
mode: fallback # single | fallback | loadbalance | least-latency |
# cost-optimized | conditional | content-based | ab-test

targets:
- virtual_key: openai
retry:
attempts: 3
- virtual_key: anthropic

Mode-specific keys live under strategy: alongside mode: conditions[] (conditional), content_conditions[] (content-based), ab_variants[] (ab-test), unpriced_strategy (cost-optimized) and sticky (loadbalance and ab-test). Each is documented on that strategy's own page. One key applies to every pool mode and rule chain:

KeyTypeDefaultDescription
strategy.failover_on_status_codes[]intβ€”Extra upstream statuses that count as failover-safe, so the walk moves to the next candidate on them. 400, 401, 403, 404 and 422 cannot be listed β€” a bad request, a bad key or a missing model is the request's problem on every target β€” and the request's own cancellation or deadline always stops routing.
targets is an allowlist

Setting a provider's credentials registers it, but a provider only routes if it appears in targets[]. A request for a model no listed target serves is 404 model_not_found, even when a registered-but-unlisted provider could have served it. To stop routing to a provider, remove its target.

The targets[] entry​

Every entry names one provider registration and optionally attaches per-target resilience. virtual_key is the only required field.

KeyTypeDefaultDescription
virtual_keystringβ€” (required)Names a provider registration, typically the provider id. ferrogw validate rejects a key no built-in provider matches.
weightfloat640Relative share under loadbalance, and the tie-break among equal-cost targets under cost-optimized; ignored by every other mode. 0 means zero traffic β€” the drain lever.
models[]stringβ€”Extra exact model IDs this target serves, added to the routing index and /v1/models. Additive only (never hides what a target already serves), no wildcards (rejected at load).
model_mapmapβ€”Per-target translation of a name clients use into this target's upstream model ID (smart: gpt-4o-mini). The visible name routes to this target and appears in /v1/models; the upstream call and pricing use the mapped ID; the response says the visible name. See One model name, different upstream IDs.
timeoutdurationnoneBound on one attempt against this target, inside request_timeout (which stays authoritative for the whole request). A unary attempt is bounded through its response; a streaming attempt only until the provider answers, since a stream that has begun cannot be replayed elsewhere. An attempt that times out is failover-safe, so a hung primary no longer consumes the whole request budget.
retryobjectsingle attemptPer-target retry policy. Applies under every mode β€” see below.
circuit_breakerobjectnonePer-target breaker. One per virtual_key, shared by all four surfaces.
concurrencyobjectunlimitedIn-flight bound with a queue; overflow fails fast with 429.

retry β€” how many times one target is re-asked (advancing to the next target is the routing mode's job, not retry's):

KeyTypeDefaultDescription
attemptsint1Max attempts against this target. 1 = no retry; the whole retry block is optional.
on_status_codes[]inttransport errors + 408/429/5xxRestrict retryable statuses. Other 4xx are deterministic client errors and are never retried.
initial_backoff_msint100Base for exponential backoff with full jitter: wait is drawn from [0, initial * 2^(attempt-1)). An upstream Retry-After wins; a hint over 30s abandons the target.

circuit_breaker β€” a target whose circuit is open is skipped during selection (see below):

KeyTypeDefaultDescription
failure_thresholdint5Consecutive failures before the circuit opens.
success_thresholdint1Consecutive half-open successes required to close.
max_half_thresholdint1Concurrent probes allowed while half-open.
timeoutduration30sHow long the circuit stays open before going half-open (e.g. "30s").

concurrency β€” bounds simultaneous in-flight requests (a streaming request holds its slot until the stream ends):

KeyTypeDefaultDescription
max_concurrencyintunlimitedMax in-flight requests to this target; capped at 10000.
queue_sizeintdefault queueRequests allowed to wait for a slot; beyond it the request is shed with 429.

One model name, different upstream IDs​

Providers rarely agree on model IDs, so a request for gpt-4o cannot simply fail over to Anthropic. targets[].model_map gives each target its own translation of a name your clients use:

strategy:
mode: fallback

targets:
- virtual_key: openai
model_map:
smart: gpt-4o
- virtual_key: anthropic
model_map:
smart: claude-sonnet-4-6

A client sends "model": "smart". OpenAI is asked for gpt-4o; if that fails in a failover-safe way, Anthropic is asked for claude-sonnet-4-6. Either way the response says "model": "smart" β€” on every streamed chunk too β€” so the client sees one stable name whoever answered. /v1/models lists smart; cost is priced on the mapped ID that actually ran.

model_map is per target. aliases (see Configuration) is a global rename applied before routing. Use aliases to give one real model ID a short name everywhere; use model_map when the same name must become a different ID on each provider.

Two mode families​

The pipeline splits the modes by what their leading candidate means, and that decides whether moving off it is a repair or a betrayal.

FamilyModesOn a target failure
Poolfallback, loadbalance, least-latency, cost-optimized, ab-testAdvances to the next candidate after a failover-safe failure; any other failure is returned.
Namedsingle, conditional, content-basedStays inside what was named: single stops; a rule walks its target_keys chain on the same failover-safe failures and stops at its end.

A pool mode picks its head for a reason that is about the pool rather than the individual target β€” spread the load, take the cheapest, take the fastest, split the traffic β€” from targets the operator declared interchangeable, so carrying a failed request to a sibling is what was asked for. A named mode picks its head because something named that target specifically (single names it; a conditional or content-based rule matched it), so serving from anyone the rule did not name would demote the rule to a suggestion. A rule may name an ordered chain (target_keys); the chain is walked like a pool and is a hard boundary β€” a rule with one target is exact, and that target being down is the corresponding error, not a sibling's answer.

Which failures fail over​

The gateway moves a request to another target only when the provider was the problem β€” never when the request was. Under a pool mode:

The target…For exampleWhat happens
could not be reachedconnection refused, DNS failure, connection resetnext target
did not answer in timeno response headers before the provider transport's timeoutnext target
asked you to back off, or was unavailable408, 429, 502, 503, any 5xxnext target
is already being avoidedcircuit breaker open, parked after a 429, concurrency queue fullnext target
said the prompt is too long for its modelthe OpenAI-compatible context_length_exceeded code, Anthropic's prompt is too long, Gemini's token-count INVALID_ARGUMENTnext target β€” its model may have a larger window
answered a status you listedany code in strategy.failover_on_status_codesnext target
rejected the request itself400, 401, 403, 404, 422that response goes back to the client
β€” the client gave upthe caller cancelled, or its deadline passedrouting stops

Retry (below) re-asks the same target first for a transport failure or a retryable status; a hung attempt, an open circuit and a full queue are never retried and advance at once. Under single the one target's result β€” after its own retry β€” is always the answer; under a rule, the chain is walked on exactly these classes and the last member's result is the answer.

What every mode shares​

Regardless of family:

  • Retry re-asks the same target. targets[].retry is honoured under every mode. It never advances to a different target β€” only pool modes do that, and only after a failover-safe failure.
  • An open circuit is skipped, among the candidates the mode offers. Before committing, a target whose breaker is open is passed over in favour of the next candidate β€” a pool's next sibling, or a rule's next chain member. single and a rule with one target offer no next candidate, so an open circuit there is answered 503.
  • A 429 parks the target. A target that answers 429 is skipped for its Retry-After β€” five seconds when the header is missing or unusable, a minute at most β€” so the next request does not pay another 429 on it. The park filters like an open circuit and never refuses a request outright; the target's circuit breaker is untouched, since a rate limit is not a failure of the target.
  • Every answer is attributed. Every routed surface responds with X-Gateway-Provider, X-Gateway-Target, X-Gateway-Model and X-Gateway-Attempts β€” the canonical provider, the virtual_key as you wrote it, the upstream model after model_map, and the number of routing-layer attempts. A stream carries them before its first chunk. See Endpoints.
  • Health is per process. Circuit state, latency samples and 429 parks are local to one gateway instance; nothing is shared between replicas.
  • All circuits open β†’ 503. When every candidate's circuit is open the request is still attempted, the breaker refuses it, and the caller gets 503 upstream_unavailable. That is deliberately different from 404: "everything that serves this model is down" is not the same answer as "nothing serves this model".
  • No candidate serves the model β†’ 404. When no configured target serves the requested model, nothing is attempted and the caller gets 404 model_not_found.
  • The response names the model the client asked for. model in the response, and in every streamed chunk, is the requested name after alias resolution β€” even when model_map sent a different ID upstream, and whichever target answered.

v1.5.2 behaviour changes​

Five things an operator may notice after upgrading from 1.5.1:

  • A rule that names one target is exact. Under conditional and content-based, an open circuit on the matched target used to borrow a healthy sibling from targets[]; it now answers 503. A rule that wants a stand-in lists one in target_keys. On the non-chat surfaces, where content rules cannot be evaluated, content-based routes to the first target that can serve the request, alone.
  • Cost ranking prices output too. cost-optimized scores input plus output β€” the request's max_tokens / max_completion_tokens, or 256 tokens β€” so a target that is cheap to read and expensive to write no longer wins a request with a large completion budget. Embedding, image and audio models that tied at zero now rank by their real catalog rate, and equal-cost targets draw by weight.
  • Latency samples expire, key by model, and keep exploring. A target nothing has measured in five minutes is profiled again; one request in ten leads with a runner-up; a stream's sample is its time to first chunk rather than its whole drain.
  • One ranker for every surface. Embeddings, images, rerank, moderation, transcription and speech previously ranked through a second implementation that drew load-balance starts from a different random source, kept unseen least-latency targets in declared order, and priced cost candidates differently. The same config now orders the same targets the same way everywhere.
  • Unlabelled A/B variants no longer load. An ab_variants[] entry needs a label; attribution keys on it. A single strategy with more than one target logs a warning naming the unused targets.

v1.5.1 behaviour changes​

Three things an operator may notice after upgrading from 1.5.0 or earlier:

  • Pool modes no longer cover a client error. Before 1.5.1 they advanced after any failure, so a target answering any other 4xx β€” 400, 401, 403, 404, 422 β€” was silently replaced by a sibling β€” a revoked key on the primary was invisible. Those responses now reach the client; only failover-safe failures advance (table above).
  • The response model is the routed name. Responses used to echo whatever identifier the provider reported, such as a dated snapshot like gpt-4o-2024-08-06; they now carry the name the client asked for, after alias resolution, on streamed chunks too.
  • Ambiguous configs are rejected at load. Two targets sharing a virtual_key, an empty virtual_key, two ab_variants naming the same target, or a duplicated key in a JSON config used to load silently, collapsing retry, weight and breaker state for the pair. ferrogw validate and startup now refuse them.

v1.4 breaking changes​

Two routing behaviours changed and may need config edits when upgrading:

  • targets[].retry now applies under every mode. It used to run only under fallback. If a target carried a retry block for use under fallback and you now run another mode, set attempts: 1 to keep the old single-attempt behaviour.
  • targets is an allowlist. A model is routable only through a listed target (plus that target's models, the catalog and live discovery). A registered provider that no target names does not route; an unowned model is 404 model_not_found.

The eight strategies​

StrategyFamilyUse when
SingleNamedYou have one provider and want the gateway as a pure governance and observability layer.
FallbackPoolA primary plus backups, where a failed request should be answered by someone else rather than reported.
Load balancePoolSpreading load across interchangeable providers, weight-shifted migration, or draining one (weight: 0) before removal.
Least-latencyPoolTime to first token is the objective across equivalent models on several providers.
Cost-optimizedPoolCost is the objective and the model catalog carries the prices.
ConditionalNamedDeterministic pinning by model, user, streaming, tool use or a metadata header, with an optional target chain β€” e.g. for compliance or contract reasons.
Content-basedNamedPrompt-aware selection: code to a code model, translation to a cheap one, sensitive content to a specific backend.
A/B testPoolComparing model quality or cost on a controlled live traffic split.