AI Engineering No. 249

LLM Gateway Routing skill for Claude Code

Fallbacks that are tested, retries that cannot snowball. Claude treats multi-provider routing as a contract per route rather than a list of model names. It builds an error-to-action table, splits time-to-first-token from total deadlines, caps retries with a budget, checks that each fallback passes the route's eval, and picks between an in-app router, a LiteLLM proxy, or a managed gateway, including when you need none of them.

  • namellm-gateway-routing
  • size263 lines · ≈3,300 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs routing for LLM traffic across providers and models - an error-to-action table for fallbacks on 429, 5xx, overload and timeouts, model tiering by task difficulty with escalation, time-to-first-token and total deadlines, retry budgets that cannot amplify outages, cooldowns, and per-route cost and latency tracking, implemented in-app, with a self-hosted LiteLLM proxy, or a managed gateway. Use when adding a fallback provider, hitting rate limits or overload errors, routing easy requests to a cheaper model, choosing an LLM gateway, or deciding whether a gateway is overkill.

Try asking

  • Add a fallback provider for when our main model returns 529 or rate limits us
  • Route easy support questions to a cheaper model without hurting quality
  • Do we actually need LiteLLM or a managed gateway for our three AI features?

Use it when

  • Provider outages, 429s or overload errors are reaching users
  • You want a second provider or model as a tested fallback
  • Mixed easy and hard traffic runs on one expensive model
  • Several teams or services share LLM keys and need budgets and central logs

Skip it when

  • You are wiring a single model into one feature for the first time — use llm-app-builder
  • The goal is cutting token spend with caching and batching — use prompt-caching-costs

How to check the result

Fault injection against a stub provider produces the planned action for each error inside the deadline, and scripts/route_report.py shows error rate, fallback rate and p95 within each route's budget.

The common mistake it prevents

Leaving SDK retries on underneath a gateway that also retries, so one provider incident turns every request into six attempts and deepens the outage.

~/.claude/skills/llm-gateway-routing/SKILL.md

--- name: llm-gateway-routing description: Designs routing for LLM traffic across providers and models - an error-to-action table for fallbacks on 429, 5xx, overload and timeouts,… license: MIT ---

Routing is done well when every route has a written contract (quality bar, latency budget, cost ceiling), every provider error maps to exactly one action, every fallback model has passed the route’s eval, retries are bounded by a deadline and a budget, and a per-route report shows error rate, fallback rate, p95 latency and cost per request. A fallback list nobody has tested is not resilience; it is a second, unknown product.

Ground rules

  • Retry at one layer only. If a gateway retries, set the provider SDK to max_retries=0 (Anthropic and OpenAI SDKs retry twice by default) or attempts multiply.
  • A fallback model must pass the same eval as the primary on that route’s test set before it goes in the config. Prompts tuned for one model often degrade on another.
  • Never fall back on a deterministic client error (400, 401, 403, 404, 413). It fails the same way everywhere, and fallback hides the bug.
  • Streaming: fall back only before the first token reaches the user. After that, surface an error or restart visibly; never splice two models’ output.
  • Every attempt is logged with route, model, status, latency, tokens and cost. Without per-attempt logs you cannot see retry amplification or silent fallback.

Do you need a gateway?

SituationRecommendation
One provider, one or two features, low volumeNo gateway. Official SDK in one client module (see llm-app-builder) with SDK retries and a timeout
One provider, need a second for outagesIn-app router (below), ~100 lines, no new infrastructure
Several services or teams share keys, need per-team budgets, virtual keys, central logsSelf-hosted proxy (LiteLLM) or a managed gateway
No ops capacity, want fallbacks plus analytics fastManaged gateway (OpenRouter, Cloudflare AI Gateway, Vercel AI Gateway, Portkey); check data retention and zero-data-retention terms first
Strict data residency or regulated dataSelf-hosted proxy in your network, or direct SDK calls; a third-party gateway is another processor in the data path

A gateway adds a network hop, a single point of failure, and a lowest-common-denominator API. If you rely on provider-specific features (prompt caching breakpoints, extended tool types, structured output modes, citations), verify the gateway passes them through unchanged before adopting it.

