Design an LLM Gateway: Token Limits, Fair Share and Failover Across Providers

A system design walkthrough for one API in front of several model providers and many tenants: token-aware rate limits, weighted fair share, circuit breakers, failover before and after the first token, caching, hard budgets and keys, with a simulator you can break.

Rahul Bisht

Founder, CrawlPilot

·
Oct 22, 2026
·
Verified Oct 25, 2026
·System Design·
28 min read
·
Design an LLM Gateway: Token Limits, Fair Share and Failover Across Providers
Key takeaway
An LLM gateway is a rate limiter, scheduler and accountant that happens to proxy HTTP. Count in tokens, reserving an estimate before forwarding and settling real usage after; share each provider quota by weight so one tenant can't take it all; fail over freely until the first token leaves, and after that, tell the client the truth instead of stitching two models together.

Your API gateway counts requests. Your model providers count tokens. That one unit mismatch is where most of the trouble starts: two requests that look identical to Envoy or nginx can differ a hundredfold in what they cost and how much of a provider's per-minute quota they consume, and you only learn the second number after the response has finished streaming.

Put a dozen tenants on top of that, two or three providers with their own quotas and their own bad afternoons, and a finance team that wants a hard monthly cap per plan, and "one API in front of several providers" stops being a proxy problem. Below is the design one request at a time, then the parts that break it, then a simulator where you can break it yourself.

This post sits one layer above LLM inference serving, which is about keeping your own GPUs busy. Here you don't own the GPUs. You rent quota from providers and resell it to tenants, and the design is about spending that quota well.


What the gateway owes its callers

Tenants call one endpoint with one key, in one request format. Behind it, the gateway owes them five things a plain reverse proxy doesn't give:

  • Survive a provider outage. Providers publish their incidents on status pages (OpenAI, Anthropic); your users shouldn't have to read them.
  • Live inside the quotas you rent. Every provider limits you per minute, and the units differ. OpenAI limits requests and tokens per minute (and per day); Anthropic limits requests, input tokens and output tokens per minute separately.
  • Share those quotas fairly between tenants who pay different amounts.
  • Stop spending at a cap, not report the overspend next month.
  • Keep the provider keys out of tenant hands and out of logs.

Open-source gateways ship most of this as configuration now (Envoy AI Gateway advertises provider fallback and token limits per team, app or model; LiteLLM documents provider fallbacks). The configuration is easy to copy. Knowing which defaults are wrong for your traffic is the part worth understanding.

A request, start to finish

Step through the path. Every numbered step is a separate component with its own failure mode, and the arc at the end (step 6) is the one people forget: the real token count only exists after the stream ends.

The request path through an LLM gateway
gateway key1tenant + model2≈3.2k tokens reserved3provider format4SSE tokens5final usage6reconcile6Tenant appone key and one formatAuth + policywho, which plan, whichToken limitertenant and providerRouter + adapterpicks a healthy providerProviderstreams tokensUsage metertokens and dollars
  1. The app sends a chat request with its gateway key, never a provider key. The gateway resolves the tenant, its plan and the models that plan may call.
  2. Tenant policy runs first: is this model allowed, is the budget open, is the prompt within the plan's size limit? Cheap checks, no tokens counted yet.
  3. The limiter estimates input tokens, reserves an output allowance, and takes both from the tenant's bucket and the chosen provider's bucket. If either is short, the request waits or gets a 429 here, before any money is spent.
  4. The router picks a provider that is healthy and has quota, and the adapter translates the request into that provider's format.
  5. Tokens stream back over server-sent events. The adapter translates each provider event into the gateway's own stream format on the fly.
  6. When the stream ends, the provider's usage report gives the true input and output counts. The meter books the cost and returns the unused reservation to the buckets.

6 steps. Press Play to walk through them, or click any part of the diagram.

App → auth and tenant policy → token limiter → router and adapter → provider; tokens stream back, then the meter settles real usage.

Two things fall out of this picture. The limiter decides on a number it made up, so the design has to tolerate the estimate being wrong. And the response is a stream, so every decision after step 5 is constrained by what the client has already seen. The next sections take those two in turn.

Counting tokens before they exist

A per-request limit treats a 200-token classification call and a 90,000-token document summary as the same thing. Providers don't, so the gateway can't either.

Requests per minute
  • Known at admission, exact
  • One counter, trivially cheap
  • A long prompt counts the same as a short one
  • Tenants pass your limit and still exhaust the provider's token quota
  • Can't map to a dollar budget
