Model Routing and Gateways
A gateway sits in front of many providers: auth, routing, fallbacks, and logging. Route simple tasks to small models and keep one SDK in the app.
- gateway
- routing
- llmops
Hard-coding gpt-something in fourteen route handlers is how a provider outage becomes a site outage. An LLM gateway sits in front of many backends: one auth story, one log, one place to retry, and a router that sends cheap tasks to small models. The app speaks one SDK. The gateway owns vendor drift.
This is the same instinct as a BFF. The browser talks to you. You talk to whoever is healthy.
Key takeaways
- The application asks for a task (extract, chat, judge), not a vendor snapshot id, except where you pin for evals.
- A gateway handles credentials, routing, fallbacks, rate limits, and traces. It is not “a smaller model” by itself.
- Schema and tool compatibility are the real lock-in. A fallback that cannot call your tools is not a fallback.
- Pin versions in CI and in traces. Aliases move.
- Do not put the provider key in the browser and call that multi-cloud.
Why an LLM gateway exists
Without one, every Next.js route handler grows its own timeout, its own header, its own “if OpenAI 500 then…” You will get them wrong in different ways.
With one, the app does:
await gateway.complete({
task: 'extract_invoice',
messages,
schema: InvoiceSchema,
abortSignal,
});
The gateway does: pick a deployment, attach the server-side key, record usage, retry idempotent generations, fall back, export a span. LiteLLM-style proxies are this idea as a process you can run: OpenAI-compatible URL in, many providers out. You can also write a thin internal wrapper. The architecture matters more than the logo.
type Task = 'chat' | 'extract_invoice' | 'route_intent' | 'judge';
type ChatMessage = { role: 'system' | 'user' | 'assistant' | 'tool'; content: string };
type ToolSchema = { name: string; parameters: Record<string, unknown> };
type CompletionResponse = { text: string; model: string };
type Deployment = {
id: string;
task: Task;
provider: 'openai' | 'anthropic' | 'google' | 'local';
model: string; // pinned snapshot
order: number; // lower is tried first
supportsTools: boolean;
supportsJsonSchema: boolean;
};
type GatewayRequest = {
task: Task;
messages: ChatMessage[];
tools?: ToolSchema[];
abortSignal?: AbortSignal;
};
class ModelGateway {
constructor(
private deployments: Deployment[],
private completeWith: (d: Deployment, req: GatewayRequest) => Promise<CompletionResponse>,
) {}
async complete(req: GatewayRequest): Promise<CompletionResponse> {
const candidates = this.deployments.filter((d) => d.task === req.task);
const ordered = this.rank(candidates, req);
let lastErr: unknown;
for (const d of ordered) {
if (req.tools?.length && !d.supportsTools) continue;
try {
return await this.completeWith(d, req);
} catch (err) {
lastErr = err;
if (!isRetryable(err)) throw err;
}
}
throw lastErr ?? new Error('no deployment');
}
private rank(ds: Deployment[], _req: GatewayRequest): Deployment[] {
// Order is config, not a call-site model id.
return [...ds].sort((a, b) => a.order - b.order);
}
}
class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
function isRetryable(err: unknown) {
return err instanceof HttpError && (err.status === 429 || err.status >= 500);
}
Keep provider keys on the server. The browser streams from your origin. See client vs server LLM calls.
Task-based routing
Routing is a product decision table, not a magic classifier you cannot debug.
| Task | Default | Fallback | Why |
|---|---|---|---|
| Intent / classification | Small, fast model | Another small model | Volume, latency |
| Structured extract | Model with JSON schema | Same family, pinned | Validity + eval |
| RAG answer | Mid-size, tools off | Larger only if eval requires | Cost vs groundedness |
| Agent with tools | Model that actually calls tools | Provider with the same schema | Compatibility |
| Judge | Separate pinned model | None (fail the eval run) | Do not silently change scores |
A classifier that reads the user message and picks “easy vs hard” is allowed if you log the choice and eval both paths. A hidden “auto” router that you cannot replay is how quality regressions vanish.
Config belongs outside the UI code. Changing “extract uses model B” should not require a frontend deploy. Changing the task a screen calls might.
Compatibility traps
Fallbacks fail in ways load balancers do not:
- Tool schema dialects. OpenAI-style function tools vs provider-specific. If the backup cannot see
refund_order, it will narrate instead of call. - Structured output. One backend constrains decoding to JSON Schema. The backup returns prose. Your Zod parse fails closed (good) and the user sees an error (you needed a second compatible backend, or no fallback).
- Context windows. Routing a 100k-token pack to a 32k model is not a fallback. It is a 400.
- Multimodal. A text-only backup cannot “see” the screenshot. Refuse, do not pretend.
- System prompt + cache. Different providers cache differently. A prefix tuned for one may miss on another. Measure per deployment.
If the task needs tools, the candidate list must be supportsTools: true. Skipping that check is how you “fail over” into a chatbot that apologizes.
Fallbacks, cooldowns, and what the app still owns
Gateway retries belong on generation, not on tools that charge cards. Cooldown a deployment after a streak of 429/5xx so you do not hammer a sick region. Cap fallback depth. An infinite walk across models is a latency bug with extra invoices. Details live with retries, timeouts, and fallbacks.
The app still owns:
- Per-user quotas (the gateway will happily spend your key).
- Abort from the UI.
- Prompt versions and eval pins. Production can route; CI should call a pinned deployment so scores do not drift.
- Traces:
deployment.id,model.id,task.
Cost optimization without a gateway is copy-paste. With a gateway, “send classify traffic to the small model” is a config change you can roll back.
One more product rule: the UI should not branch on vendor names. If extract fails schema validation, show an extract error, not “Anthropic is down.” The gateway can attach deployment.id to the trace and to an internal header. The composer stays vendor-blind. That is how you avoid painting a provider logo onto a timeout.
Interview answer (out loud)
“An LLM gateway is a server-side proxy: one SDK in the app, credentials, routing, retries, and logging behind it. I route by task, not by sprinkling model ids in fourteen handlers, and I only fall back to deployments that support the same tools and schema. CI pins snapshots. The browser never sees a provider key. A gateway does not replace evals; it makes changing the model a controlled operation.”
Further reading (primary sources)
Related on this site
- Retries, timeouts, and fallbacks
- LLM cost optimization
- Tracing and observability for LLM apps
- Client vs server LLM calls
Related guides
- Eval Gates in CIFail the build when a golden set regresses. Thresholds, flake budgets, and why an LLM judge in CI needs pinning and retries of its own.
- LLM Cost OptimizationCost is tokens × price × retries, plus the tools you invoked. Cache, route, shrink context, and stop looping before you negotiate a contract.
- LLMOps LifecycleLLMOps is how an LLM feature is versioned, evaluated, deployed, and watched. Prompts are artifacts. Traces are tests waiting to happen.
- Tracing and Observability for LLM AppsA trace is every prompt, tool call, token count, and latency span for one user request. Without it you cannot debug, eval, or explain a bill.
- Agent Failure ModesAgents loop, lie about tool results, skip confirmation, and get stuck apologizing. Catalog the failures and the circuit breakers.