Workflow

- [ ] 1. Inventory routes: feature x task, current model, volume, p95, error rate, cost
- [ ] 2. Write a contract per route (quality bar, latency budget, cost ceiling, data constraints)
- [ ] 3. Build the error-to-action table
- [ ] 4. Choose tiering: static per route first, difficulty-based only with an eval
- [ ] 5. Set deadlines: TTFT timeout, total timeout, per-attempt timeout
- [ ] 6. Set the retry budget and cooldowns
- [ ] 7. Implement (in-app, LiteLLM, or managed) and log every attempt
- [ ] 8. Prove it with fault injection and the route report

1-2. Inventory and contracts

Grep for every model call (messages.create, chat.completions.create, generateText, litellm.completion) and list them as routes. For each, record in a table:

| Route | Primary | Fallback | Quality bar | TTFT budget | Total budget | Cost ceiling/req | Data constraint |
|---|---|---|---|---|---|---|---|
| support-chat | sonnet-tier | other-provider mid-tier | eval >= 0.85 | 1.5 s | 20 s | $0.02 | EU only |
| ticket-tagging | haiku-tier | none (queue + retry later) | macro-F1 >= 0.9 | n/a | 10 s | $0.001 | none |

Background routes often need no fallback at all: a queue with delayed retry is cheaper and keeps one model’s behavior.

3. Error-to-action table

SignalAction
429 with retry-afterIf retry-after fits in the remaining deadline: wait and retry same model. Otherwise fall back
429 without retry-after and a spend-cap code (Anthropic error.details.error_code: enforced_spend_limit_reached)Do not retry; it fails until the cap resets. Fall back or fail fast and alert
529 overloaded_error (Anthropic), 503Retry once with jittered backoff, then fall back
500 / 502 / 504Retry once, then fall back
Connection reset, DNS, TLS errorsRetry once, then fall back
Per-attempt timeout (no first token within TTFT budget)Cancel the request, fall back; do not wait for the slow one
400 context length exceededRoute to a larger-context model or trim context; never retry as-is
400 / 401 / 403 / 404 / 413 otherFail, log, alert. No retry, no fallback
Refusal or content-policy stopTreat as a product decision: show the refusal path, or use an explicit policy fallback route that is reviewed, never a silent provider swap
Mid-stream error after tokens were sentFail visibly with a retry button; log as partial

Read rate-limit headers to slow down before you hit 429: Anthropic returns anthropic-ratelimit-requests-remaining, anthropic-ratelimit-input-tokens-remaining, anthropic-ratelimit-output-tokens-remaining and matching -reset timestamps; OpenAI returns x-ratelimit-remaining-requests and x-ratelimit-remaining-tokens.

4. Tiering by difficulty

Default: one model per route, chosen by eval. Add dynamic tiering only when a route has both cheap and hard traffic and volume makes the saving matter.

PatternHowWhen
Static per routeConfig maps route -> modelDefault
Rule-basedLength, attachment count, user plan, keyword -> tierSignals are obvious and cheap
CascadeCheap model answers with a self-check or validator; escalate on failureOutput is verifiable (schema, tests, citations)
Classifier routerSmall model or embedding classifier picks the tierHigh volume, labeled examples of hard vs easy

Measure a cascade on the eval set: escalation rate, quality of the cheap tier on the traffic it keeps, and total cost including the wasted cheap calls. If more than about half of traffic escalates, the cascade costs more than calling the strong model directly.

5. Deadlines

  • Set two timeouts for streaming routes: time to first token (the gateway or client aborts if no chunk arrives) and total time. LiteLLM calls these stream_timeout and timeout per deployment.
  • Derive per-attempt timeouts from the user-facing budget: attempts x per_attempt_timeout + backoff <= total budget. A 30 s budget with a 60 s SDK timeout means the fallback never runs.
  • Propagate the remaining deadline into each attempt instead of restarting the clock.

