Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Reference-grade guide to routing LLM requests across models by cost/latency/quality, escalating hard tasks, and surviving provider failures with multi-provider fallback, circuit breakers, hedging, and honest degraded-mode UX. Use when choosing which model serves a request, building a router/cascade, adding fallback for outages/ratelimits/timeouts, or designing what the user sees when you degrade.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-12 | ✗→✓ | ▲ Improved | 195% | 0% |
| case-23 | ✗→✓ | ▲ Improved | 107% | 0% |
| case-02 | ✓→✗ | ▼ Worse | 146% | 0% |
| case-09 | ✓→✓ | = Same ✓ | 158% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 134% | 0% |
Two problems share this skill. Routing = picking the cheapest model that still meets the quality bar for this request (a value decision). Fallback = surviving when the chosen model is unavailable (a reliability decision). Build both — routing without fallback is a single point of failure; fallback without routing burns money on easy work.
Tiers, not model IDs, are the durable unit: frontier (most capable, slowest, ~10–30× cost), mid (workhorse), small (fast, cheap, weak). Names shift quarterly; the tier logic does not.
The frontier model is the wrong default. ~70–90% of production traffic is easy (classification, extraction, short rewrites, routine Q&A) and a small model handles it at a fraction of the cost and latency. Routing trades three axes:
| Axis | Frontier tier | Mid tier | Small tier | |---|---|---|---| | Cost / 1M tok | $$$ (10–60×) | $$ (3–8×) | $ (1×) | | Latency (p50 TTFT) | high | medium | low | | Quality ceiling | highest | good | adequate-on-easy | | Best for | reasoning, ambiguity, long-horizon agents | most tasks | classify, extract, format |
The win is asymmetric: routing 80% of easy traffic to a small model can cut spend 60–80% with near- zero quality delta — if you escalate the hard 20% correctly. The risk is asymmetric too: route a hard task to a weak model and the failure is silent (a confident wrong answer), worse than a catchable error.
Do measure your difficulty distribution before picking a default tier. Don't pick by gut — "use the best model" is a budget decision in disguise.
Map known task types to tiers with a static table. Highest ROI, zero added latency, fully debuggable.
ROUTE_TABLE = {
"classify": small,
"extract": small,
"summarize_short": small,
"rewrite": mid,
"rag_answer": mid,
"code_gen": mid,
"multi_step_plan": frontier,
"ambiguous": frontier,
}
def route(task_kind, signals):
tier = ROUTE_TABLE.get(task_kind, mid) # safe default = mid, never small
if signals.input_tokens > LONG_CTX_THRESHOLD: # capability override (§ below)
tier = max(tier, mid)
return tierWhen task-kind is unknown, estimate difficulty from cheap signals before the expensive call: input length, question-word count, code/math presence, retrieval-score spread, constraint count, conversation depth. Combine into a 0–1 score; threshold into tiers. Keep it a few features — an over-fit difficulty model is its own maintenance burden.
A tiny model (or embedding + logistic head) reads the request and outputs a tier label. More accurate than heuristics on messy input; adds one cheap hop of latency. Train on labeled (request → tier-that-succeeded) pairs from your own traffic.
Run the small model first; accept if a verifier passes, else escalate. Pays the cheap cost on the easy majority, the full cost only on the hard minority. The verifier is the whole game — a weak one passes bad cheap answers (silent degradation); a paranoid one escalates everything (cascade plus frontier cost).
def cascade(request):
cheap = small.run(request)
if verify(cheap, request) >= CONF_THRESHOLD: # self-grade, logprob, judge, or schema check
return cheap, {"tier": "small", "escalated": False}
strong = frontier.run(request) # bounded: ONE escalation, not a ladder to infinity
return strong, {"tier": "frontier", "escalated": True}| Approach | Accuracy | Latency cost | Maintenance | Use when | |---|---|---|---|---| | Heuristic (rules/table) | medium | none | low | known task types, hot path | | LLM-as-router | high | +1 call | low | varied/unstructured input | | Learned (RouteLLM-style) | highest | tiny (classifier) | high (needs labeled data + retrain) | high volume, stable distribution, cost-critical |
RouteLLM trains a binary router on preference data to send each query strong-or-weak at a target strong-call rate, recovering most frontier quality while routing a large share to the cheap model. Start heuristic; graduate to learned when volume justifies the labeling pipeline.
Some requests require a capability — route to a model that has it regardless of cost:
This intersects the capability matrix (§6): never route to a model that lacks the required capability or that silently ignores the param you depend on.
when a budget threshold is hit (with a degraded-mode signal, §5).
at some quality cost; reserve the slow frontier tier for async/batch work. Don't optimize one axis blind: cheapest-always tanks quality; fastest-always overpays.
Escalation = routing up a tier after a first attempt gave evidence it was insufficient. Escalate on:
| Trigger | Signal | |---|---| | Low confidence | verifier/judge score below threshold, low token logprobs, model hedging ("I'm not sure") | | Validation failure | output fails schema/Zod parse, fails tests, fails a business rule | | Refusal / safety stop | model refused a legitimate task, or truncated | | Retrieval gap | RAG context thin or off-topic, model says "not in the provided context" | | Empty / degenerate | blank, repetition loop, wrong language, wrong format |
Bounded escalation is non-negotiable. Cap escalations per request (typically one: small → frontier, stop). An unbounded ladder retries forever, multiplies cost, and stacks latency. After the cap, return the best attempt with a degraded-mode signal (§5) — don't loop.
def with_escalation(request, max_escalations=1):
tier, escalations = small, 0
while True:
out = tier.run(request)
if accept(out) or escalations >= max_escalations:
return out, {"escalations": escalations, "final_tier": tier.name}
tier = next_tier_up(tier); escalations += 1 # bounded: terminatesDo log every escalation with its trigger — escalation rate is a routing-quality KPI (§6). Don't escalate on transient infra errors (timeout, 5xx); that's a fallback concern (§4), not a difficulty signal — escalating a tier won't fix a network blip.
Failures are operational, not quality: outage, 429 rate limit, timeout, 5xx, connection reset, region down. Classify the error, then act — they don't all warrant the same response.
| Error class | Retryable? | Action | |---|---|---| | 429 rate limit | yes, after Retry-After/backoff | backoff+jitter, then fall over to next provider | | 500/502/503 | yes, bounded | retry once, then fall over | | Timeout | maybe | respect timeout budget; hedge or fall over, don't blind-retry | | 400 / invalid request | no | fix the request (e.g. unsupported param, §6); retrying repeats the error | | 401/403 auth | no | alert; never retry a bad key | | Content filter | no | surface to user; not a model-availability problem |
Retry only retryable classes. Exponential backoff with full jitter spreads retries so a recovering provider isn't hammered by a synchronized herd. Cap attempts (2–3).
def backoff_delay(attempt, base=0.5, cap=8.0):
return random.uniform(0, min(cap, base * 2 ** attempt)) # full jitterFallback requires a uniform interface so the caller doesn't know which provider answered. Normalize request and response shapes; map each provider's params/errors to a common vocabulary.
class Provider(Protocol):
def run(self, req: ChatRequest, timeout: float) -> ChatResponse: ...
capabilities: Capabilities # drives the capability matrix (§6)Order is policy, not an afterthought. Default: same-quality alternate first (preserve UX), cheaper/ weaker last (preserve availability).
FALLBACK_ORDER = [
primary_frontier, # 1. intended quality
alt_provider_frontier, # 2. same tier, different provider — best UX preservation
same_provider_mid, # 3. degrade tier, same vendor
alt_provider_mid, # 4. degrade tier + vendor
cached_or_static, # 5. last resort — never fail hard (§5)
]Skip any candidate that fails the request's capability gate (a vision request on a text-only model "succeeds" blind). Stop at the first success; emit a degraded signal if the answering tier is below intended.
Don't retry into a provider you know is down — that wastes the timeout budget on every request. Per provider: closed (normal) → open after N consecutive failures (fail fast, skip straight to fallback) → half-open after a cooldown (one probe; success closes it, failure re-opens).
class Breaker:
state, fails = "closed", 0
def allow(self): return self.state != "open" or self.cooled_down()
def record(self, ok):
if ok: self.state, self.fails = "closed", 0
else:
self.fails += 1
if self.fails >= THRESHOLD: self.state = "open"; self.opened_at = now()Tail-latency-sensitive paths: if the primary hasn't responded by p95, fire a second request to an alternate, take whichever returns first, cancel the loser. Cuts tail latency at the cost of duplicate spend — cap the hedge rate (e.g. ≤5%) so a slow provider doesn't double your bill.
Set a total per-request budget and subtract elapsed time before each fallback hop; never let retries + fallbacks blow the user-facing deadline. A 30s budget allots 8s primary, 8s alternate, then a cached/partial response — not three sequential 30s waits totaling 90s.
When you fall back or downgrade, the user is getting something different from the ideal. The cardinal rule: degrade honestly — never silently, never to a hard error.
| Degradation | What the user sees | |---|---| | Fell to slower/cheaper model | answer + subtle "responding in limited mode" note if quality may differ | | Served cached answer | answer + "showing a recent result" / timestamp | | Partial result | what completed + "couldn't finish X" + retry affordance | | Feature flagged off | the capability hidden/disabled, not a crash | | Fully unavailable | graceful message + ETA/retry, queue position if queued |
dangerous failure mode — users trust a wrong answer they can't tell is degraded. Surface it, subtly.
proportionally — quiet note for a same-tier provider swap, explicit banner for a real quality drop.
(e.g. agentic multi-step, long-context summarization) and keep the core path alive.
with a clear "try again shortly" — bounded waiting beats an opaque hang or a 500.
clear actionable error. A blank screen or stack trace is never acceptable.
Do: make degraded mode a designed state with its own copy and telemetry. Don't: let degraded mode be "whatever the exception handler happens to render."
Maintain a per-model capability table so the router never sends an unsupported param — a top source of silent 400s and ignored-parameter bugs:
| Model (tier) | Vision | Tools | JSON mode | Max ctx | temperature | Notes | |---|---|---|---|---|---|---| | frontier-A | yes | yes | strict | 200k | yes | reasoning model may ignore temperature | | mid-B | no | yes | yes | 128k | yes | — | | small-C | no | partial | best-effort | 16k | yes | weak tool use; verify outputs | | vision-D | yes | no | no | 32k | yes | route images here |
Use the matrix two ways: (1) gate capability-based routing/fallback (§2, §4); (2) scrub the request before sending — strip or translate params the target doesn't support, rather than let the provider reject or silently ignore them.
Observability is the other half of routing — covered fully in the observability/evals skill; cross-reference rather than duplicate. Route-specific signals to emit: chosen tier, escalation trigger + rate, fallback rate per provider, circuit-breaker transitions, cost-per-request by route, latency by tier, degraded-mode served rate. You can't tune a router you can't see — a creeping fallback rate is your earliest outage warning.
Every route is a point in a 3-D tradeoff; make the tradeoff explicit per path:
| Route | Cost | Quality | Latency | Fits | |---|---|---|---|---| | small-only | lowest | adequate-on-easy | lowest | bulk classify/extract, autocomplete | | cascade (small→frontier) | low avg | high (verifier-gated) | low avg, high tail | mixed-difficulty Q&A | | frontier-only | highest | highest | highest | reasoning, agents, ambiguity | | hedged frontier | high | highest | low tail | latency-critical premium path | | mid + capability override | medium | good | medium | general workhorse |
Pick per task class, not globally. The cascade's "low average / high tail" is the classic trap: cheap on average but escalated requests are slow — fine for async, risky for a hard interactive SLA (prefer a latency-aware direct route there).
your 30-minute incident. Fix: multi-provider abstraction + fallback order (§4).
surfacing it. Users trust confident wrong answers. Fix: degraded-mode signaling (§5) + escalation triggers tuned on real failures (§3).
keep it down. Fix: backoff + full jitter, bounded attempts, circuit breakers (§4).
tools/temperature/JSON mode to a model that rejects orsilently ignores them → 400s or ignored constraints. Fix: capability matrix + request scrub (§6).
invisibly. Fix: measure difficulty distribution (§1), keep mid as the safe default, never small.
pass; cost savings are real, quality loss is hidden. Mirror trap: verifier too strict → escalates everything, paying cascade plus frontier. Fix: calibrate the verifier against labeled outcomes, monitor escalation rate (§6).
latency upward. Fix: bound escalations (§3); route infra errors to fallback (§4), not escalation.
Other measured skills in the registry, with their headline benchmark lift.