LLM cost engineering

Routing between Haiku, Sonnet and Opus: a decision table that works

Route by task where you know it, by request shape where you do not, and escalate one tier at a time when a check fails. That is the whole policy. The table below is how llm-router implements the shape-based part. It is the open-source starter I distilled from a production routing layer, MIT-licensed. The rest of this post covers what the code does, where it goes wrong, and what to measure.

It is a starter, not a finished library. The classifier, the response cache and whitespace compression are implemented, with unit tests. Router.route() lays out the call path as numbered TODO steps for you to wire to your own client. Install it from the repository, not by package name:

git clone https://github.com/Narayanaraj/llm-router-starter
cd llm-router-starter
pip install -e ".[dev]"
pytest

The decision table

The classifier works on an estimate of the size of the system prompt and user prompt together, at 4 characters per token, with two thresholds: 200 tokens and 2,000 tokens.

Request What the code checks Tier The assumption behind it
Short and plain 200 estimated tokens or fewer, no multi-step marker, no code Haiku Short questions and short summaries do not need more
Long 2,000 estimated tokens or more Opus Long inputs are where smaller models lose detail
Asks for reasoning Any multi-step marker, at any length Opus Multi-step reasoning is what the top tier is for
Contains code A fenced code block, or an inline code span of 20 characters or more Sonnet Code needs more than the smallest tier, not always the largest
Everything else Between the thresholds, no marker Sonnet The middle tier is the default

The checks run in that order: Haiku first, then Opus, and Sonnet catches the rest. A 10-token prompt that says "prove" goes to Opus. A 150-token prompt with a code block goes to Sonnet, unless it also asks for step-by-step reasoning.

The production layer this came from split by task rather than by shape: Haiku for classification, Sonnet for the middle, Opus only where output quality justified it. A library cannot see your tasks, so it approximates them with shape.

What the classifier looks at

Three signals, all deterministic, so classifying a request costs no model call. The reasoning markers are regular expressions:

MULTISTEP_MARKERS = [
    r"\bstep[- ]?by[- ]?step\b",
    r"\bfirst.{0,40}then\b",
    r"\bexplain (your )?reasoning\b",
    r"\bprove\b",
    r"\bderive\b",
    r"\banalyze (in )?(detail|depth)\b",
    r"\bcompare and contrast\b",
]

All three are computed over the system prompt and the user prompt together:

full_text = f"{system or ''}\n{prompt}".strip()
token_estimate = self._estimate_tokens(full_text)

has_multistep = bool(self._multistep_re.search(full_text))
has_code = bool(CODE_BLOCK_PATTERN.search(full_text))

The size signal is the crude one: len(text) // 4. Its docstring says to replace it for production. For Claude, the token counting endpoint returns the real count, and the thresholds are only as good as the count behind them.

Where the heuristic goes wrong

The repository ships 10 sample prompts in benchmarks/prompts.json, each labeled with an expected complexity. Run the classifier over them:

import json
from llm_router.classifier import Classifier

c = Classifier()
for p in json.load(open("benchmarks/prompts.json")):
    r = c.classify(p["prompt"], p["system"])
    print(p["id"], p["expected_complexity"], "->", r.complexity)

5 of the 10 match their label. The misses are the useful part:

A disagreement is not automatically a failure. Whether Haiku writes a good one-sentence summary is a question for evaluation, not for labeling, and Is the cheaper model good enough covers how to answer it before the route takes traffic. The system-prompt case is different: it is a flaw in the approach, and the fix is to classify the user turn only.

Route by task first

The strongest routing signal is not in the text. A call site that sorts a message into one of 6 intents, for example, knows what it is doing before any text arrives, and can name its tier in configuration. That is what "Haiku for classification" means in practice. Keep the text heuristics for free-form requests, where the call site cannot know in advance how hard the request is.

Escalation and fallback

In the repository, fallback_chain is an ordered list of model IDs, and the classifier picks one by index. The README's configuration example shows it:

[router]
default_model = "claude-haiku-4-5-20251001"
fallback_chain = ["claude-haiku-4-5-20251001", "claude-sonnet-4-6", "claude-opus-4-6"]
classifier_threshold_simple = 200      # tokens
classifier_threshold_complex = 2000    # tokens

There is no retry or escalation code in the repository yet. The production layer had SLA-aware retries, timeouts, latency budgets and model fallback from Haiku to Sonnet to Opus, chosen per prompt complexity. These are the rules that make escalation safe:

  1. Escalate on a check that failed. A response that fails schema validation, a parser, or a test the calling code runs is evidence. A vague sense that the answer could be better is not.
  2. One tier at a time, with a cap. Haiku to Sonnet, Sonnet to Opus, then stop and return an error the caller can handle.
  3. Keep errors apart from quality. A timeout or a rate-limit error is a reason to retry with backoff, or to fall back for availability, within the latency budget. It is not evidence that the request needed a larger model, so do not count it as a quality escalation.
  4. Spend from a latency budget. Every attempt uses part of the request's budget. When it is gone, fail cleanly.
  5. Log every escalation with its route, tier and reason.

A sketch of rules 1, 2, 4 and 5. It is neither code from the repository nor the production code, and call() is assumed to handle rule 3 with its own retries:

TIERS = ["haiku", "sonnet", "opus"]  # mapped to model IDs in configuration

def answer(request, start_tier, budget_ms):
    deadline = now_ms() + budget_ms
    for tier in TIERS[TIERS.index(start_tier):]:
        remaining = deadline - now_ms()
        if remaining <= 0:
            break
        response = call(tier, request, timeout_ms=remaining)
        if passes_checks(request, response):
            return response
        log_escalation(request.route, tier, response)
    raise EscalationExhausted(request.route)

Escalation is not free. An escalated request pays for the cheap attempt and the expensive one. Here is an example with made-up traffic: say a route starts on Haiku and a share r of its requests escalate to Sonnet. With the same token counts on both tiers, the route costs Haiku + r × Sonnet per request, against Sonnet alone for going straight there. The break-even is r = 1 − (Haiku price ÷ Sonnet price). With Claude Haiku 4.5 at half the price of Claude Sonnet 5, that is 50%. Against Claude Sonnet 4.6, at three times Haiku's price, it is about 67%. Above the break-even, route that class of requests to the larger tier from the start. Anthropic's cost guidance describes the same pattern with effort levels: run at a low setting, and re-run only the failures at a higher one.

The tiers as of 2026-09-19

Tier Current model (API ID) USD per million tokens, input / output
Haiku Claude Haiku 4.5 (claude-haiku-4-5-20251001) 1 / 5
Sonnet Claude Sonnet 5 (claude-sonnet-5) 2 / 10
Opus Claude Opus 5 (claude-opus-5) 5 / 25

Sources: Anthropic's models overview and pricing pages, checked 2026-09-19. The repository's defaults name claude-sonnet-4-6 and claude-opus-4-6, which Anthropic lists as legacy models that are still available, at 3 / 15 and 5 / 25. There is also a tier above Opus, Claude Fable 5.1 at 10 / 50, and the table extends to a fourth row the same way.

Three things move the arithmetic away from list price:

What to measure

Per route, and reviewed after every routing change:

The repository's benchmark defines the comparison to run: an Opus-only baseline without cache or compression, the router alone, and the router with cache and compression. Quality is the cosine similarity between each response and the baseline's response, and the acceptance bar is at least 30% lower cost than the baseline at a similarity of 0.95 or more. run_benchmark.py lays that harness out as numbered steps for you to implement against your own corpus. The 10 sample prompts are a starting point, not a test set.

Similarity to the Opus answer measures sameness, not correctness. A cheaper model can phrase a correct answer differently and score lower, or be wrong in similar words and score higher. For release decisions, grade against checks specific to the task.

What I would do differently