6. Retry budget and cooldowns

  • Per request: at most 2 attempts on the same model plus one fallback, all inside the deadline.
  • Global: cap retries at a fixed share of traffic (10% is a common starting point) so a provider incident does not double your load on it. Reject retries once the budget is spent.
  • Cooldown: after N failures within a window, take a deployment out of rotation for a period (LiteLLM allowed_fails and cooldown_time). Add jitter so all workers do not return at once.
  • Long generations that time out are expensive to retry; prefer streaming and a TTFT timeout so failure is detected in seconds, not minutes.

For general retry and circuit-breaker mechanics beyond LLM calls, see resilience-patterns.

7. Implement

In-app router (Python, provider-agnostic sketch):

import random, time

RETRYABLE = {408, 429, 500, 502, 503, 504, 529}

class RetryBudget:
    def __init__(self, ratio=0.1, floor=10):
        self.requests = 0; self.retries = 0; self.ratio = ratio; self.floor = floor
    def allow(self):
        return self.retries < max(self.floor, self.ratio * self.requests)

budget = RetryBudget()

def call_route(route, payload, deadline_s):
    start = time.monotonic()
    budget.requests += 1
    for i, target in enumerate(route.targets):          # primary first, then fallbacks
        for attempt in range(route.max_attempts_per_target):
            remaining = deadline_s - (time.monotonic() - start)
            if remaining <= 0.5:
                raise TimeoutError(f"{route.name}: deadline exhausted")
            if (i or attempt) and not budget.allow():
                raise RuntimeError(f"{route.name}: retry budget exhausted")
            if i or attempt:
                budget.retries += 1
            try:
                result = target.call(payload, timeout=min(target.timeout_s, remaining))
                log_attempt(route.name, target.model, "ok", result.usage, start)
                return result
            except ProviderError as e:                   # your normalized error type
                log_attempt(route.name, target.model, e.status, None, start)
                if e.spend_cap or e.status not in RETRYABLE:
                    if e.status in (400, 401, 403, 404, 413) and not e.context_length:
                        raise                               # deterministic: no fallback
                    break                                   # skip to next target
                wait = e.retry_after or random.uniform(0, 2 ** attempt)
                if wait > remaining - 1:
                    break                                   # fall back instead of waiting
                time.sleep(wait)
    raise RuntimeError(f"{route.name}: all targets failed")

LiteLLM proxy (config.yaml): keys verified against the LiteLLM docs; model IDs are placeholders to fill from each provider’s current model list.

model_list:
  - model_name: chat-primary            # alias your app calls
    litellm_params:
      model: anthropic/<model-id>
      api_key: os.environ/ANTHROPIC_API_KEY
      timeout: 30                       # whole response, seconds
      stream_timeout: 3                 # first chunk, seconds
  - model_name: chat-fallback
    litellm_params:
      model: openai/<model-id>
      api_key: os.environ/OPENAI_API_KEY
      timeout: 30
      stream_timeout: 3

router_settings:
  routing_strategy: simple-shuffle      # default; spreads load across same-alias deployments
  num_retries: 1
  allowed_fails: 3
  cooldown_time: 30
  enable_pre_call_checks: true          # needed for context-window checks
  fallbacks: [{"chat-primary": ["chat-fallback"]}]
  context_window_fallbacks: [{"chat-primary": ["chat-fallback"]}]

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY

Run with litellm --config config.yaml (or the official Docker image) and point clients at it with the OpenAI-compatible SDK. retry_policy can set retry counts per exception type (RateLimitErrorRetries, TimeoutErrorRetries, and others). Set per-key or per-team budgets through virtual keys when several teams share the proxy.

Managed gateways: OpenRouter accepts a models array in priority order and bills the model that actually served (read the response model field). Cloudflare AI Gateway supports per-request timeouts, retries and fallbacks. For any managed gateway, confirm in its current docs: which errors trigger fallback, whether logs store prompts, and how provider keys are held.

8. Prove it

  1. Fault injection in staging: point the primary at a stub returning 429 (with and without retry-after), 529, 500, a 10 s stall before first byte, and a mid-stream disconnect. Confirm each produces the action in the table, inside the deadline.
  2. Run the eval on every fallback target for every route that uses it.
  3. Run the route report on a day of attempt logs:
python3 scripts/route_report.py attempts.jsonl --prices prices.json \
  --max-error-rate 0.01 --max-p95-ms 4000

It prints per route: final error rate, retry and fallback rates, attempts per request (retry amplification), p50/p95 end-to-end latency, p95 TTFT, cost per request, and which models served traffic. Exit code 1 means a route breached a threshold.

Gotchas

  • Prompt caching is per provider and usually per model. Failing over moves traffic onto a cold cache, so the fallback can cost several times more per request; budget for it.
  • Tokenizers and context windows differ. A prompt that fits the primary can 400 on the fallback; run pre-call token checks or trim per target.
  • Tool schemas, JSON modes and stop reasons differ across providers. Normalize them in the router and test tool-calling routes against each target.
  • “OpenAI-compatible” endpoints accept the request shape but may drop provider-only parameters silently. Diff the responses, not just the status codes.
  • Fallback onto a provider without your data-processing or zero-retention agreement is a compliance incident. Encode allowed providers per route.
  • A fallback that serves 30% of traffic for a week is a migration nobody approved. Alert when the fallback rate exceeds a few percent for more than an hour.
  • Latency-based routing chases noise at low volume; keep simple-shuffle until each deployment sees steady traffic.

Output contract

## Routing table
| Route | Primary | Fallbacks (eval score) | TTFT / total budget | Retry policy | Cost ceiling |
## Error-to-action table
## Implementation: in-app | LiteLLM | managed (why)
## Fault-injection results (scenario -> observed action -> within deadline?)
## Route report (scripts/route_report.py output)

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/route_report.py 154 lines · 6.3 KB
#!/usr/bin/env python3
"""Per-route reliability, latency and cost report from LLM attempt logs (JSONL).

One JSON object per line, one line per *attempt* (retries and fallbacks are separate lines):

    {"request_id": "r1", "route": "support-chat", "attempt": 1, "model": "primary-model",
     "status": "429", "latency_ms": 180, "input_tokens": 0, "output_tokens": 0}
    {"request_id": "r1", "route": "support-chat", "attempt": 2, "model": "fallback-model",
     "status": "ok", "latency_ms": 2100, "ttft_ms": 640, "input_tokens": 1800,
     "output_tokens": 320, "cost_usd": 0.0061}

Required keys: request_id, route, attempt, model, status ("ok" or an error code/name),
latency_ms. Optional: ttft_ms, input_tokens, output_tokens, cost_usd.
If cost_usd is missing, pass --prices prices.json mapping model -> [input $/MTok, output $/MTok].

Usage:
    python3 route_report.py attempts.jsonl
    python3 route_report.py attempts.jsonl --prices prices.json --max-error-rate 0.01 --max-p95-ms 4000

Exit 0 when every route is within thresholds, 1 when any route breaches one, 2 on bad input.
"""
import argparse
import json
import sys
from collections import defaultdict


def pct(values, p):
    if not values:
        return None
    s = sorted(values)
    k = max(0, min(len(s) - 1, int(round(p / 100 * (len(s) - 1)))))
    return s[k]


