Tune Claude Sonnet 5 for Cost and Quality: Effort, Prompt Caching and the Batch API — Cesar Ayala
← All posts

Tune Claude Sonnet 5 for Cost and Quality: Effort, Prompt Caching and the Batch API

Sonnet 5 is the value tier at $3/$15 ($2/$10 intro through Aug 31, 2026). Effort defaults to high — dial it down per route. Cache the stable system+tools prefix (reads ~0.1x), batch offline jobs (−50%), and route hard tasks to Opus 4.8, trivial ones to Haiku 4.5. Model your bill at standard pricing.

The bill nobody models: Sonnet 5’s real unit economics

To control Claude Sonnet 5’s bill, tune four independent levers per route — effort (defaults to high), prompt caching (reads at ~0.1x input), the Batch API (−50% offline), and model routing — and model your production cost at the standard $3/$15, not the introductory $2/$10 that ends August 31, 2026. The levers stack. That’s the whole game.

Here’s the thing I keep seeing in production: teams wire up Claude Sonnet 5, ship it, and never once model the bill. The model is brand-new — it launched June 30, 2026 — so let me be straight about what it actually is. Sonnet 5 is the value tier: close to Opus 4.8 in quality at a much lower cost, but it is not the absolute frontier. That’s the honest framing, and it’s also exactly why it’s the model you want as your default workhorse.

The model id is exactly claude-sonnet-5 — a dateless pinned-snapshot format, no date suffix. It carries a 1M-token context and up to 128k max output. Pricing is $3 / $15 per MTok standard, with an introductory $2 / $10 through August 31, 2026 (Anthropic — Introducing Claude Sonnet 5, Models overview). Pilot now at the intro rate — but model your production bill at the standard $3/$15, because the discount is time-bound and your cost projections shouldn’t depend on a price that expires.

And here’s the trap nobody warns you about: on Sonnet 5, output_config.effort defaults to “high”. So the naive integration — messages.create, model id, done — quietly runs every single call at high effort. More thinking, more tool calls, more output tokens, on every route, whether the task needs it or not. You’re overspending and you don’t even see it.

This post is the fix: four independent levers — effort, prompt caching, the Batch API, and routing — to get more quality per peso out of Sonnet 5.

effortDefaults to high — lower it per route
cachingCache reads at ~0.1x input price
Batch API−50% for offline jobs
pricing$2/$10 intro to Aug 31, then $3/$15 standard
routingSonnet 5 default; escalate hard, downshift trivial

The lever map: where your pesos actually go

Before we drill in, here’s the whole map so you can see how the pieces fit:

  1. Effort — defaults high; lower it where the task doesn’t need deep reasoning.
  2. Prompt caching — cache reads cost roughly 0.1x the input price; reuse your stable prefix.
  3. Batch API — 50% off for anything offline the user isn’t waiting on.
  4. Routing — send each task to the right-sized model.

Layered over all four is the time-bound price reality: $2/$10 intro now, $3/$15 standard later.

The important part: these levers are independent and they stack. You don’t pick one. A routed Haiku call, at low effort, inside a batch, with a cached prefix, is the floor of your cost curve. A routed Opus 4.8 call at high effort is the ceiling you reserve for correctness-critical work.

And re-tune per route, not once globally. One more framing that matters: input and output tokens are priced very differently — output at $15/MTok is where bills explode. So your output-heavy routes (long generations, agentic loops) deserve the most scrutiny.

Full price (1.0x)100
Batch −50%50
Cache read ~0.1x10
Intro vs standard output67

Illustrative relative cost on a stable workload — not a benchmark.

Lever 1 — the effort parameter: stop defaulting to high

output_config.effort takes low | medium | high | xhigh | max, and on the Claude API and Claude Code it defaults to high (Effort docs). Higher effort means more thinking, more tool calls, and more output tokens — better quality, but higher cost and latency. Lower effort is terser, cheaper, faster, with fewer tool calls.

Two rungs people miss: xhigh sits between high and max — reserve it for the hardest coding and agentic routes (long-horizon work, heavy tool-calling), and only reach for max when your evals show real headroom above xhigh. Don’t jump straight to max; on most workloads it adds significant cost for small quality gains.

A critical detail for Sonnet 5: it uses adaptive thinking, which is on by default — no thinking config required. Effort is your control for thinking depth: at high, xhigh, and max, Claude almost always thinks; at lower effort it may skip thinking on simple problems. There is no budget_tokens and no temperature / top_p / top_k (passing those returns a 400 invalid_request_error). For the cheapest latency-critical routes you can turn thinking off entirely with thinking: {"type": "disabled"}.

Here’s a real classification/extraction route dialed down to low effort:

import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    output_config={"effort": "low"},   # defaults to "high" — set it explicitly
    # adaptive thinking is on by default; pass {"type": "disabled"} to turn it off
    messages=[
        {
            "role": "user",
            "content": "Classify this support ticket into one of: billing, bug, "
                       "feature_request, other. Reply with only the label.\n\n"
                       "Ticket: My card was charged twice this month.",
        }
    ],
)

