On this page
- Why routing is the wrong mental model
- The split: data plane and control plane
- Reference architecture
- Policy as data, not scattered SDK code
- Budgets and rate limits belong before provider calls
- Observability is part of the contract
- Failure modes to design for
- Rollout checklist
- FAQ
- Is an LLM gateway just a reverse proxy?
- Should policy live in the application or the gateway?
- How should teams handle provider rate limits?
- What should be traced at the gateway boundary?
- References
A production LLM gateway should not be treated as a prettier model router. The useful gateway is the policy boundary where teams enforce ownership, spend, rate limits, tracing, and failure behavior before any request reaches a model provider.
Why routing is the wrong mental model
Most teams start with a thin gateway because the first pain is provider sprawl. One service calls OpenAI. Another calls Anthropic. A third uses a cheaper fallback model for batch jobs. A gateway that normalizes URLs and headers feels like progress, especially once it can swap models behind one API surface.
That gateway is useful, but it is not sufficient. The hard production questions show up one layer higher. Which product owns this prompt? Which tenant is allowed to use this model? What is the monthly spend ceiling? What happens when the provider rate limit is hit? Which traces prove that a retrieval failure, not the model, caused the bad answer?
Those are policy questions, not routing questions. LiteLLM's proxy documentation points in this direction by treating virtual keys as a way to track spend and control model access, not merely as bearer tokens. Its budget documentation goes further by describing personal budgets, team budgets, team member budgets, and agent budgets. Once the gateway knows about owners, limits, and model access, it has become part of the control plane.
The operational mistake is pretending that each application can solve those concerns locally. That creates five different retry policies, three cost attribution schemes, and no single place to turn down a runaway agent. A gateway becomes valuable when it makes the boring rules centralized, explicit, and inspectable.
The split: data plane and control plane
The data plane is the hot path. It accepts an inference request, authenticates it, chooses a provider route, applies request transforms, forwards the call, streams the response, and records telemetry. It should stay simple enough to reason about during an incident.
The control plane is where humans and automation define policy. It owns tenant records, model allowlists, budgets, key issuance, routing rules, fallback rules, redaction policy, audit logs, and rollout state. If the control plane is missing, engineers eventually encode those decisions inside SDK wrappers and feature branches.
This split is not new in infrastructure. Envoy's external authorization filter, for example, calls an external HTTP or gRPC authorization service to decide whether a request is allowed, and Envoy returns 403 when the request is unauthorized. The lesson for LLM gateways is the same: keep the fast path tight, but move policy decisions behind a stable enforcement seam.
The seam matters because LLM calls are expensive, stateful, and easy to misuse. A normal API gateway can often treat all successful upstream calls as roughly equivalent. An LLM gateway cannot. A request to a small classification model, a long context reasoning model, and an agent session with tool access have different cost, latency, privacy, and blast radius.
Reference architecture
An LLM gateway should expose one runtime path and several policy surfaces. The runtime path handles requests. The policy surfaces let platform engineers change behavior without shipping every product again.
flowchart LR
App["Product or agent service"] --> Key["Virtual key and tenant lookup"]
Key --> Policy["Policy engine"]
Policy -->|allow| Router["Model router"]
Policy -->|deny| Deny["403 or typed policy error"]
Router --> ProviderA["Provider A"]
Router --> ProviderB["Provider B"]
Router --> Cache["Prompt or response cache"]
Router --> Telemetry["Traces, metrics, audit log"]
Budget["Budgets and rate limits"] --> Policy
Registry["Model registry"] --> Policy
Incidents["Kill switches and rollouts"] --> Policy
The policy engine does not need to be fancy at first. A database table with owners, model allowlists, dollar caps, and rate limits is already better than a dozen hardcoded SDK wrappers. The important property is that every request crosses the same enforcement point.
A useful request context usually includes the tenant, product, environment, user or service account, feature name, model alias, expected sensitivity, and idempotency key. The gateway can then answer practical questions before spend happens: is this model allowed for this product, is the monthly budget exhausted, is the user sending production data to a sandbox provider, and is this feature in a limited rollout?
Policy as data, not scattered SDK code
The simplest way to keep policy reviewable is to represent it as data. Here is a small example. It is intentionally boring.
{
"tenant": "acme-prod",
"feature": "support-agent",
"allowed_models": ["fast-chat", "deep-reasoner"],
"default_model": "fast-chat",
"monthly_budget_usd": 1200,
"rpm_limit": 120,
"tpm_limit": 250000,
"fallbacks": [
{"from": "deep-reasoner", "to": "fast-chat", "on": ["rate_limit", "timeout"]}
],
"require_trace": true,
"pii_mode": "redact-before-provider"
}
This policy can be reviewed in pull requests, loaded into a database, or managed through an admin UI. The shape is less important than the ownership model. Product teams should own intent. Platform teams should own the safe defaults and enforcement behavior.
A tiny evaluator can keep application code honest:
from dataclasses import dataclass
@dataclass
class Request:
tenant: str
feature: str
model: str
estimated_tokens: int
@dataclass
class Decision:
allowed: bool
reason: str
POLICY = {
"tenant": "acme-prod",
"feature": "support-agent",
"allowed_models": {"fast-chat", "deep-reasoner"},
"tpm_limit": 250_000,
}
def evaluate(req: Request, used_tokens_this_minute: int) -> Decision:
if req.tenant != POLICY["tenant"] or req.feature != POLICY["feature"]:
return Decision(False, "unknown tenant or feature")
if req.model not in POLICY["allowed_models"]:
return Decision(False, "model is not allowed")
if used_tokens_this_minute + req.estimated_tokens > POLICY["tpm_limit"]:
return Decision(False, "token rate limit would be exceeded")
return Decision(True, "allowed")
assert evaluate(Request("acme-prod", "support-agent", "fast-chat", 1000), 0).allowed
assert not evaluate(Request("acme-prod", "support-agent", "research-only", 1000), 0).allowed
Real systems need more nuance, but this sketch shows the boundary. The product service asks for a model alias. The gateway decides whether that request is allowed right now.
Budgets and rate limits belong before provider calls
Provider limits are real operational constraints. OpenAI documents rate limits and spend limits as part of API operations, and Anthropic documents Claude API rate limits. A production platform should not discover those limits only after applications receive random 429s.
The gateway is the best place to normalize these constraints because it sees all traffic. It can enforce per-tenant budgets, reserve capacity for critical features, shed low-priority traffic, and make fallback decisions consistently. LiteLLM's budget docs explicitly include rate limits such as TPM and RPM plus session-level caps for agents, which is the right granularity for agent platforms.
The key design detail is to separate three numbers that teams often blur together:
| Limit type | What it protects | Where it should be enforced |
|---|---|---|
| Provider quota | External API account health | Gateway router |
| Product budget | Business ownership and cost | Policy engine |
| Session cap | Agent loop blast radius | Agent runtime plus gateway |
If these limits live only in application code, they drift. If they live only in the provider console, product owners cannot reason about them. Put them at the gateway boundary and emit clear denial reasons.
Observability is part of the contract
An LLM gateway that cannot explain its decisions is a liability. When an answer is wrong, the incident review needs to know which model was called, which policy allowed it, which fallback fired, how many tokens were consumed, whether retrieval ran, and whether the provider returned an error.
OpenTelemetry now has a dedicated Generative AI area in its semantic conventions, including provider-specific sections such as OpenAI and Anthropic plus MCP, spans, metrics, events, and exceptions. That matters because gateway telemetry should not be a pile of custom log keys that every team names differently.
At minimum, every gateway request should produce a trace with these fields:
- Product, tenant, environment, feature, and caller.
- Model alias requested and concrete provider model selected.
- Policy decision, including allow, deny, fallback, or degraded mode.
- Token counts, latency, cache behavior, and provider status.
- Retrieval or tool-call correlation IDs when the request belongs to an agent or RAG workflow.
The point is not observability theater. The point is reducing incident time. If a fallback model caused lower answer quality, the trace should show it. If a budget cap denied requests for one tenant, the trace should show it. If a provider limit caused retries, the trace should show it before engineers start guessing.
Failure modes to design for
The first failure mode is policy bypass. Envoy's documentation calls out a security risk where route cache clearing after external authorization can allow requests to reach routes with different authorization requirements after the authorization check has already run. LLM gateways have an analogous risk when application code can call providers directly or when fallbacks bypass the same policy checks.
The second failure mode is silent fallback. Fallbacks are useful, but they should be visible. A support agent that quietly moves from a strong reasoning model to a cheap summarizer may keep latency green while degrading customer outcomes. Treat fallback as a policy decision and trace it.
The third failure mode is budget ambiguity. If a request belongs to a user, a tenant, a team, and an agent session, engineers need a deterministic rule for which budget applies first. LiteLLM's documentation explicitly discusses interactions between team and user budgets for keys that belong to teams in its budgets section. That is the kind of edge case to settle before production traffic depends on it.
The fourth failure mode is retry amplification. A model timeout can trigger SDK retries, gateway retries, queue retries, and agent loop retries. One degraded provider can become a cost incident. Put retry ownership in one layer and make every retry observable.
Rollout checklist
If I were adding an LLM gateway to an existing product, I would not start by migrating every call. I would start with enforcement seams.
- Inventory every direct provider call and block new ones outside the gateway.
- Create virtual keys or service identities per product feature, not one shared key for the company.
- Define model aliases that describe intent, such as
fast-chat,deep-reasoner, andsafe-summarizer. - Attach owner, environment, budget, and model allowlist to each key.
- Enforce product budgets and provider quotas before the upstream call.
- Emit GenAI traces from the gateway and propagate correlation IDs to retrieval and tool systems.
- Add kill switches for models, tenants, and features.
- Run a brownout test where a provider returns 429, 500, and slow responses.
- Review the traces with the product team before calling the migration done.
The practical milestone is simple: during an incident, one engineer should be able to answer who called which model, under which policy, at what cost, with which fallback, and why the gateway allowed it.
FAQ
Is an LLM gateway just a reverse proxy?
No. A reverse proxy forwards traffic. An LLM gateway should also enforce model access, cost ownership, rate limits, routing rules, telemetry, and incident controls. The difference is policy ownership.
Should policy live in the application or the gateway?
Product intent can live in the application, but enforcement should happen at the gateway. Application code can request a model alias and pass context. The gateway should decide whether that request is allowed under current policy.
How should teams handle provider rate limits?
Treat provider rate limits as shared infrastructure capacity, not random application errors. Enforce per-product limits before provider calls, reserve capacity for important traffic, and make fallback behavior explicit in traces.
What should be traced at the gateway boundary?
Trace the caller, tenant, feature, requested model alias, selected provider model, policy decision, fallback decision, token counts, latency, cache behavior, and provider status. For RAG and agents, propagate IDs that connect the LLM call to retrieval and tool execution.