Models How-to

A model switch re-reads your whole session uncached. Put a price on it with a PreModelSwitch hook

Claude Code now tells hooks what a /model switch will cost before it happens. A 40-line script turns that into a confirmation, a hard refusal for unattended sessions and a ledger.

Part of the hooks reading guide

A violet wireframe terrain with a smooth plateau that drops into a sharp, deep step at its centre

A session is 180,000 tokens deep, the cache is warm, and someone types /model opus because the next step looks hard. Nothing on screen says what that costs. The next request reads the entire conversation again with no cache hits, on a model that has never seen it.

Claude Code now gives hooks the numbers. A PreModelSwitch hook receives the context size, whether the cache is warm and an estimate of what rebuilding it costs, and can allow, ask or refuse the switch. This piece builds one small script that does three things with that: asks before an expensive switch, refuses expensive switches that come from anywhere a person can’t answer a prompt, and keeps a ledger of every switch. It needs Claude Code 2.1.251 or later, and a minute of setup.

Why a switch costs anything

Each model has its own prompt cache. According to the Claude Code prompt caching docs , switching models recomputes the entire request even when the content is identical. The next turn is slower and billed as a cache write for the whole history, after which the new model’s cache is warm.

Claude Code already softens this for the plain case. Run /model at the terminal and it asks for confirmation, but only while the old cache is still warm and only when the new model isn’t the one that produced the last response. After one cache lifetime of inactivity the cache is gone and the switch goes through silently.

The catch is how many things count as a switch. The same docs list several that don’t look like one:

  • opusplan, which resolves to Opus in plan mode and Sonnet in execution, so every plan-mode toggle is a switch
  • Automatic fallback when a safety classifier flags a request and Claude Code re-runs it on a fallback model
  • A skill or command whose frontmatter names a model other than the session’s, for that turn
  • Fast mode, when enabling it moves the session to Opus

The built-in prompt covers /model and little else. It has no threshold, so it asks about a 9,000-token session as readily as a 180,000-token one. And it can’t ask at all in a -p run or an Agent SDK host.

What the hook receives

The hooks reference documents these fields, on top of the usual common ones:

FieldMeaning
from_model, to_modelModel IDs on either side of the switch
sourcecommand for /model, /config or fast mode; picker for a model picker; sdk for a set_model request from an Agent SDK host or Remote Control
context_tokensTokens the next request re-sends as its prompt
prompt_cache_warmWhether the switch forfeits a cache that is still warm
cache_ttl5m or 1h, the main conversation’s cache lifetime
estimated_cache_write_usdEstimated cost of writing context_tokens to the new model’s cache
pricingconfigured, catalog or default: how that figure was priced

The cost is an estimate. The docs say the server may not need to re-cache the whole context, and pricing: "default" means the target model had no known price and Claude Code assumed a rate. Treat the dollar figure as an order of magnitude, which is all a threshold needs.

The hook answers with exit code 2 to cancel, or JSON in hookSpecificOutput with permissionDecision set to allow, deny or ask, plus a permissionDecisionReason. One restriction matters here: only /model in an interactive session can show the ask prompt. On every other surface, including -p, /config and set_model requests, ask is treated as a refusal.

The script

Save this as .claude/hooks/model-switch-gate.py. It is Python because it needs no build step, and it handles both events so the ledger shows the whole picture.

#!/usr/bin/env python3
"""PreModelSwitch / PostModelSwitch hook: price a model switch, ask or deny when it is expensive, keep a ledger."""
import json
import os
import sys
import time

ASK_ABOVE_USD = float(os.environ.get("MODEL_SWITCH_ASK_USD", "0.50"))
LEDGER = os.path.join(os.environ.get("CLAUDE_PROJECT_DIR", "."), ".claude", "model-switches.jsonl")

event = json.load(sys.stdin)
name = event["hook_event_name"]
cost = event.get("estimated_cache_write_usd", 0.0)

with open(LEDGER, "a") as ledger:
    ledger.write(json.dumps({
        "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "event": name,
        "from": event["from_model"],
        "to": event["to_model"],
        "source": event["source"],
        "context_tokens": event["context_tokens"],
        "cache_warm": event["prompt_cache_warm"],
        "est_usd": cost,
    }) + "\n")

if name == "PostModelSwitch":
    if "opus" in event["to_model"]:
        print("On Opus, delegate implementation to subagents and keep this conversation for planning and review.")
    sys.exit(0)

if not event["prompt_cache_warm"] or cost < ASK_ABOVE_USD:
    sys.exit(0)

reason = (f"Switching to {event['to_model']} re-sends {event['context_tokens']:,} tokens "
          f"uncached (about ${cost:.2f} to rebuild the cache). Run /compact first, or continue.")
# Only /model in an interactive session can show the prompt. Everywhere else "ask" is a refusal.
decision = "ask" if event["source"] == "command" else "deny"
print(json.dumps({"hookSpecificOutput": {
    "hookEventName": "PreModelSwitch",
    "permissionDecision": decision,
    "permissionDecisionReason": reason,
}}))

