What you'll build: a small Python router that sends each agent call to the cheapest Claude model that can do the job, and escalates to a bigger model only when a validator rejects the cheap answer. It's about 60 lines. The payoff is direct: most agent calls are mechanical — classification, extraction, query rewriting, summarization — and those belong on Haiku 4.5, which is 5x cheaper than Opus 5 on both input and output. The workhorse reasoning goes to Sonnet 5; only the hard or expensive-if-wrong calls start on Opus 5. This implements the decision you (hopefully) already made in Opus 5 vs Sonnet 5 vs Haiku 4.5: which Claude model for the job — turning that per-task judgment into code.

The cost logic#

The tiering wins because the price spread is large and most calls are cheap work. Here are the rates, per million tokens (Mtok), as of August 2026 — verify current pricing before you rely on them:

ModelIDInputOutput
Haiku 4.5claude-haiku-4-5$1$5
Sonnet 5claude-sonnet-5$2 (intro)$10 (intro)
Opus 5claude-opus-5$5$25

Sonnet 5's $2/$10 is introductory pricing through August 31, 2026; on September 1 it becomes $3/$15. Keep that in mind — it narrows the Haiku-to-Sonnet gap and is a reason to route deliberately, not reflexively.

Take a typical classification call: ~1,000 input tokens, ~50 output tokens. The arithmetic below is illustrative — derived from the rate table, not measured:

Haiku is 5x cheaper than Opus for the same call. Run a million such calls and that's $1,250 on Haiku versus $6,250 on Opus — an illustrative $5,000 difference on one workload. You don't have to move every call: even shifting the mechanical majority off Opus is where the money is. Prompt caching (cache reads at ~0.1x the base input rate) and the Batch API (50% off input and output) stack on top of whatever tier you land on — see the cross-provider treatment in how to cost-route open and closed models if you're mixing vendors.

Step 1: A tier map#

Keep the routing policy in one place: a dict of task types to models, an ordered list of tiers for escalation, and a starting tier per task. This is the whole policy surface — auditable in one screen.

# router.py
import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

# Cheapest -> most capable. Escalation walks this list left to right.
TIERS = ["haiku", "sonnet", "opus"]

MODELS = {
    "haiku":  "claude-haiku-4-5",
    "sonnet": "claude-sonnet-5",
    "opus":   "claude-opus-5",
}

# Which tier each task type STARTS on. Mechanical work starts cheap.
TASK_TIERS = {
    "classify":  "haiku",
    "extract":   "haiku",
    "rewrite":   "haiku",   # query rewriting, normalization
    "summarize": "haiku",
    "reason":    "sonnet",  # the workhorse: multi-step reasoning, tool use
    "tool_use":  "sonnet",
    "plan":      "opus",    # hard, or expensive if wrong
}

DEFAULT_TIER = "sonnet"

Unknown task types fall through to sonnet — a safe middle default. When Sonnet's price rises on September 1, this map is the one place you'd revisit whether a given task type still earns a cheaper start.

Step 2: The route() function#

route() is a thin wrapper around client.messages.create. It picks the model from the task type (or an explicit tier override for escalation) and returns both the response and the tier it used.

def route(task_type, messages, *, max_tokens=1024, system=None, tier=None):
    """Send one call to the tier for this task type (or an explicit tier)."""
    tier = tier or TASK_TIERS.get(task_type, DEFAULT_TIER)
    params = {
        "model": MODELS[tier],
        "max_tokens": max_tokens,
        "messages": messages,
    }
    if system is not None:
        params["system"] = system
    resp = client.messages.create(**params)
    return resp, tier

That's a complete, working router already. A classification call routes to Haiku with no extra thought at the call site:

resp, tier = route(
    "classify",
    [{"role": "user", "content": "Is this ticket a bug or a feature request? 'App crashes on export.'"}],
    max_tokens=16,
)
print(tier, resp.content[0].text)  # -> haiku "bug"

The caller declares what kind of work this is; the map decides which model runs it. That separation is the whole point — swap the policy without touching call sites.

Step 3: Confidence-based escalation#

Cheap tiers are cheap because they're occasionally wrong. The fix isn't to distrust them wholesale — it's to check the answer and only pay for a bigger model when the check fails. Write a validator that returns (ok, value_or_reason), then loop up the tiers.