print(response.content[0].text)

Reserve max for when correctness matters more than cost, xhigh for the hardest agentic routes, and dial low/medium for trivial or latency-sensitive ones. And do not let a chat classifier and an agentic loop share one effort setting — re-tune per route.

effort: low

  • Cheaper per call
  • Faster / lower latency
  • Terser output
  • Fewer tool calls
  • Best for classify / extract / latency-sensitive

effort: high / xhigh

  • More thorough reasoning
  • Self-verifying
  • More output tokens
  • More tool calls
  • xhigh for the hardest coding / agentic work

Lever 2 — cache the stable prefix (reads at ~0.1x)

If you send the same system prompt and tool definitions on every request — and most production apps do — you’re paying full input price for bytes that never change. Prompt caching fixes that.

Put cache_control: {"type": "ephemeral"} at the end of the stable prefix. The render order is tools → system → messages, so a single breakpoint on the last system block caches your tools and system together. You get a max of 4 breakpoints. A cache read costs ~0.1x the input price; a write costs 1.25x (5-minute TTL) or 2x (1-hour TTL) (prompt caching deep dive).

One gotcha you must not skip: the minimum cacheable prefix is model-dependent (roughly 1k–4k tokens across models). Confirm Sonnet 5’s exact minimum in the Anthropic docs — do not hardcode a number, because a prefix that’s too short silently won’t cache at all.

import anthropic

client = anthropic.Anthropic()

SYSTEM = "You are a precise CFDI 4.0 fiscal assistant. <... long stable rules ...>"

response = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=2048,
    output_config={"effort": "medium"},   # note: defaults to "high"
    system=[
        {
            "type": "text",
            "text": SYSTEM,
            "cache_control": {"type": "ephemeral"},   # last stable block
        }
    ],
    messages=[
        {"role": "user", "content": "Validate this RFC: GODE561231GR8"}
    ],
)

# Verify the cache is actually working:
print("cache read:", response.usage.cache_read_input_tokens)
print("cache write:", response.usage.cache_creation_input_tokens)

Verify it’s working. Check usage.cache_read_input_tokens. If it’s zero across repeated requests that should share a prefix, a silent invalidator is breaking the byte-for-byte match — a datetime.now() in your system prompt, an unsorted JSON blob, a tool list whose order varies. The architectural rule: freeze the system prompt, keep tools deterministic, and push everything volatile (timestamps, per-request IDs, the user’s question) after the last breakpoint.

Lever 3 — the Batch API for offline jobs (−50%)

This one is free money most teams leave on the table. For any job the user isn’t waiting on in real time, the Message Batches API runs at 50% of standard token prices. You can submit up to 100k requests / 256 MB per batch, results stay available for ~29 days, and most batches finish within an hour (max 24h). It also unlocks up to 300k output tokens with the output-300k-2026-03-24 beta header (vs 128k standard).

import time
import anthropic

client = anthropic.Anthropic()

batch = client.messages.batches.create(
    requests=[
        {
            "custom_id": f"ticket-{i}",
            "params": {
                "model": "claude-sonnet-5",
                "max_tokens": 256,
                "output_config": {"effort": "low"},   # cheap bulk work
                "messages": [
                    {"role": "user", "content": f"Classify: {text}"}
                ],
            },
        }
        for i, text in enumerate(tickets)
    ],
)

# Poll until the batch finishes, then read results.
while True:
    batch = client.messages.batches.retrieve(batch.id)
    if batch.processing_status == "ended":
        break
    time.sleep(30)

for result in client.messages.batches.results(batch.id):
    print(result.custom_id, result.result)

The decision rule is simple: anything the user isn’t watching load — overnight enrichment, bulk classification, backfills — belongs in batch. Calling those synchronously is just paying double. And batch stacks with the other levers: set a low effort inside the batch params and cache the shared prefix, and you compound the savings.

Lever 4 — route by task: Sonnet 5 default, escalate, downshift

Routing is the highest-leverage lever. An Opus-only pipeline is paying frontier prices for value-tier work; a Sonnet-only pipeline burns reasoning on tasks Haiku could do for a fraction.

Make Sonnet 5 the default. Escalate only the hardest tasks to Opus 4.8 (claude-opus-4-8). Downshift trivial calls to Haiku 4.5 (claude-haiku-4-5).

import anthropic

client = anthropic.Anthropic()


def route(task_difficulty: str):
    """Pick model + effort by a task-difficulty signal."""
    if task_difficulty == "trivial":
        return {"model": "claude-haiku-4-5", "output_config": {"effort": "low"}}
    if task_difficulty == "hard":
        return {"model": "claude-opus-4-8", "output_config": {"effort": "high"}}
    return {"model": "claude-sonnet-5", "output_config": {"effort": "medium"}}


cfg = route("trivial")
response = client.messages.create(
    max_tokens=512,
    messages=[{"role": "user", "content": "..."}],
    **cfg,
)