Register it in .claude/settings.json. The PostModelSwitch entry takes no matcher so the ledger sees every switch; the script decides what to say.

{
  "hooks": {
    "PreModelSwitch": [
      { "hooks": [ { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/model-switch-gate.py\"" } ] }
    ],
    "PostModelSwitch": [
      { "hooks": [ { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/model-switch-gate.py\"" } ] }
    ]
  }
}

A few decisions in the script are worth knowing about.

A cold cache never triggers the gate. If prompt_cache_warm is false there is nothing to forfeit, and the built-in behaviour already skips the prompt in that case.

The threshold is an environment variable, MODEL_SWITCH_ASK_USD, defaulting to 50 cents. Pick one that matches how you work. Someone on a subscription who never sees a bill per token might set it higher; a team on API keys might set it lower.

Any source other than command gets deny rather than ask. The documented behaviour is that ask is a refusal on those surfaces anyway, so saying deny puts an honest reason in the error instead of a generic one. For an unattended run that’s the right outcome. A pipeline that quietly flips from Sonnet to Opus halfway through a 200,000-token session should fail loudly, not bill quietly.

What it prints

The hook reads JSON on stdin, so it can be exercised without a session. These payloads are hand-built from the example in the hooks reference (the 182,340-token, $1.1396 case is the docs’ own sample). A warm cache, a large context and source: "command":

{"hookSpecificOutput": {"hookEventName": "PreModelSwitch", "permissionDecision": "ask", "permissionDecisionReason": "Switching to claude-opus-5 re-sends 182,340 tokens uncached (about $1.14 to rebuild the cache). Run /compact first, or continue."}}

The same payload with source: "sdk" returns "permissionDecision": "deny" with the same reason. A 9,000-token context at an estimated $0.0563, and the same large context with prompt_cache_warm false, both exit 0 with no output, so the switch proceeds untouched. A PostModelSwitch payload for Opus prints the delegation reminder, which Claude Code delivers with the next request.

Every call appends a line to the ledger:

{"at": "2026-10-28T16:03:08Z", "event": "PreModelSwitch", "from": "claude-sonnet-5", "to": "claude-opus-5", "source": "command", "context_tokens": 182340, "cache_warm": true, "est_usd": 1.1396}

That firing inside a live session is documented behaviour here, not observed. To check it in yours, start a session on one model, send a prompt so the cache is warm, then run /model to a different one. The hooks reference describes the same test: Claude Code keeps the current model and reports that the hook blocked the switch, with your reason.

Reading the ledger

The ledger answers a question nobody can currently answer: how often do sessions change model, and from where? A one-liner shows the biggest offenders:

jq -s 'map(select(.event=="PreModelSwitch")) | sort_by(-.est_usd) | .[:5][] | [.at, .source, .from, .to, .est_usd] | @tsv' .claude/model-switches.jsonl

Add .claude/model-switches.jsonl to .gitignore. It is per-checkout noise, and it records nothing but model names and token counts.

Expect surprises in the source column. A ledger full of command entries means people are switching by hand. If you see sdk, something host-side is calling set_model. Entries on PostModelSwitch with source: "auto" are fallbacks that Claude Code made on its own, which never go through the pre-hook, so you can see them but not stop them.

Where this goes wrong

Hooks can’t stop what they can’t see. PreModelSwitch doesn’t fire for automatic fallback or for the model restored on resume, per the docs, and those reach PostModelSwitch only. A skill that names a different model in its frontmatter is described as a switch for that turn, but whether the pre-hook is consulted for it isn’t something the hooks page promises, so don’t rely on the gate for it. Check the ledger after using such a skill.

A slow hook blocks the switch. The default timeout for this event is 30 seconds, and a hook that doesn’t answer in time cancels the switch. This script returns in milliseconds, but if you extend it to call a remote service, set a short timeout and expect refusals when the service is down.

deny for non-interactive sources is a policy, not a law of nature. Some pipelines legitimately switch to a bigger model for one step. If that’s you, raise MODEL_SWITCH_ASK_USD for those runs or allow sdk switches below a higher ceiling. The cheaper answer for most unattended jobs is to pin the model in the command and never switch.

The estimate is a list-price estimate unless the pricing field says configured. On a subscription it isn’t a bill at all, but it still tracks the real cost in latency and usage, because re-reading 180,000 tokens uncached takes longer and burns more of the allowance than a cached turn.

Don’t bother if your sessions are short. At 20,000 tokens the rebuild is a few cents, and a prompt on each switch is friction for nothing. The gate earns its place in long sessions and in anything headless.

First 15 minutes

Check claude --version is 2.1.251 or later. Drop the script and settings into one repo, run chmod +x on the script, and pipe a payload through it from the hooks reference to see the JSON it returns. Then open a session in that repo, send a prompt, and run /model to a different model once the context is a few thousand tokens deep. Lower MODEL_SWITCH_ASK_USD to 0.01 for the test so the prompt actually appears.