← AI + Backend
LLM & GenAI Systems

LLM Gateway & Multi-Provider Routing

Watch: "LiteLLM Proxy Tutorial: Track Costs, Budgets, and Multi-Provider LLM Usage" — Ready Tensor

An LLM gateway is a service that sits between your applications and one or more model providers (hosted APIs or your own inference clusters) and turns "call a model" into a managed, observable, controllable operation. Interviewers probe it because it is where reliability, cost and security of an AI product actually get decided: providers have outages and rate limits, prices and latencies differ by an order of magnitude between models, and nobody wants every team pasting API keys into its own service. For a Backend or AI engineer the questions are concrete: what belongs in the gateway, how do you route and fail over without making things worse, and how do you avoid the gateway becoming the single point of failure it was built to remove. The end-to-end design version is in Design an LLM API Gateway; this page is the routing and multi-provider mechanics.

What Is an LLM Gateway?

An LLM gateway is a middleman service that every part of a company's software talks to instead of calling an AI provider (like OpenAI or Anthropic) directly. It's the single front desk that checks who's asking, keeps track of spending, and forwards the actual request onward, so individual apps and teams don't each need to handle logins, billing, and outages on their own.

What Is Multi-Provider Routing?

Multi-provider routing is the logic that decides, for each request, which AI company's model should actually answer it & a company might use a cheap, fast model for simple questions and a more expensive, powerful one for hard questions, or switch providers automatically if one is having an outage. It's the same idea as a delivery app that picks whichever driver, restaurant, or route will get your food to you fastest and cheapest at that particular moment.

Where This Is Used

A Day-to-Day Example

Think of a hotel concierge desk. Guests don't call the restaurant, the car service, and the dry cleaner directly & they tell the concierge what they need, and the concierge decides which partner business to call, keeps a running bill for the guest's room, and if their usual dry cleaner is closed, quietly sends the order to a backup one instead. The guest never notices the switch. An LLM gateway plays that exact concierge role between a company's software and the various AI companies it works with.

Deep Dive: What the Gateway Owns

Inside an LLM gatewayLLM GATEWAYapps / agents / batch jobs1 authn/authzwhich models?2 quotas + limitsRPM/TPM, 429 early3 cache lookupexact / semantic4 routerprovider + model5 adapterschema, tokenizer6 resilienceretry, fallback7 stream relayno buffering8 accountingtokens, costprovider Aprovider Bself-hosted (vLLM)one API, virtual key per team
Every model call from every internal app goes through one gateway: it authenticates the team's virtual key, enforces quotas, checks the cache, routes to a provider, translates the request, handles failures (timeouts, retries, fallback, circuit breakers), relays the stream without buffering and records tokens and cost.
routing decisionupstream providers
Show text version
apps / agents / batch jobs
        |   one internal API (one schema, virtual API key per team)
        v
+---------------------------------------------------------------+
| LLM GATEWAY                                                   |
|  1 authn/authz        who is this? which models may they use? |
|  2 quotas + limits    per-tenant RPM/TPM/budget  (429 early)  |
|  3 cache lookup       exact / semantic, tenant-scoped         |
|  4 router             pick provider + model (policy)          |
|  5 adapter            translate schema, tokenizer, params     |
|  6 resilience         timeout, retry, fallback, breaker       |
|  7 stream relay       forward events without buffering        |
|  8 accounting         tokens in/out, cost, latency, provider  |
+---------------------------------------------------------------+
        |               |                 |
     provider A      provider B      self-hosted (vLLM etc.)

The gateway earns its place by centralising concerns that are expensive to get right ten times. Authentication maps a caller to a tenant and to allowed models; quotas and budgets stop one runaway agent loop from consuming the whole provider limit (the mechanics are in Rate Limiting and Throttling); the adapter hides provider differences; resilience logic lives once instead of in every client; and every call emits the same log and metric shape, which is what makes cost attribution ("which team, which feature, which prompt version spent this") possible (see Observability for AI Systems and Cost Optimization for AI Systems). Its cost is a new tier on the critical path of every AI feature: an extra network hop, an extra deploy target, and a new outage domain, so it has to be stateless where possible, horizontally scaled, and boringly reliable.