Here's a validator for structured extraction that fails on unparseable JSON, a missing required field, or a low self-reported confidence:

import json

def json_validator(required_fields, min_confidence=0.0):
    def _validate(text):
        try:
            data = json.loads(text)
        except json.JSONDecodeError as e:
            return False, f"invalid JSON: {e}"
        for field in required_fields:
            if field not in data:
                return False, f"missing field: {field}"
        if data.get("confidence", 1.0) < min_confidence:
            return False, f"low confidence: {data.get('confidence')}"
        return True, data
    return _validate

The escalation loop starts on the task's tier and walks up TIERS until the validator accepts an answer:

def escalate(task_type, messages, validator, *,
             max_tokens=1024, system=None, start_tier=None):
    start = start_tier or TASK_TIERS.get(task_type, DEFAULT_TIER)
    last_reason = None
    for tier in TIERS[TIERS.index(start):]:
        resp, _ = route(task_type, messages,
                        max_tokens=max_tokens, system=system, tier=tier)
        text = "".join(b.text for b in resp.content if b.type == "text")
        log_usage(resp, tier)  # defined in Step 4
        ok, result = validator(text)
        if ok:
            return result, tier
        last_reason = result
    raise RuntimeError(f"all tiers failed validation: {last_reason}")

To make the confidence check meaningful, ask the model to report it. A system prompt like "Return only JSON: {\"category\": ..., \"confidence\": 0.0-1.0}. Set confidence below 0.7 if the input is ambiguous." gives the cheap tier a way to self-flag the calls it isn't sure about — those are exactly the ones worth escalating:

result, tier = escalate(
    "extract",
    [{"role": "user", "content": "Extract category + confidence: 'maybe a refund thing?'"}],
    validator=json_validator(["category", "confidence"], min_confidence=0.7),
    system='Return only JSON: {"category": str, "confidence": float 0-1}.',
    max_tokens=64,
)
print(result, "resolved on", tier)

An ambiguous input gets a low-confidence answer from Haiku, fails the min_confidence=0.7 gate, and retries on Sonnet — automatically, only when needed.

Step 4: Measure the savings#

Routing you can't measure is routing you can't tune. Every response carries response.usage.input_tokens and response.usage.output_tokens; combine those with the price table to log the cost of each call.

# Prices per Mtok, as of August 2026 — verify current pricing.
# Sonnet 5 shown at intro pricing ($2/$10); it rises to $3/$15 on 2026-09-01.
PRICES = {
    "claude-haiku-4-5": {"input": 1.0,  "output": 5.0},
    "claude-sonnet-5":  {"input": 2.0,  "output": 10.0},
    "claude-opus-5":    {"input": 5.0,  "output": 25.0},
}

def cost(model, usage):
    p = PRICES[model]
    return (usage.input_tokens * p["input"]
            + usage.output_tokens * p["output"]) / 1_000_000

def log_usage(resp, tier):
    c = cost(resp.model, resp.usage)
    print(f"[{tier:>6}] {resp.model}  "
          f"in={resp.usage.input_tokens} out={resp.usage.output_tokens} "
          f"${c:.6f}")
    return c

resp.model is the exact model ID the API served, so it always matches the price table even after an escalation. Sum these across a run and you have a real per-tier cost breakdown — and the escalation rate (how often the cheap tier's answer got rejected) tells you whether your validator is too strict or your starting tiers are too optimistic.

Two multipliers stack on top of everything above. Prompt caching bills cache reads at roughly 0.1x the base input rate, so a large stable system prompt reused across calls is ~90% cheaper on the cached portion — check response.usage.cache_read_input_tokens to confirm hits. The Batch API takes 50% off both input and output for non-latency-sensitive work. Route to pick the model, then cache and batch to squeeze the tier you landed on.

When not to route#

Routing is a default, not a law. Skip it when:

Keep the routing rule honest: measure the escalation rate. If most calls on a tier escalate, that tier was the wrong starting point — move it up in TASK_TIERS and stop paying for answers you throw away.

For the per-model judgment behind the tier map — what each model is actually good at, and where the quality cliffs are — start with the companion piece: Opus 5 vs Sonnet 5 vs Haiku 4.5: which Claude model for the job. And if the price ladder itself is new to you, the week's Founders Wire on Anthropic's price ladder has the context.