●MODEL — Claude Fable 5.1 and Claude Mythos 5.1 landed on September 1. They are the same underlying model; only the strength of the safeguards differs●PRICING — Per-token rates hold at $10/$50 per MTok. What changed is cache reads, cut 75% to $0.25 per MTok●COST — How much that saves depends on your workload: roughly 25% for typical use, up to about 45% for context-heavy agentic work. Worth measuring your own split before quoting a number●BENCH — Terminal-Bench-Science 0.1 climbs from 24.7% on Fable 5 to 52.6%. Anthropic also states a standard error of 3.5-4.5 points, which is worth remembering before reading small gaps as real●SAFEGUARDS — Sharper cyber safeguards cut interventions in Claude Code sessions by roughly 60% on average. Finding vulnerabilities is now allowed; developing exploits still is not●API — New API accounts created from today can no longer edit prior context while preserving Claude's thinking transcript. It is an anti-distillation measure, and existing accounts are unaffected for now●MODEL — Claude Fable 5.1 and Claude Mythos 5.1 landed on September 1. They are the same underlying model; only the strength of the safeguards differs●PRICING — Per-token rates hold at $10/$50 per MTok. What changed is cache reads, cut 75% to $0.25 per MTok●COST — How much that saves depends on your workload: roughly 25% for typical use, up to about 45% for context-heavy agentic work. Worth measuring your own split before quoting a number●BENCH — Terminal-Bench-Science 0.1 climbs from 24.7% on Fable 5 to 52.6%. Anthropic also states a standard error of 3.5-4.5 points, which is worth remembering before reading small gaps as real●SAFEGUARDS — Sharper cyber safeguards cut interventions in Claude Code sessions by roughly 60% on average. Finding vulnerabilities is now allowed; developing exploits still is not●API — New API accounts created from today can no longer edit prior context while preserving Claude's thinking transcript. It is an anti-distillation measure, and existing accounts are unaffected for now
Two hooks that record every model switch and stop the ones you never agreed to
Build a PostModelSwitch hook that logs every model change and a PreModelSwitch hook that blocks unapproved switches during unattended runs. Complete scripts, measured timings, and the reason the two jobs must stay separate.
Last week my weekly usage on a set of unattended scheduled runs came in higher than I expected, so I went looking for the breakdown. My logs had the outcome of every run and the artifacts it produced. What they did not have, anywhere, was a record of when the session changed models.
As an indie developer running four sites on scheduled automation, splitting work across models is the obvious design. Formatting and search-and-replace go to something light; rewriting an article goes to something heavier. The problem was that this split existed as an intention, not as an observation. I had never once written down what actually happened.
Claude Code v2.1.251 added two hook events for exactly this: PreModelSwitch and PostModelSwitch. We are going to build a recorder and a guard — as two separate hooks, on two separate events. The separation is the interesting part, so let's start there.
A usage total cannot be decomposed after the fact
The Claude Code weekly limit is a shared pool across models. The status line JSON exposes rate_limits.seven_day alongside seven_day_opus and seven_day_sonnet, which tells you the pool carries per-model sub-buckets inside a single ceiling.
Practically, that means any run that used a heavier model than intended has already eaten into the room left for everything else. And from September 14 the weekly limit settles at +25% over the pre-promotion baseline — lower than the +50% in effect now, which makes this a reasonable moment to re-measure how the allocation actually behaves.
Re-measuring needs data. Checking /usage afterwards gives you a total, not an attribution. Some information exists only at the instant the switch happens, so the recorder has to be in place first.
What the two events hand you
PreModelSwitch fires immediately before a switch. PostModelSwitch fires after the session's model has changed. Where other hooks receive things like tool_name, these two receive fields that describe the switch itself.
Field
Type
Meaning
from_model
string
Model in use before the switch
to_model
string
Model being switched to
requested_model
string | null
The explicitly requested model; null when the switch was automatic
source
"command" | "picker" | "sdk"
How the switch was triggered: the /model command, the interactive picker, or the SDK
context_tokens
number
Context size at that moment
prompt_cache_warm
boolean
Whether the prompt cache is currently warm
cache_ttl
"5m" | "1h"
Cache lifetime in effect
estimated_cache_write_usd
number
Estimated cost of the cache write, in USD
pricing
"configured" | "catalog" | "default"
Which price table produced that estimate
The usual session_id, transcript_path, and cwd come along too. PostModelSwitch carries the same fields, with two additional possible values for source.
That source field is what lets you tell a human choosing a model at a picker apart from a switch that happened inside an unattended run. That distinction becomes the hinge for the guard.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to explain, after the fact, which unattended run consumed which model instead of guessing from a single usage total
✦You will avoid the failure mode where a heavy step inside PreModelSwitch quietly stops model switching altogether
✦You will be able to fold the cache write cost reported by estimated_cache_write_usd into a switching decision you were making on per-token price alone
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Recording belongs to PostModelSwitch; only the decision belongs to PreModelSwitch
This is the design choice worth making before you write any code. My first attempt put both the logging and the policy check into PreModelSwitch, since standing right before the switch means you can see both sides of it.
That turns out to be the wrong place. When a PreModelSwitch hook is canceled at its timeout, the model switch is blocked. Most hook events fail open on a timeout; this one fails closed.
So a logging step that simply took too long stops the switch from happening at all. You wanted a log line, and instead your model routing silently stopped working — with a cause that is genuinely hard to see from the outside. In an unattended run, nobody is watching the screen when it happens.
Hence the split.
Event
Job
What may live there
PreModelSwitch
Decide whether to block, nothing else
Local file reads and string comparison. No network calls, no aggregation
PostModelSwitch
Record, nothing else
File appends, outbound sends. Slowness here cannot affect a switch that already happened
We want to block switches to unapproved models during unattended runs only. Interactive switches have a human in front of them, so they stay out of scope.
Start with an allowlist. Put one model ID per line in .claude/model-allowlist.txt.
claude-haiku-4-5-20251001claude-sonnet-5
The hook reads JSON on stdin and exits with code 2 when the switch should not proceed. For most hook events, exit code 2 is the only exit code that blocks through the exit code alone.
#!/usr/bin/env bash# .claude/hooks/model-switch-guard.sh# PreModelSwitch: decision only. Nothing heavy goes in here.set -uo pipefailINPUT=$(cat)ALLOW_FILE="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/model-allowlist.txt"# Let everything through unless this is an unattended run.# An interactive switch was somebody's deliberate choice.[[ "$INPUT" == *'"source":"sdk"'* || "$INPUT" == *'"source": "sdk"'* ]] || exit 0# Pull out to_model with parameter expansion — no subprocess.TO="${INPUT#*\"to_model\":}"TO="${TO#*\"}"TO="${TO%%\"*}"# No allowlist means no blocking. A missing config file# should never be the thing that halts your work.[ -f "$ALLOW_FILE" ] || exit 0grep -qxF "$TO" "$ALLOW_FILE" && exit 0echo "Unattended run attempted a switch to an unapproved model: ${TO}" >&2echo "Allowlist: ${ALLOW_FILE}" >&2exit 2
Feeding it three sample payloads:
$ echo '{"to_model":"claude-opus-5","source":"sdk"}' | ./model-switch-guard.shUnattended run attempted a switch to an unapproved model: claude-opus-5Allowlist: /tmp/hooktest/.claude/model-allowlist.txtexit=2$ echo '{"to_model":"claude-sonnet-5","source":"sdk"}' | ./model-switch-guard.shexit=0$ echo '{"to_model":"claude-opus-5","source":"picker"}' | ./model-switch-guard.shexit=0
The third case matters most. A guard that also blocks the picker turns into an obstacle the moment you want to try something by hand.
Why the JSON parsing avoids Python
My first version piped the payload through python3 to read to_model and source. That version is plainly easier to read. Running each variant twenty times on my machine gave these numbers:
Implementation
Per invocation
Parsing with Python
29 ms
Parameter expansion only
7 ms
Almost all of the difference is interpreter startup. Even 29 ms sits nowhere near a timeout, and on those numbers alone the readable version wins. I went with the fast one because of what PreModelSwitch does when it runs out of time: slow means blocked. Leaving headroom now keeps room for a heavier check later.
These are numbers from my environment, not a benchmark you should adopt. Measure your own. What transfers is the shape of the cost — interpreter startup as a fixed overhead on every invocation — not the milliseconds.
Building the recorder
The PostModelSwitch side appends one JSONL line per switch. Nothing here can affect the switch, so readability wins.
#!/usr/bin/env bash# .claude/hooks/model-switch-log.sh# PostModelSwitch: recording only.set -uo pipefailLOG_DIR="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/logs"mkdir -p "$LOG_DIR"export LOG="${LOG_DIR}/model-switch-$(date -u +%Y-%m).jsonl"python3 -c 'import json, sys, os, datetimed = json.load(sys.stdin)row = { "at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "session": d.get("session_id"), "from": d.get("from_model"), "to": d.get("to_model"), "requested": d.get("requested_model"), "source": d.get("source"), "context_tokens": d.get("context_tokens"), "cache_warm": d.get("prompt_cache_warm"), "cache_ttl": d.get("cache_ttl"), "cache_write_usd": d.get("estimated_cache_write_usd"), "pricing": d.get("pricing"),}with open(os.environ["LOG"], "a", encoding="utf-8") as f: f.write(json.dumps(row, ensure_ascii=False) + "\n")' 2>>"${LOG_DIR}/model-switch-error.log"# The switch is already done by now. Do not turn a logging# failure into something that looks like a switching failure.exit 0
That explicit exit 0 is deliberate. For SessionStart, SubagentStart, and PostModelSwitch, exit code 2 causes Claude Code to render the stderr in the transcript as a hook error. A failed append does not deserve that treatment.
Omitting matcher fires the hook on every switch. If you later want to narrow it, note that for PreModelSwitch the matcher is evaluated against the canonical name Claude Code derives from to_model. Recording everything first and narrowing afterwards is the safer order — you cannot pick a filter for behavior you have not observed yet.
The 5-second and 15-second timeouts are the earlier split written into configuration: short for the decision, generous for the record.
Once the JSONL has some volume, aggregate it. Counts alone are not what you are after — you want the breakdown by source, and the pairs that go back and forth.
#!/usr/bin/env python3"""Aggregate recorded switches by source, and surface round trips."""import json, sys, collectionsrows = []for path in sys.argv[1:]: with open(path, encoding="utf-8") as f: rows += [json.loads(l) for l in f if l.strip()]by_source = collections.Counter()cost_by_source = collections.defaultdict(float)pairs = collections.Counter()for r in rows: s = r.get("source") or "unknown" by_source[s] += 1 cost_by_source[s] += r.get("cache_write_usd") or 0.0 pairs[(r.get("from"), r.get("to"))] += 1total = sum(cost_by_source.values())print(f"{len(rows)} switches / estimated cache writes ${total:.4f}")for s, n in by_source.most_common(): print(f" source={s:<8} {n:>3}x ${cost_by_source[s]:.4f}")# A pair that appears in both directions is worth a second look.seen = set()for (a, b), n in pairs.items(): if (b, a) in pairs and (b, a) not in seen: seen.add((a, b)) print(f" round trip: {a} <-> {b} {n}+{pairs[(b,a)]}x")
Round trips get their own line on purpose. When a single session bounces between a light and a heavy model, either the work is sliced too finely or two switching conditions are fighting each other. Neither shape shows up in a raw count.
The cache write that rides along with every switch
There is a reason estimated_cache_write_usd is in the payload at all.
Changing models changes the prompt cache prefix. Whatever was warm before the switch is no longer usable, and the next turn starts with a cache write. The higher prompt_cache_warm was at the moment of the switch, the more you just threw away.
This is where the September 1 pricing change gets easy to misread. With Claude Fable 5.1, the cut applies to cache reads only — down 75% from the equivalent of $1.00 to $0.25 per MTok. Input at $10 and output at $50 per MTok are unchanged, and cache writes were not reduced either.
Line item
Change
Effect on switch-heavy workloads
Input and output tokens
Unchanged
None
Cache reads
Down 75%
Largest benefit when a warm cache is reused for a long time
Cache writes
Unchanged
Incurred on every switch, eroding the benefit
Anthropic published an index from four weeks of August 2026 usage: roughly 25% lower for a typical workload, up to roughly 45% lower for context- and tool-heavy agentic work. Both figures assume cache reads make up a large share of the bill. A workload that switches often, discarding the cache each time, sits at the low end of that share.
Which is why "25% cheaper" needed checking against my own numbers before I believed it. In my case, having sliced the unattended runs into small stages translated directly into switch count. The switch count, it turns out, is a cost question — and I had no way to see that until the log existed.
Where to start
Roll it out in four stages:
Drop in .claude/hooks/model-switch-log.sh and register it on PostModelSwitch only. Nothing gets blocked yet
Let it run for a week, then read the breakdown and round trips with switch-report.py
From the models that actually appear, write the ones you sanction for unattended work into .claude/model-allowlist.txt
Register model-switch-guard.sh on PreModelSwitch, with a short timeout
Do steps 1 and 2 first and leave the guard for later. What is worth blocking is not something you can decide before you have seen what actually happens.
I prefer this order for my own automation: drawing the line after observing holds up better than drawing it in advance. My own allowlist is waiting on two weeks of data before I settle it.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.