def fmt_ms(v):
    return "-" if v is None else f"{v:,.0f}"


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("log", help="JSONL file of attempts")
    ap.add_argument("--prices", help="JSON file: {model: [input_usd_per_mtok, output_usd_per_mtok]}")
    ap.add_argument("--max-error-rate", type=float, default=None, help="fail if final error rate exceeds this (0-1)")
    ap.add_argument("--max-p95-ms", type=float, default=None, help="fail if end-to-end p95 exceeds this")
    args = ap.parse_args()

    prices = {}
    if args.prices:
        try:
            with open(args.prices) as f:
                prices = json.load(f)
        except (OSError, json.JSONDecodeError) as e:
            print(f"error: cannot read prices: {e}", file=sys.stderr)
            return 2

    requests = defaultdict(list)  # (route, request_id) -> attempts
    try:
        with open(args.log) as f:
            for n, line in enumerate(f, 1):
                line = line.strip()
                if not line:
                    continue
                try:
                    a = json.loads(line)
                    key = (a["route"], a["request_id"])
                    a["attempt"] = int(a["attempt"])
                    a["latency_ms"] = float(a["latency_ms"])
                    a["status"] = str(a["status"])
                    a["model"] = str(a["model"])
                except (json.JSONDecodeError, KeyError, ValueError, TypeError) as e:
                    print(f"error: line {n}: {e}", file=sys.stderr)
                    return 2
                requests[key].append(a)
    except OSError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if not requests:
        print("error: no attempts found", file=sys.stderr)
        return 2

    def cost(a):
        if "cost_usd" in a:
            return float(a["cost_usd"])
        p = prices.get(a["model"])
        if not p:
            return None
        return (a.get("input_tokens", 0) * p[0] + a.get("output_tokens", 0) * p[1]) / 1_000_000

    routes = defaultdict(lambda: {"req": 0, "att": 0, "ok": 0, "fallback": 0, "retried": 0,
                                  "lat": [], "ttft": [], "cost": 0.0, "cost_unknown": 0,
                                  "errors": defaultdict(int), "served": defaultdict(int)})
    for (route, _rid), atts in requests.items():
        atts.sort(key=lambda a: a["attempt"])
        r = routes[route]
        r["req"] += 1
        r["att"] += len(atts)
        primary = atts[0]["model"]
        final = atts[-1]
        r["lat"].append(sum(a["latency_ms"] for a in atts))
        if len(atts) > 1:
            r["retried"] += 1
        for a in atts:
            if a["status"] != "ok":
                r["errors"][a["status"]] += 1
            c = cost(a)
            if c is None:
                r["cost_unknown"] += 1
            else:
                r["cost"] += c
        if final["status"] == "ok":
            r["ok"] += 1
            r["served"][final["model"]] += 1
            if "ttft_ms" in final:
                r["ttft"].append(float(final["ttft_ms"]) + sum(a["latency_ms"] for a in atts[:-1]))
            if final["model"] != primary:
                r["fallback"] += 1

    breached = False
    print(f"{'route':<22} {'reqs':>6} {'err%':>6} {'retry%':>7} {'fallbk%':>8} {'amp':>5} "
          f"{'p50ms':>7} {'p95ms':>7} {'ttft95':>7} {'$/req':>9}")
    for route in sorted(routes):
        r = routes[route]
        err = 1 - r["ok"] / r["req"]
        p95 = pct(r["lat"], 95)
        per_req = r["cost"] / r["req"]
        flag = ""
        if args.max_error_rate is not None and err > args.max_error_rate:
            flag += " ERROR-RATE"
        if args.max_p95_ms is not None and p95 is not None and p95 > args.max_p95_ms:
            flag += " P95"
        breached |= bool(flag)
        print(f"{route:<22} {r['req']:>6} {err*100:>5.1f}% {r['retried']/r['req']*100:>6.1f}% "
              f"{r['fallback']/r['req']*100:>7.1f}% {r['att']/r['req']:>5.2f} "
              f"{fmt_ms(pct(r['lat'], 50)):>7} {fmt_ms(p95):>7} {fmt_ms(pct(r['ttft'], 95)):>7} "
              f"{per_req:>9.5f}{flag}")
        if r["errors"]:
            top = ", ".join(f"{k}x{v}" for k, v in sorted(r["errors"].items(), key=lambda kv: -kv[1])[:5])
            print(f"{'':<22} attempt errors: {top}")
        served = ", ".join(f"{m} {v/r['ok']*100:.0f}%" for m, v in sorted(r["served"].items(), key=lambda kv: -kv[1])) if r["ok"] else "-"
        print(f"{'':<22} served by: {served}")
        if r["cost_unknown"]:
            print(f"{'':<22} warning: {r['cost_unknown']} attempts had no cost (add cost_usd or --prices)")

    print("\namp = attempts per request (retry amplification); 1.00 means no retries or fallbacks.")
    if breached:
        print("RESULT: at least one route breached a threshold")
        return 1
    print("RESULT: all routes within thresholds" if (args.max_error_rate or args.max_p95_ms) else "RESULT: report only (no thresholds set)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc