AI engineering

LLM Model Routing: Send Easy Requests to a Cheaper Model

Build a simple Python router that sends each request to a small or large Claude model, then measure cost and quality against an eval set before you ship it.

By EpiFive Editorial TeamReviewed/updated 12 min readHow we check articles
On this page

A model router picks a model per request. Easy requests such as classification, extraction and short rewrites go to a small, cheap model, and difficult ones go to a larger model. The saving comes from the price gap between models, and the risk is quality loss on requests the router misjudges. So build the simplest router that works, measure it against each model alone on a labeled eval set, and ship it only if the quality drop is inside a margin you chose in advance.

Who this is for and what you will have at the end

This guide is for developers running an LLM feature in production or about to, who see a growing bill and wonder whether every request needs the strongest model. At the end you will have a working Python router for the Claude API, a cost calculator that uses logged token usage, a cascade option, and an eval script that compares three setups: small model only, large model only and routed. If you have no eval set yet, start with getting started with LLM evals and golden datasets for regression testing.

The code uses the anthropic Python SDK and model IDs and prices from Anthropic's documentation as checked on 30 October 2026. It was not run against the live API while writing this article, so test it with your own key and a small sample first.

When routing is worth it

Routing pays off when three things are true:

  1. You have a mix of request difficulty. If all your requests are hard, a router has nothing to save.
  2. Volume is high enough. The engineering and evaluation effort has a fixed cost. At low volume, picking one model is cheaper than building a router.
  3. Quality is measurable. Without an eval you cannot tell whether the cheap model is good enough on a given class of request.

Anthropic's models overview describes the smallest current model, Claude Haiku 5.5, as intended for high-volume, latency-sensitive tasks such as classification, extraction and routing, and describes Claude Sonnet 5.5 as the best combination of speed and intelligence. That matches how most routers divide work.

What the models cost

Prices from Anthropic's pricing page on 30 October 2026, in USD per million tokens (MTok):

ModelAPI IDInputOutput
Claude Haiku 5.5 (prompts up to 100,000 tokens)claude-haiku-5-5$0.10$0.50
Claude Haiku 5.5 (prompts over 100,000 tokens)claude-haiku-5-5$0.50$2.50
Claude Sonnet 5.5claude-sonnet-5-5$2$10
Claude Opus 5.5claude-opus-5-5$4$20
Claude Fable 5.1claude-fable-5-1$10$50

Two details matter for routing. First, Haiku 5.5 has a higher price for prompts over 100,000 tokens, so very long inputs lose part of the advantage. Second, batch requests are 50 percent off and prompt cache reads cost less than base input, so routing is one of several levers. For caching, see prompt caching cost and latency. Prices change, so read the current table before you rely on these numbers.

Using these prices, a request with 2,000 input tokens and 500 output tokens costs:

  • Haiku 5.5: 2,000 x $0.10 / 1,000,000 + 500 x $0.50 / 1,000,000 = $0.0002 + $0.00025 = $0.00045
  • Sonnet 5.5: 2,000 x $2 / 1,000,000 + 500 x $10 / 1,000,000 = $0.004 + $0.005 = $0.009

In that example the large model costs 20 times as much. If a router correctly handles 60 percent of traffic on the small model, the blended cost falls substantially. The exact saving depends on your mix, so measure it.

Remember that the thinking and effort settings also change the number of output tokens a model uses, and the default effort differs by model. Check the effort and thinking behavior in the documentation and set it explicitly for fair comparisons.

Design decisions before code

Write down these four things first.

  1. The quality metric. For example: the share of eval cases where the answer is judged correct, or where the JSON validates and matches the expected labels.
  2. The tolerance. For example: the router may lose no more than two percentage points of pass rate against the large model alone. Choose it before you see results, so you do not move the goalposts afterward.
  3. The route set. Two tiers (small and large) is enough to start. A third tier adds evaluation work.
  4. The routing signal. What do you know about a request before you call a model? Task type, input length, presence of code, required output format, customer tier and whether a previous attempt failed are all cheap signals.

Step 1: A rule-based router

Start with rules. They cost nothing per request, are easy to explain and are easy to test.

from dataclasses import dataclass
 
SMALL = "claude-haiku-5-5"
LARGE = "claude-sonnet-5-5"
 
# Task types that your application already knows when it builds the request
SIMPLE_TASKS = {"classify", "extract", "tag", "short_rewrite"}
 
 
@dataclass
class Request:
    task: str
    prompt: str
    needs_reasoning: bool = False
 
 
def route(req: Request) -> str:
    # Hard signals first: anything that needs multi-step reasoning goes large
    if req.needs_reasoning:
        return LARGE
    # Very long prompts: small-model price advantage shrinks above 100k tokens,
    # and long context is where quality differences tend to show
    if len(req.prompt) > 200_000:  # characters, a rough proxy for tokens
        return LARGE
    if req.task in SIMPLE_TASKS:
        return SMALL
    return LARGE

Key points:

  • The default is the large model. A request goes to the small model only when it matches a rule you trust. This keeps mistakes expensive in money, not in quality.
  • The 200,000 character threshold is a rough proxy for tokens. Count real tokens if the threshold matters to you, and see token counting and context window limits.
  • Task labels come from your application, not from the model. That is the cheapest and most reliable signal.

Step 2: Call the model and record usage

Every response reports token usage, which you use for cost tracking. The SDK call shape is the same for every model.

import time
import anthropic
 
client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment
 
# USD per million tokens, from the Anthropic pricing page (30 Oct 2026)
PRICES = {
    "claude-haiku-5-5": {"in": 0.10, "out": 0.50},
    "claude-sonnet-5-5": {"in": 2.00, "out": 10.00},
}
 
 
def cost_usd(model: str, input_tokens: int, output_tokens: int) -> float:
    p = PRICES[model]
    return (input_tokens * p["in"] + output_tokens * p["out"]) / 1_000_000
 
 
def call(model: str, system: str, prompt: str, max_tokens: int = 1024) -> dict:
    start = time.perf_counter()
    msg = client.messages.create(
        model=model,
        max_tokens=max_tokens,
        system=system,
        messages=[{"role": "user", "content": prompt}],
    )
    text = "".join(b.text for b in msg.content if b.type == "text")
    return {
        "model": model,
        "text": text,
        "stop_reason": msg.stop_reason,
        "input_tokens": msg.usage.input_tokens,
        "output_tokens": msg.usage.output_tokens,
        "cost": cost_usd(model, msg.usage.input_tokens, msg.usage.output_tokens),
        "latency_s": time.perf_counter() - start,
    }

Two cautions. The cost function ignores cache pricing and the over-100,000-token Haiku price, so extend it if you use caching or long prompts. And always check stop_reason: a value of max_tokens means the answer was cut off, which is a failure you should count.

Step 3: An optional cascade

A cascade tries the small model and escalates if a cheap check fails. It works best when you can verify an output mechanically, for example that it is valid JSON with the required keys.

import json
 
 
def valid_ticket(text: str) -> bool:
    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        return False
    return isinstance(data, dict) and {"category", "priority"} <= data.keys()
 
 
def cascade(system: str, prompt: str) -> dict:
    first = call(SMALL, system, prompt)
    if first["stop_reason"] != "max_tokens" and valid_ticket(first["text"]):
        first["escalated"] = False
        return first
    second = call(LARGE, system, prompt)
    second["escalated"] = True
    # Count both calls: the failed attempt still cost money and time
    second["cost"] += first["cost"]
    second["latency_s"] += first["latency_s"]
    return second

The cascade's total cost on an escalated request is the sum of both calls, and its latency is also the sum. Track the escalation rate. If more than a modest share of requests escalate, the small model is wrong for that task and a rule that sends it straight to the large model is cheaper.