One caveat that bites people: switching models mid-conversation invalidates the prompt cache — caches are model-scoped. So keep one model per loop. If a sub-task needs a cheaper model, spawn a separate subagent for it rather than swapping mid-stream and blowing away your cache.

And the honest note: don’t over-route. Measure before you assume a task “needs” Opus. Most of the time, Sonnet 5 at the right effort is already the answer. See more agent patterns at /ai-agents/ and the Sonnet 5 switch guide.

Production ops: retries, streaming and idempotency so the savings stick

Cheap is worthless if the pipeline falls over. The operational detail the launch posts skip:

Stream any request that may produce long output. Non-streaming requests risk SDK HTTP timeouts above ~16k max_tokens, so Sonnet 5’s 128k output essentially requires streaming. Use client.messages.stream(...) and .get_final_message() for the complete message (Streaming docs).

Branch on stop_reason: end_turn (done), tool_use (run the tool, continue), max_tokens (raise it or stream), pause_turn (a server tool paused — re-send to resume), refusal (a safety decline — handle it, don’t blindly read content).

Use typed exceptions, never string-matching. Catch anthropic.RateLimitError, anthropic.APIError — they expose .status and .type. The official SDKs auto-retry 429 and 5xx with exponential backoff (default max_retries=2).

import anthropic

client = anthropic.Anthropic()

try:
    with client.messages.stream(
        model="claude-sonnet-5",
        max_tokens=64000,
        output_config={"effort": "medium"},
        messages=[{"role": "user", "content": "Write the full migration plan."}],
        extra_headers={"Idempotency-Key": "migration-plan-2026-06-30-001"},
    ) as stream:
        for _ in stream.text_stream:
            pass
        final = stream.get_final_message()

    if final.stop_reason == "refusal":
        handle_refusal(final)
    elif final.stop_reason == "max_tokens":
        ...  # raise max_tokens or continue
except anthropic.RateLimitError as e:
    # 429 — SDK already backed off; at scale, queue + jitter
    print("rate limited:", e.status)
except anthropic.APIError as e:
    print("api error:", e.status, e.type)

Retryable vs not. Retryable: 429 rate_limit_error (honor the retry-after header), 500 api_error, 504 timeout_error (a transient long-request timeout — the cue to stream), and 529 overloaded_error. Not retryable: 400 invalid_request_error, 401 authentication_error, 403 permission_error, 404 not_found_error, 413 request_too_large, and 402 billing_error (a hard config/billing problem — fix it, don’t retry). That 404 not_found_error is the one you’ll hit from a typo’d model id — get a character wrong in claude-sonnet-5 and you get a 404, not a helpful message (Errors reference).

Pass an Idempotency-Key on any request an agent might re-send on a timeout, so a retry doesn’t duplicate work and duplicate charges. And at scale, design for 429s with a queue + jitter even though the SDK backs off for you. More on this in running LLMs in production.

  1. Set effort per routeDon't default to max — low/medium for trivial, high/xhigh for hard
  2. Cache the system+tools prefixReads ~0.1x; confirm Sonnet 5's min cacheable size in docs
  3. Batch the offline jobs−50% on anything the user isn't waiting on
  4. Route by taskHard → Opus 4.8, trivial → Haiku 4.5, default Sonnet 5
  5. Model unit economics at STANDARD $3/$15Intro $2/$10 ends Aug 31, 2026

FAQ

Should I pilot now at $2/$10? Yes — pilot now. But model your production bill at the standard $3/$15; the intro rate ends August 31, 2026.

Why is my cache_read_input_tokens always zero? A silent invalidator in the prefix — a timestamp, unsorted JSON, a varying tool set. Diff the rendered prompt bytes, and confirm the prefix clears Sonnet 5’s model-dependent minimum.

Can I set a thinking budget? No. Sonnet 5 uses adaptive thinking, on by default; budget_tokens and temperature are not accepted. Control depth with output_config.effort, and pass thinking: {"type": "disabled"} to switch thinking off on the cheapest routes.

When do I use xhigh vs max? xhigh is for the hardest coding and agentic work; max only when your evals show measurable headroom above xhigh. For everything else, high (the default) or below.

Is Sonnet 5 as good as Opus 4.8? It’s the value tier — close, at lower cost, not the frontier. Route the hardest tasks to Opus 4.8.

Batch or cache for a big offline job? Both. Batch gets you the −50%; caching reuses the shared prefix. They stack.

What’s the cheapest possible call? Haiku 4.5, low effort, inside a batch, with a cached prefix.

Closing: dial it in per route

The win isn’t one magic setting — it’s tuning effort, caching, batch, and routing per route. Default to Sonnet 5, lower effort where you can, cache the stable prefix, batch the offline work, and escalate to Opus 4.8 only when correctness demands it. Model the bill at the standard $3/$15 and the savings hold up either way — that’s the whole game. For the broader cost picture, see how much it costs to add AI to your app.