Deep Dive: Routing Policies

Routing as a funnelall models1. tenant may use + supports tools/JSON/context2. circuit closed + quota headroom3. policy picks: cost / latency / quality / weights4. rest = fallback chain
Order matters: filter out models the tenant can't use or that lack a needed feature, then providers that are down or out of quota, then let the policy choose. Whatever is left becomes the fallback chain. The policies are compared below.
filterschoicefallbacks
Policy            Rule                                   Watch out for
----------------  -------------------------------------  ------------------------------------
static            route "summarize" to model X           no adaptation to outages
cost-first        cheapest model that meets the tier     cheap model quality regression
latency-first     lowest recent p95 / TTFT               herd effect: everyone piles onto
                                                         the "fastest" provider, it slows down
quality-tier      hard prompts -> strong model,          classifier errors; needs evals
                  easy prompts -> small model
weighted / canary send 5% to the new model or provider   need per-variant metrics and a kill switch
fallback chain    try A, on failure/timeout try B, C     latency stacks up; B/C must be
                                                         pre-validated for the same task

Typical composition (order matters):
  1. filter   : models the tenant may use, that support the needed features
                (tools, JSON mode, context length for THIS prompt)
  2. filter   : providers whose circuit is not open, with quota headroom
  3. choose   : policy (cost / latency / quality / weights) among what is left
  4. chain    : the remaining candidates, in order, become the fallback list

The important idea is that "which model" is really two decisions: a capability decision (can this model do the task and fit the context) and an optimisation decision (among those that can, which is cheapest or fastest right now). Doing them in that order keeps you from failing over to a model that silently cannot handle tool calls or the prompt length. Latency-based routing needs damping (moving averages, minimum traffic to every target) or it oscillates. Quality-based routing is only as good as your offline evaluation, so it pairs with the rollout discipline in A/B Testing and Canary Deployments for Models: send a small weighted share to a candidate, compare quality and error metrics per variant, and keep an instant kill switch. Fallbacks are not free either: a fallback model can answer differently in tone, format and refusals, which downstream parsers may not tolerate, so treat the fallback list as tested configuration, not a guess.

Deep Dive: Outages, Retries, Fallback and Circuit Breakers

Per-provider circuit breakerCLOSEDcalls flowOPENcalls skipped instantlyHALF-OPENallow ONE probeN failurescooldownprobe failsprobe succeedsretry: timeout, reset, 429 (honour Retry-After), 5xx overloadnever: 400/401/403/422, content-filter refusals, after tokens streamed
After N failures in a row the breaker opens and calls to that provider fail over instantly instead of each waiting for a timeout. After a cooldown, one probe decides whether to close it again. What is and isn't safe to retry is below.
healthytrippedtesting
Breaker per provider (per model/region if they fail independently):

   CLOSED --(N consecutive failures)--> OPEN --(cooldown elapsed)--> HALF-OPEN
     ^                                    |                              |
     |                                    +-- calls skipped instantly    +-- allow ONE probe
     +-------- probe succeeds -------------------------------------------+   probe fails -> OPEN

Why not just retry?  During an outage every call to the dead provider hangs until the timeout.
  attempts on dead provider x timeout (say 2 s) x retries  =  seconds of latency added
  to EVERY request, plus retry traffic hitting a provider that is already struggling.

Retry only what is safe and useful:
  retry   : timeout, connection reset, 429 (honour Retry-After), 500/502/503/529-style overload
  do not  : 400/401/403/422 (your bug), content-filter refusals, anything after tokens were streamed
  always  : exponential backoff + jitter, a retry BUDGET, and an overall request deadline