Token buckets, reserve then settle
  • Same unit as the provider's quota and the bill
  • Long prompts pay for what they use
  • Feeds budgets directly
  • Input is estimated, output unknown until the end
  • Needs a reconcile step after every stream

Verdict: Keep a coarse request limit as a guard against floods of tiny calls, but make tokens the unit you schedule and charge in.

Two ways to limit one tenant: by requests, or by tokens

Input tokens can be estimated before forwarding. Providers do exactly this themselves: Anthropic says its input-token limit is estimated at the start of each request and adjusted during it, and OpenAI's limiter uses an estimate based on the character count of the request. A character-based estimate per model family is good enough for admission. Calling a counting endpoint on the hot path isn't worth the extra round trip, and Anthropic notes its token counts are themselves estimates and that tokenizers change between model generations. An estimate tuned for one provider's tokenizer is wrong for another's, so keep one ratio per target model, not one per gateway.

Output tokens are the hard part, because nobody knows how long the answer will be. The two big providers handle it differently, and the difference is instructive. OpenAI counts a request against your limit as the larger of max_tokens and the estimate, which is conservative. Anthropic counts output tokens as they are produced and says max_tokens doesn't factor in at all.

For a gateway, reserving the full max_tokens sounds safe and starves everyone in practice, because many clients set it to the model maximum out of habit. A better reservation is the smaller of max_tokens and a recent high percentile of that tenant's real output lengths for that model. Then let the bucket go negative when a response runs long. The debt delays the tenant's next request instead of cutting off the current one mid-sentence.

There are two buckets per request, not one:

  1. 01
    The tenant bucket, sized by plan. It answers "has this customer used its share?"
  2. 02
    The provider bucket, sized by the quota you rent from that provider for that model. It answers "will this request get a 429 from upstream?"

Keep the provider bucket honest by reading the rate-limit headers each provider returns on every response (x-ratelimit-remaining-tokens at OpenAI, anthropic-ratelimit-input-tokens-remaining and friends at Anthropic). Your local count drifts; their header is the truth. When the provider bucket runs dry, the router sends the request somewhere else before the provider has to refuse it. The bucket mechanics themselves (refill rate, burst, where the counter lives across gateway replicas) are the same as any distributed limiter; the rate limiter design covers them in depth.

Sharing a quota you rent

Here's where a gateway differs most from a single-tenant client. The provider quota is a shared pool, and the question is who gets it when demand exceeds it.

The default most proxies implement is first come, first served. Under contention, every tenant is cut by the same fraction, so the tenant sending the most traffic gets the most capacity. A free-tier customer running a backfill script can push a paying customer's interactive requests into 429s, without breaking any rule.

Weighted fair share fixes that. Each tenant gets capacity in proportion to a weight (say, 2 for Pro and 1 for Free), but a tenant that needs less than its share only takes what it needs, and the remainder is redistributed to the others. It is max-min fairness with weights. It doesn't punish the heavy tenant when there's room, only when there's contention. In practice you implement it as one queue per tenant drained by something like deficit round robin, with the deficit counted in tokens rather than packets.

Try it. The defaults put three tenants against three providers with 26 requests a minute of combined quota; Noisy Co alone wants 24.

LLM gateway simulator (illustrative numbers)
GatewayFCFSAcme6/minBeta4/minNoisy Co24/minProvider A12/12Provider B8/8Self-hosted6/6

Minute 1. Acme: 5 of 6 served. Beta: 3 of 4 served. Noisy Co: 18 of 24 served.

Tenants (weight)
6 req/min
5
served · Σ5
1
429 · Σ1
0
errors · Σ0
0
blocked · Σ0
Budget $0.10 of $40
4 req/min
3
served · Σ3
1
429 · Σ1
0
errors · Σ0
0
blocked · Σ0
Budget $0.06 of $20
24 req/min
18
served · Σ18
6
429 · Σ6
0
errors · Σ0
0
blocked · Σ0
Budget $0.36 of $6
Providers (quota per minute)
Provider A
Load 12 of 12
Errors so far: 0
Provider B
Load 8 of 8
Errors so far: 0
Self-hosted
Load 6 of 6
Errors so far: 0

Try: switch to weighted fair share and watch Acme. Take Provider B down with the breaker off, then on. Press Play and wait for Noisy Co's budget to run out.

