●RELEASE — Claude Code v2.1.251 landed on August 28 with close to a hundred changes, built around two themes: tightening permissions and sandboxing, and making usage and cost visible●SECURITY — Read, Write, and Edit could follow a symlink swapped inside the working directory after the permission check had passed, reading or writing outside the approved location●SECURITY — Grep and Glob were not applying Read(...) deny rules to files reached through a symlinked search path. Plugin command path traversal was closed off in the same release●COST — /cost now shows a per-session prompt cache line covering hit ratio, misses, tokens re-cached, and warm versus cold, with a matching prompt_cache object for status line scripts●HOOKS — New PreModelSwitch and PostModelSwitch events let you block, confirm, or annotate a model switch, and SessionStart resume hooks now receive staleness and estimated re-cache cost●MODEL — Seat-billed Enterprise now defaults to Opus 5, and /effort saves a default per model. The 50% weekly limit increase runs through August 31, which leaves one day●RELEASE — Claude Code v2.1.251 landed on August 28 with close to a hundred changes, built around two themes: tightening permissions and sandboxing, and making usage and cost visible●SECURITY — Read, Write, and Edit could follow a symlink swapped inside the working directory after the permission check had passed, reading or writing outside the approved location●SECURITY — Grep and Glob were not applying Read(...) deny rules to files reached through a symlinked search path. Plugin command path traversal was closed off in the same release●COST — /cost now shows a per-session prompt cache line covering hit ratio, misses, tokens re-cached, and warm versus cold, with a matching prompt_cache object for status line scripts●HOOKS — New PreModelSwitch and PostModelSwitch events let you block, confirm, or annotate a model switch, and SessionStart resume hooks now receive staleness and estimated re-cache cost●MODEL — Seat-billed Enterprise now defaults to Opus 5, and /effort saves a default per model. The 50% weekly limit increase runs through August 31, which leaves one day
Read the Catalog Your CLI Already Ships Before Bulk-Replacing Model IDs
A newer generation makes older model IDs look stale. Here is how to pull the catalog embedded in your installed binary, sort every reference into matched, date-mismatched, and malformed, and stop the replacement that would break working IDs.
A few nights after Opus 5 started showing up as the newest model, I was reviewing my own site and stopped mid-scroll. The claude-sonnet-4-6 and claude-opus-4-6 strings scattered through my articles suddenly looked like leftovers.
It seemed like a one-line sed job. I got as far as counting the targets: 209 Japanese articles, 56 of them still indexed. My finger was on the command when I noticed the thing I had not verified.
Were those 4-6 IDs actually invalid?
Had I replaced them without checking, I would have stripped a still-working identifier out of 56 articles and handed readers code that no longer runs. Working as a solo indie developer, that flavor of well-intentioned damage is the one I fear most, because nobody else is going to catch it.
The short version: the Claude Code binary already installed on your machine carries a catalog of the model IDs that version knows about. You can read it without a network call and without an API key. What follows is how to pull it, reconcile it against your own files, and stop the replacement before it lands.
A new generation appearing is not the same event as an ID being retired
Conflating those two is exactly what nearly cost me.
A new generation means the set of options grew. A retirement means requests that used to succeed will start failing, and that normally arrives with an announcement and a date. The first does not imply the second.
The catalog I pulled from v2.1.246 contained claude-opus-4, claude-opus-4-1, claude-opus-4-5, claude-opus-4-6, claude-opus-4-7, claude-opus-4-8, and claude-opus-5at the same time. Generations show up as a list of things that coexist, not as a history of replacements.
The habit of reading "newest released" as "previous one is dead" gets stronger the faster you skim release notes. I track updates daily, and that reflex almost took over.
The installed binary carries the model catalog
The Claude Code native binary stores its supported model identifiers as plain strings. Pin a version, run strings over it, and you get the full set that build knows about.
#!/usr/bin/env bash# model_catalog.sh — pull the model ID catalog out of the installed CLI# usage: ./model_catalog.sh > catalog.txtset -euo pipefailBIN="$(command -v claude || true)"if [ -z "$BIN" ]; then echo "claude is not on PATH" >&2 exit 1fiBIN="$(readlink -f "$BIN")"# Always record which build you read. The catalog is only what that build knows.echo "# source: $BIN" >&2claude --version >&2strings -n 8 "$BIN" \ | grep -Eo 'claude-(opus|sonnet|haiku|fable)-[0-9][a-z0-9._@-]*' \ | sort -u
Running it against v2.1.246 (/usr/local/bin/claude, roughly 237MB) produced this:
Stage
Count
What it contains
Strings beginning with claude-
233
Mostly internal identifiers, not models
Containing a family name (opus / sonnet / haiku / fable)
38
The candidate pool
Matching the ID shape
25
Directly usable
Failing the shape check
13
Not all of these are wrong, as shown below
Only 38 of the 233 claude- strings looked like model IDs at all. The rest were internal identifiers such as claude-code-agent-id and claude-cli-internal. A naive grep hands you six times more noise than signal.
This works as long as the binary is not stripped. Distribution formats change, so the gate described later must always have a branch for "the catalog could not be read."
✦
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 stop a bulk replacement that would break still-valid IDs, before the command runs
✦You will be able to pull a catalog of model IDs straight from the binary on your disk, with no network call and no API key
✦You will be able to narrow more than 1,300 references down to the 63 that actually need attention
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.
This is where reality diverged hardest from my expectations. What you extract is strings present in the binary, not valid model identifiers. Inside a binary, strings get concatenated and sit next to unrelated tokens.
Here is exactly what turned up mixed into my results:
Extracted string
What it actually is
Caught by a shape check?
claude-haiku-3-55
Almost certainly a concatenation artifact. No such generation exists
No — it satisfies the shape
claude-sonnet-4.6
Dotted spelling. API IDs use hyphens
Yes
claude-fable-5.md
A documentation filename
Yes
claude-fable-5-mythos-5
Two tokens run together
Yes
anthropic.claude-daemon
An application bundle identifier, not a model
Yes
That first row is the dangerous one. claude-haiku-3-55 cleanly satisfies family plus generation plus minor, so a regex shape check waves it through. The only tell is a two-digit minor version, and if you miss it you walk away believing a generation exists that never did.
Shape checking is necessary but not sufficient. I added two more constraints: the minor version must be a single digit, and the family must be one of the four known names. Even then, "it was in the binary" does not prove "it is valid," so the final judgment splits along these lines:
An extraction is a candidate, useful for narrowing mechanically
A match is a sufficient reason to stop a replacement — the ID is still known, so there is no urgency to delete it
A non-match is not a reason to delete or replace anything (more on this below)
That asymmetry turned out to be the single most useful idea here.
The same file gives you the per-provider spellings
The binary also carries the Amazon Bedrock and Vertex AI spellings. Here is real data pulled from my copy:
Anything carrying -v1 or :0 falls outside the bare-ID shape. Flag those mechanically as invalid and you will report a perfectly correct Bedrock configuration value as an error. Separate the three families — bare, vendor-prefixed, and @-dated — before you classify anything.
Once you have a catalog, reconcile rather than replace. This is the audit script I use verbatim. It targets MDX here, but source and config files work the same way.
#!/usr/bin/env python3"""audit_model_ids.py — reconcile model IDs in your files against a catalogusage: ./model_catalog.sh > catalog.txt python3 audit_model_ids.py catalog.txt ./contentbuckets: A matched … present in the catalog. Leave alone B date mismatch … family and generation known, date suffix not in catalog C needs fixing … malformed, or an unknown family/generation"""import collectionsimport osimport reimport sys# Bare ID shape, no provider prefix or suffixSHAPE = re.compile(r"^claude-(opus|sonnet|haiku|fable)-\d+(-\d+)?(-20\d{6})?$")# Pick IDs out of prose. Skip anything embedded in a longer tokenIN_TEXT = re.compile( r"(?<![\w-])claude-(?:opus|sonnet|haiku|fable)-\d+(?:-\d+)?(?:-20\d{6})?(?![\w-])")# Catch missing-hyphen typos such as claude-sonnet-46 separatelyMALFORMED = re.compile(r"(?<![\w-])claude-(?:opus|sonnet|haiku|fable)-?\d{2,}(?![\w-])")# Markdown link targets contain slugs, so strip them before countingLINK = re.compile(r"\]\([^)]*\)")def load_catalog(path): """Read the catalog, drop implausible spellings, return IDs and generations.""" ids = set() for line in open(path, encoding="utf-8"): mid = line.strip() if not SHAPE.match(mid): continue parts = mid.split("-") # A two-digit minor is a concatenation artifact (guards claude-haiku-3-55) if len(parts) >= 4 and not parts[3].startswith("20") and len(parts[3]) > 1: continue ids.add(mid) generations = set() for mid in ids: p = mid.split("-") has_minor = len(p) >= 4 and not p[3].startswith("20") generations.add("-".join(p[:4]) if has_minor else "-".join(p[:3])) return ids, generationsdef classify(mid, ids, generations): if mid in ids: return "A" if not SHAPE.match(mid): return "C" p = mid.split("-") has_minor = len(p) >= 4 and not p[3].startswith("20") base = "-".join(p[:4]) if has_minor else "-".join(p[:3]) return "B" if base in generations else "C"def main(catalog_path, root): ids, generations = load_catalog(catalog_path) buckets = {k: collections.Counter() for k in "ABC"} where = collections.defaultdict(set) for dirpath, _, filenames in os.walk(root): for name in filenames: if not name.endswith((".mdx", ".md", ".py", ".ts", ".json", ".yaml")): continue path = os.path.join(dirpath, name) text = LINK.sub("", open(path, encoding="utf-8", errors="replace").read()) found = set(IN_TEXT.findall(text)) for mid in IN_TEXT.findall(text): bucket = classify(mid, ids, generations) buckets[bucket][mid] += 1 if bucket != "A": where[mid].add(path) for mid in MALFORMED.findall(text): if mid not in found: # do not double-count valid IDs buckets["C"][mid] += 1 where[mid].add(path) for key, label in (("A", "matched"), ("B", "date mismatch"), ("C", "needs fixing")): counter = buckets[key] print(f"{key} {label}: {len(counter)} distinct / {sum(counter.values())} total") print() for key in ("B", "C"): if not buckets[key]: continue print(f"--- {key} ---") for mid, count in buckets[key].most_common(): print(f" {count:>4} {mid} ({len(where[mid])} files)") # Fail only while C is non-empty. B is meant for a human to read. return 1 if buckets["C"] else 0if __name__ == "__main__": sys.exit(main(sys.argv[1], sys.argv[2]))
Run across 837 Japanese articles (excluding this one), the result was:
Bucket
Distinct
Total
Action
A matched
19
1,329
Leave alone
B date mismatch
13
62
Verify each against primary sources
C needs fixing
1
1
Correct the spelling
Out of 1,392 references, 63 warranted attention — about 4.5 percent. The claude-sonnet-4-6 replacement I had been about to run would have swept up 579 of the matched ones.
The date suffix goes wrong before the family name does
Reading the 13 distinct entries in bucket B one by one produced the most useful finding of the exercise.
claude-sonnet-4-6-20250514 appeared 14 times. Known family, known generation, valid shape, nothing obviously wrong. Except 20250514 is Sonnet 4's date. A generation-date mismatch had been sitting in 14 places wearing a perfectly plausible face.
Any check that only inspects the family name passes this. It surfaces only when you reconcile the generation and the date together. Put differently: if you never write the date suffix, the contradiction cannot occur.
I settled on this split for my own material:
Context
Recommended form
Why
Production configuration
Pin with the date
Prevents unintended generation drift
Documentation and article examples
Omit the date
Dates rot fastest and mismatches go unnoticed
Verification scripts
Read from an environment variable
Pinning forces an edit on every re-run
Absence from the catalog does not mean invalid
Getting this backwards turns the audit itself into the next incident.
The catalog you extracted is the set that build knows about. IDs introduced after that build are obviously missing. So absence carries two meanings at once:
The ID does not exist, or has been retired
The ID arrived after the version you happen to be running
Nothing mechanical separates those. That is why B and C are split, and why only C fails the gate. B is the pile a human works through against primary sources.
My extraction did include spellings I had never seen. Unfamiliar is not the same as wrong, so the rule is to confirm against the official model list before treating any of them as usable.
Which brings us back to the original question. claude-opus-4-6 and claude-sonnet-4-6 were both in the catalog on my disk. That is reason enough to stop a replacement. Match means stop; absence means investigate. Deleting or replacing waits for a retirement announcement, which is primary information.
A small gate in front of bulk replacement
Finally, take the judgment out of memory. What I use runs the audit only on commits that touch model IDs.
#!/usr/bin/env bash# .git/hooks/pre-commit — audit only when a change touches model IDsset -euo pipefailSTAGED="$(git diff --cached --name-only --diff-filter=ACM)"[ -z "$STAGED" ] && exit 0# No model IDs in the staged diff means no audit neededif ! git diff --cached -U0 -- $STAGED \ | grep -qE '^[+-].*claude-(opus|sonnet|haiku|fable)-[0-9]'; then exit 0fiCATALOG="$(mktemp)"trap 'rm -f "$CATALOG"' EXITif ! ./tools/model_catalog.sh > "$CATALOG" 2>/dev/null || [ ! -s "$CATALOG" ]; then # No catalog means no basis for judgment. Do not let that pass as success. echo "Could not read the model catalog. Verify manually before committing." >&2 exit 1fi# Check whether the IDs on removed lines are still in the catalogREMOVED="$(git diff --cached -U0 -- $STAGED \ | grep -E '^-' \ | grep -oE 'claude-(opus|sonnet|haiku|fable)-[0-9]+(-[0-9]+)?' \ | sort -u || true)"STILL_KNOWN=""for mid in $REMOVED; do if grep -qx "$mid" "$CATALOG"; then STILL_KNOWN="$STILL_KNOWN $mid" fidoneif [ -n "$STILL_KNOWN" ]; then echo "You are removing IDs that are still in the catalog:$STILL_KNOWN" >&2 echo "Confirm the retirement notice, then pass --no-verify deliberately." >&2 exit 1fipython3 tools/audit_model_ids.py "$CATALOG" ./content
The hook earns its keep by inspecting removed lines rather than added ones. Migration accidents rarely come from mistyping a new ID; they come from deleting one that still works. That is precisely the step where I caught myself.
The --no-verify escape hatch stays open so a legitimate, verified migration is not obstructed. The gate exists to ask whether you decided, not to decide for you.
Three things that tripped me up running this
It did not work on the first pass. Here is what I actually hit and fixed.
Assuming links were the cause of the false positives
My first run reported 28 distinct entries needing fixes. Opening them showed article slugs such as claude-sonnet-46-complete-mastery-guide. I assumed link targets were to blame, stripped Markdown links, and recounted.
Three entries disappeared. Twenty-eight became twenty-five, and the pile stayed essentially intact.
What actually worked was a different change. Adding negative word-boundary lookarounds and restricting the family name to the four known values dropped non-ID detections to zero — same files, same corpus.
Extraction pattern
Links stripped
Non-ID detections
Loose pattern
No
28 distinct / 47 total
Loose pattern
Yes
25 distinct / 28 total
Word-boundary pattern
No
0
Word-boundary pattern
Yes
0
Stripping links is not useless, but it was not the fix. Guessing at the cause of a false positive means paying for a remedy that does not work while believing the problem is handled. I nearly skipped measuring before and after here.
Reporting -v1 as an error
Check against the bare-ID shape alone and Bedrock's anthropic.claude-opus-4-6-v1 lands in the needs-fixing bucket. Reporting a correct configuration value as a defect erodes trust in the report itself, and a gate nobody trusts is a gate nobody reads.
Separate the three families — bare, vendor-prefixed, and @-dated — before classifying anything.
Passing silently when the catalog cannot be read
My first hook exited 0 when strings failed. That treated "no basis for judgment" as success, which is the hardest failure mode to detect in production. If the binary ships stripped in some future release, the gate would quietly stop working.
It now fails explicitly on an empty catalog. Stopping beats passing in silence.
Wrapping up
Run strings once and drop your build's catalog into a file. It takes under a minute. The moment you reconcile that file against your repository, "what to replace" and "what to leave alone" separate into two clearly different piles.
Had I run that sed command, 56 articles would have broken quietly. I hope this saves you the same night.
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.