The Try It Yourself simulation compares four configurations over the same virtual traffic in which the cheapest provider is hard-down for a 30-second window. With the cheapest provider only and no retries, about a third of requests fail; retries alone barely help during a hard outage and push p95 up because each retry waits for another timeout; a fallback chain reaches 100% success but every request in the outage window still pays the dead provider's timeout first; adding a circuit breaker removes almost all calls into the outage and brings p95 back close to normal while cost rises only by the difference in per-request price. The lesson is the interaction: retries fix transient errors, fallback fixes sustained failure, and the breaker is what stops fallback from being slow. Retry storms are the failure to guard against, since three attempts per layer across three layers is 27 calls for one user request; retry at one layer (usually the gateway), bound it with a budget (for example retries capped at a fraction of traffic), and see Fault Tolerance and Circuit Breakers and Timeouts, Retries and Backoff.

Deep Dive: Normalising Providers and Handling Their Differences

One canonical schema, one adapter per providercanonical requestone internal schemaadapter Aroles, tools, paramsadapter Broles, tools, paramsadapter Croles, tools, paramsprovider Aown schema + eventsprovider Bown schema + eventsprovider Cown schema + eventsback: streaming events, errors and usage normalised to one internal grammar
Apps speak one request format; each adapter translates it to a provider's schema, limits and parameters, and translates streaming events, errors and token usage back. The differences are listed below.
internalprovider-specific
Difference               Why it bites                                     Gateway approach
-----------------------  -----------------------------------------------  ------------------------------------
request/response schema  message roles, system prompt placement, tool       canonical internal schema + one
                         call and tool result formats differ               adapter per provider
streaming events         different event names and end-of-stream signals   normalise to one event grammar
tokenizers               same text = different token counts per provider   count with provider's own numbers;
                         -> quotas, context checks, cost all differ         trust returned `usage` for billing
context / output limits  different maximums per model                      check prompt length BEFORE choosing
rate limits              RPM, TPM, concurrency; differ by tier/model       track headroom per provider key
features                 JSON mode, tools, vision, prompt caching          capability matrix; route by need
errors                   status codes and retry hints are inconsistent     map to a small internal error set
parameters               temperature ranges, stop sequences, seed          clamp/translate; document gaps

Normalisation has an honest limit: a canonical schema is the lowest common denominator plus escape hatches. If it exposes only what every provider supports, you lose useful features; if it passes provider-specific parameters through, callers become coupled again. A workable compromise is a stable core (messages, tools, streaming, usage) with an explicit, namespaced extension field, and a capability matrix the router reads so it never sends a vision request to a text-only model. Token accounting deserves care: a tokenizer difference means the same 2,000-word document may be a different number of tokens per provider, so a per-tenant token quota should either be expressed in a provider-neutral unit, or enforced against the provider-reported usage after the call with a conservative pre-estimate before it. Rate limits are per provider key and often per model; a gateway that only enforces its own per-tenant limits still gets 429s from the provider when the aggregate exceeds the key's ceiling, so it needs both views and should shed or queue by priority before the provider does it for you. Nobody should trust static tables in a document like this for current limits; read the provider docs and expose them as configuration.

Deep Dive: Sticky Routing, Caches, Secrets and Streaming

# Python 3 stdlib: sticky routing by rendezvous (highest-random-weight) hashing
import hashlib

def score(conv, backend):
    return int(hashlib.sha256(("%s|%s" % (conv, backend)).encode()).hexdigest(), 16)

def pick(conv, backends):
    return max(backends, key=lambda b: score(conv, b))

convs = ["conv-%d" % i for i in range(1000)]
full = ["cheap", "mid", "strong"]
before = {c: pick(c, full) for c in convs}
after = {c: pick(c, ["mid", "strong"]) for c in convs}          # "cheap" removed (outage)
moved = sum(1 for c in convs if before[c] != after[c])
cheap_before = sum(1 for c in convs if before[c] == "cheap")
print("conversations pinned to cheap before:", cheap_before)
print("conversations that moved when cheap left:", moved)
print("moved but were NOT on cheap:", sum(1 for c in convs if before[c] != after[c] and before[c] != "cheap"))
# naive modulo hashing for contrast
naive = lambda c, n: int(hashlib.sha256(c.encode()).hexdigest(), 16) % n
moved_naive = sum(1 for c in convs if naive(c, 3) != naive(c, 2))
print("modulo hashing, 3 -> 2 backends, conversations moved:", moved_naive)
# Real output from python3:
# conversations pinned to cheap before: 333
# conversations that moved when cheap left: 333
# moved but were NOT on cheap: 0
# modulo hashing, 3 -> 2 backends, conversations moved: 654

Stickiness exists because state lives on the provider side. Provider prompt caches (where offered) generally reuse a computed prefix only when the same prefix is sent to the same provider, often the same account or region, within a time window, so routing turns of one conversation to different providers throws away cached prefill and raises both cost and TTFT. Conversation-level features (threads, server-side assistant state) can be even harder to move. Hash the conversation or tenant key to a preferred backend, and fall back only when that backend is unhealthy. The demo uses rendezvous hashing: removing one backend moves only the conversations pinned to it (333 of 1,000), whereas naive modulo hashing reshuffles most of them (654 of 1,000 when going from 3 to 2). Stickiness fights the load-balancing goal, since hot conversations can skew load, so it is a preference with a health override, not a hard pin.

Caches before the router, and streaming failoverrequestexact cachehash of requestsemantic cachetenant + modelrouterproviderhit → returnsim ≥ threshold → returnstreaming failover:before the first token: switch provider silentlyafter the first token: send an in-band error, client resumes or restarts
Both caches sit in front of routing so hits never reach a provider; the exact cache keys on a hash of (model, params, messages) and the semantic cache is scoped to one tenant and model family. Once the first token has streamed, the gateway can't switch provider without changing the answer mid-way.
safetoo late to switch
Where do caches sit relative to routing?

  request -> [exact cache: hash(model, params, messages)] -> hit? return
          -> [semantic cache: embed(query) nearest >= threshold, SAME tenant + model family]
          -> router -> provider

Streaming through the gateway:
  first token not yet sent   -> failover to another provider is still invisible to the client
  after first token sent     -> cannot silently switch: text would change mid-answer
                                -> forward an in-band error, or let the client resume/restart

Secrets:
  provider keys in a secrets manager, injected at runtime, rotated, never logged or sent to clients;
  callers get gateway-issued virtual keys (per team, revocable, budget-capped); redact prompts
  and keys in logs; per-tenant "bring your own key" needs its own isolation.

Cache placement is a correctness question as much as a cost one. An exact-match cache is safe and cheap; a semantic cache (see Semantic Caching for LLMs) can return an answer written for a similar but different question, so it must be scoped by tenant and model family, gated by a similarity threshold you evaluated, and skipped for personalised or sensitive requests. Put both before the router so hits cost no provider call, and log hit and miss as first-class metrics. Streaming changes the failover story: until the first byte reaches the client the gateway can switch providers freely, but after that a switch would splice two different generations, so the gateway forwards an error frame and relies on client-side resume or restart as described in Streaming Responses, and it must relay frames without buffering. Secrets handling is plain but often asked: provider keys live only in the gateway's secret store, teams receive virtual keys that can be revoked and budget-limited (also the basis of cost attribution), and prompts and completions are treated as potentially sensitive in logs (see Authentication and Authorization).

Key Pointers

How It Works

  1. The application calls the gateway with a virtual API key and a request in the canonical schema; the gateway authenticates the caller and resolves tenant, allowed models and budget.
  2. It checks per-tenant limits and budget, then looks up the exact and (if enabled) semantic cache; a hit returns without a provider call.
  3. The router filters candidates by capability, health and quota headroom, applies the routing policy (with sticky preference for conversations), and builds an ordered fallback list.
  4. The adapter translates the request to the chosen provider's schema and parameters and sends it with a timeout; a circuit breaker per provider skips targets that are currently failing.
  5. On a retryable failure before any output is sent, the gateway backs off and retries or moves down the chain within the overall deadline; after streaming has started it forwards an error instead.
  6. The response (streamed or not) is normalised back to the canonical schema, usage is recorded with tenant, model, provider, latency and cost, and the result may populate the cache.

Try It Yourself

This simulation uses a virtual clock (no sleeping), three providers with different price units, latencies and failure rates, and a hard outage of the cheapest one from t=30 s to t=60 s. Each call's randomness depends only on provider, request and attempt, so all four configurations face identical luck and the comparison is fair. Prices are made-up units, not real prices.

# Python 3 stdlib: multi-provider router with retries, fallback chain and a circuit breaker.
# Virtual clock (no sleeping), seeded. Costs are made-up UNITS, not real prices.
import random

TIMEOUT = 2.0                                   # seconds a hung call costs us

class Provider:
    def __init__(self, name, cost, mean_lat, fail_rate, outage=None):
        self.name, self.cost, self.mean_lat = name, cost, mean_lat
        self.fail_rate, self.outage = fail_rate, outage
    def call(self, t, key):
        """returns (ok, latency_seconds); draws depend only on (provider, request, attempt)
        so every configuration below sees identical 'luck' and the comparison is fair"""
        rng = random.Random("%s-%s" % (self.name, key))
        if self.outage and self.outage[0] <= t < self.outage[1]:
            return False, TIMEOUT                       # provider down: calls hang until timeout
        if rng.random() < self.fail_rate:
            return False, 0.05                          # fast 5xx / 429
        return True, rng.expovariate(1 / self.mean_lat)

class Breaker:
    def __init__(self, threshold=5, cooldown=10.0):
        self.threshold, self.cooldown = threshold, cooldown
        self.fails, self.opened_at = 0, None
    def allow(self, t):
        if self.opened_at is None: return True
        if t - self.opened_at >= self.cooldown:         # half-open: let a probe through
            self.opened_at = t
            return True
        return False
    def record(self, ok, t):
        if ok: self.fails, self.opened_at = 0, None
        else:
            self.fails += 1
            if self.fails >= self.threshold: self.opened_at = t

def make_providers():
    return [Provider("cheap",  1, 0.6, 0.05, outage=(30, 60)),   # cheapest, has an outage
            Provider("mid",    3, 0.9, 0.02),
            Provider("strong", 10, 1.5, 0.01)]

def run(name, retries, fallback, use_breaker, n=300, dt=0.33):
    provs = make_providers()
    chain = provs if fallback else provs[:1]                 # policy: cheapest first
    br = {p.name: Breaker() for p in provs}
    rng = random.Random(42)
    lats, ok_count, cost, attempts, wasted_outage, share = [], 0, 0, 0, 0, {}
    for i in range(n):
        t0 = i * dt; t = t0; done = False
        for p in chain:
            if use_breaker and not br[p.name].allow(t):
                continue                                     # skip an open circuit instantly
            for a in range(1 + retries):
                ok, lat = p.call(t, (i, a))
                attempts += 1
                if p.outage and p.outage[0] <= t < p.outage[1]: wasted_outage += 1
                t += lat
                br[p.name].record(ok, t)
                if ok:
                    done = True; cost += p.cost; share[p.name] = share.get(p.name, 0) + 1; break
                t += 0.1 * (2 ** a) * rng.uniform(0.5, 1.0) # backoff with jitter
                if t - t0 > 6.0: break                       # overall deadline
            if done or t - t0 > 6.0: break
        ok_count += done
        lats.append(t - t0)
    lats.sort()
    print("%-28s ok=%5.1f%%  p50=%.2fs p95=%.2fs  attempts=%3d  calls-into-outage=%3d  cost=%4d  share=%s"
          % (name, 100 * ok_count / n, lats[n // 2], lats[int(n * 0.95)], attempts, wasted_outage, cost, share))

run("A: cheapest only, no retry",       retries=0, fallback=False, use_breaker=False)
run("B: retry x2, no fallback",         retries=2, fallback=False, use_breaker=False)
run("C: retry x1 + fallback chain",     retries=1, fallback=True,  use_breaker=False)
run("D: C + circuit breaker",           retries=1, fallback=True,  use_breaker=True)
# Real output from python3:
# A: cheapest only, no retry   ok= 67.0%  p50=0.62s p95=2.09s  attempts=300  calls-into-outage= 91  cost= 201  share={'cheap': 201}
# B: retry x2, no fallback     ok= 73.7%  p50=0.68s p95=6.59s  attempts=486  calls-into-outage=255  cost= 221  share={'cheap': 221}
# C: retry x1 + fallback chain ok=100.0%  p50=0.68s p95=5.79s  attempts=488  calls-into-outage=176  cost= 474  share={'cheap': 213, 'mid': 87}
# D: C + circuit breaker       ok=100.0%  p50=0.46s p95=2.38s  attempts=322  calls-into-outage= 11  cost= 478  share={'cheap': 211, 'mid': 89}

# Reading the output:
#  - A: 33% of requests fail; the outage window alone accounts for about a third of the traffic.
#  - B: retrying a hard-down provider adds timeouts (p95 6.59 s) and 255 wasted calls into the outage.
#  - C: fallback gives 100% success but 176 calls still go into the outage, so p95 stays 5.79 s.
#  - D: the breaker cuts calls into the outage from 176 to 11 and p95 to 2.38 s; cost is about 1% higher than C.
#  - p50 also drops in D because ~30% of requests were inside the outage window in A-C.

Not run here (needs a real gateway and provider accounts): a typical routing configuration expressed as data, shown only to illustrate shape.

# pseudo-config, not a real product's syntax, no output shown
routes:
  - match: {task: summarize}
    candidates:
      - {provider: cheap,  model: small-v1,  weight: 90}
      - {provider: mid,    model: medium-v2, weight: 10}   # canary
    fallback: [mid/medium-v2, strong/large-v3]
    retry: {max: 1, backoff_ms: 100, retry_on: [timeout, 429, 5xx]}
    breaker: {consecutive_failures: 5, cooldown_s: 10}
    sticky_key: conversation_id
    cache: {exact: true, semantic: {enabled: false}}

Interview Q&A

Q: Why put a gateway in front of LLM providers instead of calling them directly?

A: Centralisation: one place for auth, quotas, retries, fallback, logging and cost attribution instead of every service reimplementing them and holding provider keys. It also lets you change providers or models without changing applications, and it enforces budgets so one runaway loop cannot exhaust the account. The cost is an extra hop (typically a few milliseconds to tens of milliseconds, small next to seconds of model latency) and another tier that must be as reliable as the providers it protects.

Q: How would you design the routing policy?

A: Filter first, optimise second: drop candidates that cannot do the task (missing tool support, context too short, tenant not allowed) or are unhealthy or out of quota, then pick among the rest by cost, latency or a quality tier, and keep the remaining candidates as the fallback chain. Add weighted canary traffic for new models with per-variant metrics and a kill switch. I would avoid pure lowest-latency routing without damping because it herds traffic and oscillates.

Q: A provider has a 30-minute outage. What happens in your system?

A: The first failures are timeouts or 5xx; retries are limited and budgeted, and after N consecutive failures the circuit opens so the gateway stops calling that provider and sends traffic straight to the next candidate. A periodic half-open probe detects recovery. The trade-offs are higher cost and slightly different answers from the fallback model, which is why it must be pre-validated, and load on the fallback provider, which needs capacity or its own rate-limit headroom for the redirected traffic.

Q: How do you handle different token counts across providers?

A: The same text tokenizes differently per provider, so I do not use one tokenizer for everything. Before the call I use a conservative estimate for context-length checks and pre-quota, and after the call I record the provider-returned usage as the source of truth for billing and quota reconciliation. Cross-provider budgets are stored in money or a neutral unit, not raw tokens.

Q: Why does sticky routing matter for LLMs?

A: Provider-side prompt caching generally works only when the same prefix goes to the same provider (often same account and region) soon enough, so bouncing a conversation between providers repeats prefill for a long system prompt or history, raising cost and TTFT. I hash the conversation id to a preferred provider and override on unhealthy or saturated targets. The trade-off is uneven load, since a few very long conversations can skew a backend.

Q: Where does a semantic cache go, and what can go wrong?

A: Before the router, after auth, so hits skip the provider entirely, and keyed by tenant and model family. It can return an answer for a similar-but-different question, leak content across tenants if the scope is wrong, or serve stale facts, so it needs a tuned similarity threshold, TTLs, a bypass for personalised or sensitive prompts, and hit-quality monitoring. Exact-match caching is the safe first step.

Q: How does streaming change failover?

A: Before the first token has reached the client, failover is invisible, so I fail over on connection errors, timeouts to first byte and overload responses. Once tokens have been sent I cannot switch providers without changing text the user already read, so the gateway emits an error event and lets the client resume or restart. The gateway also must relay frames without buffering, or it destroys the time-to-first-token benefit.

Follow-up: Now the gateway is a single point of failure. How do you avoid that?

A: Run it as stateless replicas across zones behind a load balancer, keep configuration cached locally so a config-store outage does not stop traffic, and make shared dependencies (rate-limit counters, caches) fail open or to conservative local limits instead of blocking. Deploy it progressively like any critical service and provide a bypass or fallback path for the most critical calls. If availability of a single provider is 99.5% and two were independent, combined availability would be about 99.9975%, but outages are correlated (shared cloud, shared upstream), so treat that figure as an upper bound; and the gateway's own availability multiplies in.

Follow-up: How do you prevent retry storms across layers?

A: Decide which layer retries, normally the gateway, and have others pass a hint (a header or context flag) not to retry. Use exponential backoff with jitter, a per-provider retry budget (for example allow retries up to a small percentage of recent successful traffic), and an overall deadline propagated downstream. Honour Retry-After on 429s. Without these, three layers with three attempts each turn one request into 27, which prolongs the outage they are reacting to.

Follow-up: How do you attribute cost to teams and features when providers bill in aggregate?

A: Tag every request at the gateway with tenant, team, feature and prompt-version identifiers from the virtual key and headers, record provider-returned input and output token usage, and multiply by a versioned price table you maintain (prices change, so it is configuration with effective dates). Reconcile the sum against the provider invoice periodically and investigate the gap: retries, cancelled streams and cached tokens are the usual sources.

System Design Tie-in

In any design that uses LLMs at scale, draw the gateway as its own tier and say what it enforces. Estimate with stated assumptions: ILLUSTRATIVELY, 5 million requests per day averages about 58 requests per second (5,000,000 / 86,400), with a peak of perhaps 3 to 5 times that, so a handful of stateless gateway replicas handle the routing logic easily; the limiting resources are the providers' rate limits and, for streams, concurrent connections, since each long-lived stream holds a socket for seconds (about 230 concurrent streams at the average rate if each lasts 4 s, by Little's law: 58 x 4). State the cost lever numerically too: if an easy tier that is 60% of traffic can go to a model priced at one tenth, the blended cost falls to about 0.6 x 0.1 + 0.4 x 1 = 0.46 of the all-strong baseline (illustrative), which is why routing by difficulty is the biggest cost knob, ahead of micro-optimising prompts. Then cover resilience (breaker, fallback with validated alternatives, capacity reserved on the fallback provider), sticky routing for prompt caches, cache placement, secrets and virtual keys, and observability (tokens, TTFT, error class, provider, cache hit rate, per-tenant spend). See Design an LLM API Gateway for the complete walkthrough.

Trade-offs

A gateway trades directness and simplicity for control: an extra hop and an extra critical service in exchange for reliability, governance and freedom to change providers. Multi-provider routing buys resilience and price leverage, but every added provider is more surface to evaluate, secure, monitor and reconcile, and fallbacks can produce inconsistent behaviour. A canonical schema reduces coupling but hides provider-specific features unless you build escape hatches. Sticky routing saves cost and latency through provider caches but works against even load, and aggressive caching saves money at the risk of stale or wrong answers. A gateway is the wrong choice for a prototype with one model and one team (a direct SDK call with timeouts is enough), for latency-critical paths where an extra hop is unacceptable and a sidecar or library is a better home for the same logic, or when the organisation will not staff it as production infrastructure, since an under-maintained gateway is worse than none.