A validity check does not prove the answer is correct, only well-formed. For tasks where correctness matters, pair the cascade with an eval that compares labels.

Step 4: A classifier router (only if rules are not enough)

If you cannot tell difficulty from task labels, you can ask the small model to judge it. This adds a call to every request, so the savings must outweigh it.

ROUTER_SYSTEM = (
    "You decide whether a request needs a strong reasoning model. "
    "Answer with exactly one word: SIMPLE or COMPLEX. "
    "COMPLEX means multi-step reasoning, math, code changes, or ambiguous "
    "instructions. SIMPLE means classification, extraction, or short rewriting."
)
 
 
def llm_route(prompt: str) -> str:
    r = call(SMALL, ROUTER_SYSTEM, prompt[:4000], max_tokens=256)
    return SMALL if r["text"].strip().upper().startswith("SIMPLE") else LARGE

Notes:

  • The prompt is truncated for the router call to cap its cost.
  • Any unclear answer falls to the large model, so errors lean toward quality.
  • The router call has its own cost and latency, so add it to your totals when you evaluate.

Step 5: Evaluate the router

The evaluation compares three setups on the same labeled cases: small only, large only and routed. Build the dataset from real requests, with an expected answer for each, and include both easy and hard cases in realistic proportions.

import csv
import json
import statistics
 
 
def grade(case: dict, text: str) -> bool:
    # Replace with your own check: exact label match, schema validation,
    # or an LLM judge for open-ended answers
    return text.strip().lower() == case["expected"].strip().lower()
 
 
def run(setup: str, cases: list[dict], system: str) -> dict:
    passed, costs, lats = 0, [], []
    for case in cases:
        req = Request(task=case["task"], prompt=case["prompt"],
                      needs_reasoning=case.get("needs_reasoning", False))
        model = {"small": SMALL, "large": LARGE}.get(setup) or route(req)
        r = call(model, system, case["prompt"])
        passed += grade(case, r["text"])
        costs.append(r["cost"])
        lats.append(r["latency_s"])
    n = len(cases)
    return {
        "setup": setup,
        "pass_rate": passed / n,
        "cost_per_request": sum(costs) / n,
        "p50_latency_s": statistics.median(lats),
    }
 
 
if __name__ == "__main__":
    with open("eval_cases.jsonl") as f:
        cases = [json.loads(line) for line in f]
    system = "Answer with the label only."
    rows = [run(s, cases, system) for s in ("small", "large", "routed")]
    with open("routing_report.csv", "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=rows[0].keys())
        w.writeheader()
        w.writerows(rows)
    for row in rows:
        print(row)

Each line of eval_cases.jsonl is a JSON object like this:

{"task": "classify", "prompt": "Customer says the invoice total is wrong.", "expected": "billing"}

Reading the report

ResultWhat it meansWhat to do
Routed pass rate within tolerance of large, cost lowerThe router worksShip it behind a flag and monitor
Routed pass rate well below largeToo many hard cases on the small modelTighten the rules or add a cascade
Small alone matches large on a taskThat task does not need routingSend it to small directly
Routed cost close to largeFew requests qualify for the small modelRouting may not be worth the complexity

Break the results down by task type. An overall average can hide a task where the small model fails badly. Also look at the individual cases where the routed result fails but the large model passes. Those are the router's mistakes, and they tell you which rule to change.

Use enough cases that the difference is meaningful. A handful of cases cannot show a two-point gap, so expand the set before you rely on a tolerance that small. Because model output varies between runs, repeat the evaluation and look at the spread.

Step 6: Roll out and monitor

  1. Log every request with the route chosen, model, token counts, cost, latency, stop reason and whether the output passed any automatic check.
  2. Ship behind a flag and start with a small share of traffic, or route only one task type first.
  3. Sample outputs for review. Periodically pull routed outputs, especially those from the small model, and grade them against the large model's output on the same input.
  4. Watch the escalation rate if you use a cascade, and the share of requests going to each model.
  5. Re-run the eval when you change a prompt, a rule or a model. Anthropic publishes retirement dates for each model in its models overview, so plan for updates. For example, the page lists Claude Haiku 5.5 as not retiring sooner than October 7, 2027.

Pitfalls

  • Comparing models with different effort or thinking settings. The default effort differs by model. Set it explicitly so you compare like with like.
  • Ignoring output tokens. Output is priced higher than input, and a model that writes longer answers can erase a price advantage. Compare cost per request, not price per token.
  • Routing on a signal you cannot trust. If task labels are wrong or users can influence them, the router will send hard requests to the small model.
  • Forgetting the router's own cost. A classifier call on every request adds to your bill and latency.
  • Judging only on average. Check the worst task type, and any request class with high business impact.
  • Treating a validity check as correctness. Valid JSON can still be the wrong label.

Summary checklist

  1. Confirm there is a mix of easy and hard requests and enough volume to justify routing.
  2. Define the quality metric and tolerance before you build.
  3. Start with rules, default to the large model, and send only trusted request types to the small model.
  4. Log tokens, cost, latency, stop reason and route for every call.
  5. Add a cascade or classifier only if the numbers justify it.
  6. Evaluate small only, large only and routed on the same labeled set, and break results down by task type.
  7. Roll out gradually, sample outputs and re-run the eval when anything changes.

References

Questions people ask

What is LLM model routing?

Model routing means choosing a model for each request instead of using one model for everything. A router sends simple requests to a cheaper, faster model and difficult ones to a more capable model, so you pay the higher price only when it is likely to matter.

How do I know the router is not hurting quality?

Run the same labeled eval set through the router, through the small model alone and through the large model alone. Compare pass rate and cost per request. Ship the router only if its pass rate is within a margin you set in advance of the large model, at a lower cost.

Should the router itself be an LLM call?

It can be, but every routing call adds cost and latency. Start with simple rules such as task type, input length and required output format. Add a small-model classifier only if rules leave too much quality or cost on the table, and measure it like any other component.

What about escalating when the small model fails?

A cascade tries the small model first and escalates to the large model if a check fails, for example the output does not validate against a schema or the model reports low confidence. It protects quality on hard cases at the cost of paying for two calls on those requests.

Claude APILLM RoutingEvalsPython
  1. Getting Started with LLM Evals: A Small-Team Setup with Code

    Build your first LLM eval in an afternoon: write 30 test cases in a JSONL file, grade them with simple code, add an LLM grader for tone, and track the score between prompt changes.

    AI engineering9 min read
  2. Claude Message Batches API: Cut Bulk Job Costs in Python

    Run large asynchronous Claude jobs at 50 percent of standard API prices with the Message Batches API: Python create, poll and results code, limits and fixes.

    AI engineering9 min read
  3. Anthropic Prompt Caching: Cut Cost and Latency on Repeated Context

    Use Claude prompt caching to cut input cost on repeated prompts: cache_control placement, 5-minute and 1-hour TTLs, pricing maths, minimum lengths and fixes.

    AI engineering9 min read
  4. Claude Structured Outputs: Get Valid JSON from the API in Python

    Use output_config with a JSON schema, or messages.parse with Pydantic, to get schema-valid JSON from Claude in Python, plus the schema limits and failure cases to handle.

    AI engineering9 min read
  5. Claude Tool Use in Python: Build a Simple Agent Loop Step by Step

    Build a tool-calling agent loop with the Claude Messages API in Python: define tools, run tool_use blocks, return tool_result blocks and stop safely.

    AI engineering11 min read
  6. LLM API Timeouts, Retries and Rate Limits: A Python Reliability Guide

    Make Claude and OpenAI API calls in Python resilient: set timeouts, retry only the right errors, honor retry-after on 429, and tell spend limits from rate limits.

    AI engineering10 min read