Each tick is one minute. Rates, quotas, the $0.02 per request price and the budgets are made up to show the behaviour, not any provider's real limits.

What to notice:

  1. 01
    First come, first served: Acme asks for 6 a minute and gets 5, while Noisy Co gets 18 of its 24. Switch to weighted fair share and Acme and Beta get everything they ask for. Noisy Co still gets 16, because the others don't need the rest.
  2. 02
    Take Provider B down with the breaker off: the gateway keeps routing nearly a third of the traffic to it, and every tenant sees errors in proportion to how much it sent.
  3. 03
    Turn the breaker on: after one bad minute the breaker opens, Provider B drops out of the routing table, and the errors turn into 429s. Throttling is the honest result now that capacity really is smaller. Every few minutes the breaker lets one probe through to check whether B is back.
  4. 04
    Press Play and wait: Noisy Co spends its illustrative $6 budget and is blocked outright, and its share flows back to the other two.

The simulator shows request counts to keep the picture readable. The real scheduler works in reserved tokens, as described above.

When a provider goes dark

Health-based routing needs a definition of "unhealthy" that matches what your users feel. Three signals, in rough order of usefulness:

  • Server errors and overload responses. Anthropic can return 529 overloaded_error when its API sees high traffic across all users. That is a capacity signal about the provider, not about you.
  • Time to first token. A provider that answers in 40 seconds instead of 2 is down for an interactive product even though it returns 200. Trip on a latency threshold, not just on errors.
  • Upstream 429s. These mean your quota is exhausted, not that the provider is sick. Route around them, but don't trip the health breaker on them, or one tenant's burst will mark a healthy provider dead for everyone.

The breaker itself is the classic circuit breaker: closed while things work, open after a threshold of failures so calls fail fast instead of piling up on timeouts, half-open after a cooldown to let a trial request through.

Failing over before the first token
stream: true1attempt 12529, no tokens yet3A is open3attempt 24tokens stream5Tenant appwaiting on a streamRouterhealth + quota awareProvider AoverloadedProvider BhealthyBreaker for Aclosed → open →
  1. The tenant asks for a streamed answer. The client has received nothing yet, and that is what makes the next steps possible.
  2. Attempt 1 goes to Provider A, the preferred route. It answers with an overload error, or with nothing at all before the first-token deadline.
  3. The failure counts against A's breaker. Past the threshold, the breaker opens and A leaves the routing table for a cooldown, so the next hundred requests don't each wait on a timeout.
  4. The router retries the same request on Provider B under the same idempotency key, after translating it into B's format. The reservation moves to B's quota bucket.
  5. B streams the answer. The client sees one slightly slower response and an attempts count in a response header; nothing else about the failure leaks through.

5 steps. Press Play to walk through them, or click any part of the diagram.

Provider A fails before sending anything; the breaker opens and the same request, same idempotency key, goes to Provider B.

The retry budget matters as much as the breaker. Allow one failover per request, two at most, and cap the total time spent before the first token. Unbounded retries during a provider-wide incident turn your gateway into a load amplifier against the providers that are still up.

There is one quiet cost to failover: it throws away warm caches. Provider prompt caches live inside one provider, and OpenAI notes its cached state lives on individual machines, so sticky routing is what earns cache hits. Prefer the provider a tenant's long prefix is already cached on, and treat a failover as costing a cache miss in your routing score.

The first token is the point of no return

Everything in the last section works because the client hadn't seen a byte. Once tokens are on the wire, the options shrink fast.

Before the first token
  • Retry on another provider, invisibly
  • Status code and headers are still unsent
  • The client sees one answer, written by one model
  • Bound it with a first-token deadline and one or two attempts
After tokens reached the client
  • Same-model continuation is possible for plain text
  • A different model restarts or rephrases the answer
  • Tool-call and thinking blocks can't be resumed partway
  • The status is already 200; errors must travel inside the stream
  • The prompt is billed again on the retry

Verdict: Fail over freely until the first token. After it, end the stream with a typed error and let the client decide.

What the gateway can still do, depending on when the provider fails

Mid-stream failures are real, not theoretical. Anthropic documents that an error can arrive after the API has already returned 200, as an error event inside the stream. Its own recovery guidance is to send a continuation request containing the partial response. It also says tool-use and thinking blocks can't be partially recovered: you resume from the last text block.

