All writing

Fable 5.1: three places a model ID lives

Fable 5.1 shipped this morning. I opened my global settings, changed one string, and considered the migration done. It was not done. The session kept launching on Fable 5, and the reason was a shell function I had written myself a few weeks earlier and completely forgotten about.

That's the whole post, really — but the same shape showed up twice more before the afternoon was over, once in my shell and once in application code, and the common thread is worth writing down: a model ID swap looks like a one-line change and behaves like a behavior change, and every way it goes wrong is silent.

Layer one: the setting

The obvious one. ~/.claude/settings.json had "model": "claude-fable-5[1m]". The new ID is claude-fable-5-1 — same 1M context, same $10/$50, with cache reads at a quarter of what Fable 5 charged. The [1m] suffix is the one-hour prompt cache TTL, which I kept.

One thing I want to flag about how I got that ID: I didn't recall it, I looked it up. My assistant's own cached model table stopped at claude-fable-5, and the model had been out for a matter of hours. Model IDs are exactly the kind of fact where a confident guess and a correct answer are indistinguishable until something 400s.

Layer two: the wrapper I wrote and forgot

Back in July I wrote about the Fable weekly cap running out ahead of the all-models cap. One of the things I did about it was a small zsh function that checks the cap before launching and falls back to Opus when Fable is exhausted. It lives in ~/.claude/fable-model-selector.zsh and .zshrc sources it.

fable-model-selector.zsh
local fable="${CLAUDE_FABLE_MODEL:-claude-fable-5}"
local opus="${CLAUDE_OPUS_MODEL:-claude-opus-4-8}"
...
"$real" --model "$model" "$@"

The last line is the problem. The wrapper doesn't read my settings file — it decides on a model itself and passes --model explicitly, and an explicit flag beats the config file every time. So the hardcoded default on line one was the model I actually got, and editing settings.json did nothing at all.

Nothing errors when this happens. There's no warning that two layers disagree. The session just starts on the old model, reports the old model if you ask it, and everything works — which is precisely why I'd have kept believing I was on 5.1 indefinitely. The fix was two default strings, plus the fallback target, which I moved from Opus 4.8 to Opus 5 while I was in there.

Layer three: the binary

The third place is the CLI itself. I checked the installed binary for the new ID and it wasn't there — 2.1.238 knew claude-opus-5 and claude-sonnet-5 but had never heard of claude-fable-5-1, which is what you'd expect for a model released after that build.

This one resolved itself: by the time I ran the update it had already moved to 2.1.257 on its own, and the new binary has the ID. So layer three was a false alarm. I'm including it anyway because I didn't know it was a false alarm when I found it, and "the binary predates the model" is a real failure mode on a day-of release — just not the one that bit me.

The scan that found nothing

Fable 5.1 has three breaking changes from Fable 5: forced tool use now errors, thinking blocks are tied to the model that produced them, and editing earlier turns invalidates them. So I swept every project I have for code that calls Fable.

Nothing. Zero callers. The only hits in the entire tree were test fixtures and doc comments inside a vendored pricing table, and that table gets its actual numbers from a remote feed, so it'll pick up the new model on its own. My usage-tracking tools read model labels dynamically rather than hardcoding them, so they were fine too.

A scan that finds nothing feels like wasted work and isn't. Before it I had a vague worry about breaking changes across an unknown number of projects; after it I had a bounded answer. The cost was a few minutes of grep.

The same bug, one layer down

What the scan did turn up was three projects pinned to Opus 4.8. Opus 5 is the same price — $5 in, $25 out — so moving up looked like another free one-line change.

It isn't, and here's where the day rhymed. On Opus 4.8, omitting the thinking parameter means no thinking. On Opus 5, omitting it means adaptive thinking is on. The wire format didn't change; the default did.

One of those projects reads bid amounts off photographed court-auction bid slips and returns structured JSON. It never set thinking, and it had max_tokens: 1024 — sized comfortably around a small JSON object, back when that was all the model produced. But max_tokens caps thinking plus response. Swap the model ID alone and the ceiling that used to hold only the answer now has to hold the reasoning too.

The failure mode is the one I keep describing in this post: nothing raises. The request succeeds, the JSON comes back cut off mid-object, the parser returns null, and the app reports a parse failure on a call that was actually fine. For a tool whose entire job is catching a mistyped bid price, a read that silently stops working is worse than one that visibly breaks.

What I changed, and what I deliberately didn't

I raised the ceiling and left the quality dial alone. Thinking-on costs more output tokens, and the obvious offset is to lower effort — but this is a safety-critical read where a confident misread costs more than thousands of API calls, so I wasn't going to quietly trade accuracy for tokens on my own judgment. Instead the effort level became an environment variable defaulting to the model's own default, so it changes nothing until someone decides to change it, without a deploy.

I also skipped disabling thinking to restore the old behavior, which was my first instinct. On Opus 5, thinking-off has two documented failure modes — tool calls occasionally arrive as plain text instead of structured calls, and internal XML tags can leak into responses. Lower effort with thinking on is the better lever in every direction.

Then I wrote tests for both halves, because both halves fail silently and silent failures need something watching them: assertions that the outgoing request carries the model and effort I think it does, that max_tokens is above the old value, and that a classifier refusal is reported as a refusal rather than a parse error. I checked the refusal test actually catches the regression by removing the branch and confirming it went red.

The thing I'd tell myself this afternoon

Changing a model ID is not a config edit. It's a behavior change wearing a config edit's clothes, and the interesting failures are all silent ones: a wrapper that overrides your setting, a binary that predates your model, a token ceiling that used to be generous and isn't anymore. None of them throw. You find them by going and looking.

The one I'm least happy about is the wrapper, because I wrote it. It was a good fix for a real problem two months ago, and it quietly became the thing standing between me and the model I thought I'd switched to. Every optimization you hardcode is a fact about the world you've promised to keep updating.