That continuation trick works with the same model. Across providers it produces an answer written by two models, with a seam a careful reader will find. My call: the gateway does not continue silently. It ends the stream with its own final event that says what happened, how many tokens were delivered, and whether a retry from scratch is safe. Offer continuation as an opt-in mode for plain-text chat only.

Two supporting pieces make this workable:

  • Idempotency keys. The client sends one per logical request. If the client's connection drops and it retries, the gateway can replay a completed response from a short-lived store, or say "still running", instead of generating and billing twice. This matters more for agents, where a duplicate response can mean a duplicate tool call.
  • A way to tell the client. Headers only work before the first byte. After that, the information has to ride inside the stream. Use a gateway-specific event type that SDKs can ignore, or a comment line, which the server-sent events spec tells clients to skip.

Here is a streamed request that fails over cleanly, step by step. The provider payloads are placeholders; the gateway's own format is ours to define.

Tenant appGatewayProvider AProvider BPOST /v1/chat (stream)1attempt 1 (A's format)2529 overloaded3attempt 2 (B's format)4200 + token stream5200 + SSE in gateway format6final usage event7

7 messages. Press Play, or pick one, to see exactly what is sent.

One streamed request: Provider A is overloaded before the first token, so the gateway retries on Provider B.

Two caches with very different risks

"Add caching" means two unrelated things in a gateway. One is nearly free to adopt. The other can return the wrong answer.

Provider prompt cache
  • Reuses computed state for an identical prefix; the answer is still fresh
  • Anthropic bills cache reads at 0.1× the base input price, less on some models
  • OpenAI enables it by default on supported models
  • Only exact prefixes hit; one changed byte early in the prompt misses
  • Lost on failover to another provider
Gateway semantic cache
  • Skips the model call entirely on a hit
  • Similar is not the same question: a near-miss returns a confidently wrong answer
  • Keyed globally, it leaks one tenant's answers to another
  • Stale for anything time-sensitive or personalised

Verdict: Turn on provider prompt caching everywhere. Offer an exact-match response cache per tenant. Treat a semantic cache as an opt-in feature for FAQ-like traffic, never a default.

Provider prompt caching vs a gateway semantic cache

The provider side is cheap and well documented. Anthropic bills cache writes at 1.25× and cache reads at 0.1× the base input price for the default 5-minute lifetime (reads cost less still on some models), and for most of its models cached reads don't count toward the input-token rate limit. Caching raises effective quota as well as cutting cost. OpenAI applies caching by default on supported models once a prefix reaches a minimum length. The gateway's job is to keep prefixes stable: system prompt and tool definitions first, per-request content last. The token optimization guide has the prompt-side techniques.

Watch the isolation boundary. Provider caches are isolated per organization, and on the Claude API per workspace. If every tenant goes through one gateway workspace, they share one cache scope. Hits still require an identical prefix, so no tenant reads another's answer, but cache accounting per tenant is gone. OpenAI lets you pass a prompt_cache_key per customer to keep that accounting separate; on the Claude side, a workspace per large tenant does the same job.

Budgets that stop spending

A budget that's checked after the request is a report, not a cap. Picture ten concurrent requests from one tenant: each one sees "$1 left", and together they spend several times that. The fix is the same reserve-then-settle pattern as the token limiter, applied to dollars.

At an illustrative $3 per million input tokens and $15 per million output tokens, a request with 3,000 input tokens and an 800-token output cap reserves at most 3,000 × $3/1M + 800 × $15/1M ≈ $0.021. If the tenant's remaining budget can't cover that reservation, the request stops at the gateway and never reaches a provider.

A hard budget cap with reservations
request1reserve ≈$0.0212reserved, so forward3settle actual cost4threshold crossed5no room: reject6Tenant appFree planBudget checkremaining minus reservedSpend ledgerper tenant per monthRejectedbudget exhaustedProviderdoes the workOwner alertat 80% spent
  1. A request arrives. The gateway already has the input estimate and the output reservation from the token limiter, so it can price the worst case.
  2. It atomically moves the worst-case cost from 'remaining' to 'reserved' in the tenant's ledger. Concurrent requests can't all see the same remaining balance.
  3. Only after the reservation succeeds does the request go to a provider.
  4. When the stream ends, the actual cost is booked and the unused part of the reservation goes back to 'remaining'. Failed attempts that produced nothing are released, not charged.
  5. Crossing a soft threshold, say 80% of the month, notifies the tenant's owner. Nothing is blocked yet.
  6. When a reservation no longer fits, the request is rejected with a clear 'budget exhausted' error before any provider sees it. The cap holds even under a burst.

6 steps. Press Play to walk through them, or click any part of the diagram.

Reserve the worst-case cost before forwarding, settle the real cost after, alert at a threshold, reject at the cap.

Three choices to make explicitly. Who pays for a failed attempt: if a provider failed mid-stream after billing you, eat it as the platform or pass it on, and say which in the plan's terms. Hard or soft cap per plan: a free tier gets a hard stop; an enterprise tenant usually wants an alert and a manager's override rather than an outage at 3 a.m. Where the ledger lives: the reservation needs an atomic decrement (a Redis script or a row lock), while the durable spend record can be an append-only usage log rolled up per day.

What to watch, and where the keys live

Everything above produces events you'll want on a dashboard. The useful cuts:

  • Per tenant: tokens and dollars per day against budget, 429s from your limiter, the burn rate (how many days of budget are left at today's pace).
  • Per provider and model: time to first token and time between tokens at p50, p95 and p99. These are two different latencies with different causes, so keep them as separate series. Add errors by class (overload, other 5xx, timeouts, mid-stream aborts, upstream 429s) and breaker state changes.
  • Per route: failovers per minute, cache read share of input tokens, and the gap between estimated and actual tokens. A growing gap means your estimator needs retuning for a new model.

Keep tenant identifiers out of high-cardinality metric labels once you have more than a few hundred tenants. Per-tenant numbers belong in the usage ledger, which is already keyed by tenant. Don't log prompts or completions by default. Log their token counts and a hash, and make content logging a per-tenant opt-in for debugging.

Keys get their own picture, because the rules are strict and simple:

Where the keys live
gateway keycached in memorykey Akey BTenant appholds a gateway keyKey vaultprovider keys + BYOKGatewaytenant keys hashed atProvider adapterinjects the provider keyMetrics + ledgercounts and hashes, noProvider Aone workspace per envProvider Bone workspace per env
Tenants hold gateway keys only; provider keys stay in a vault and the adapter injects them per request.

Tenants only ever hold a gateway key, stored as a hash, scoped to their models and revocable without touching a provider. Provider keys live in a vault and are injected by the adapter. They're never returned in errors, never logged, and rotated with two valid keys overlapping. Use separate provider workspaces or projects per environment so a leaked staging key can't spend production quota. When a large tenant brings its own provider contract, store that key encrypted under its own tenant entry and route that tenant only to it. Its traffic then also stops drawing on your shared quota.

Check yourself

Check yourself

Q1Provider A starts returning 429s because one tenant burst through your shared quota. What should the health breaker for Provider A do?

Q2Provider B fails after 300 tokens of a streamed answer have reached the client. What's the safe default?

Q3Why reserve worst-case cost before forwarding instead of checking the budget after each response?

Cheat sheet
LLM gateway design

Token limits

  • Schedule and charge in tokens; keep a request limit only as a flood guard.
  • Reserve estimated input plus min(max_tokens, recent p95 output), then settle real usage.
  • Two buckets per request: the tenant's plan and the provider's rented quota.
  • Correct the provider bucket from the rate-limit headers on every response.

Fair share

  • First come, first served hands the quota to the loudest tenant.
  • Weighted max-min share: weights by plan, unused share flows to others.
  • Per-tenant queues drained by deficit round robin counted in tokens.

Health and failover

  • Trip breakers on overloads, 5xx, timeouts and slow first tokens, not on upstream 429s.
  • One or two attempts, bounded by a first-token deadline.
  • Before the first token, fail over invisibly under the same idempotency key.
  • After it, end the stream with a typed error; continuation is opt-in, same model only.

Caching

  • Provider prompt caching everywhere: stable prefixes first, request content last.
  • Sticky routing keeps caches warm; a failover costs a cache miss.
  • Semantic cache only per tenant, opt-in, for FAQ-like traffic.

Budgets

  • Atomically reserve worst-case cost before forwarding; reject when it doesn't fit.
  • Settle actual cost after the stream; release failed attempts.
  • Soft alert at a threshold, hard cap per plan.

Ops and keys

  • Track TTFT and inter-token latency separately, per provider and model.
  • Per-tenant cost lives in the usage ledger, not in metric labels.
  • Tenants hold gateway keys; provider keys stay in a vault, per environment.
The decisions from this post on one page.

Sources