My overnight unattended tasks all finished. No failure logs. But the heaviest stage came back visibly sloppier than usual.
Tracing it back, the stage I had pinned to xhigh had actually run at high. Not one error along the way. Honestly, a hard failure would have been easier to deal with.
You only catch this mismatch once you know that thinking and effort constrain each other. Let me walk through how they interact.
When is not supported when thinking is disabled shows up
Start with what this looked like back when it surfaced as an error. The API returns a 400, and the body reads:
output_config.effort 'xhigh' is not supported when thinking is disabled
Claude Opus 5 thinks by default. You can turn that off with thinking: {"type": "disabled"}, but only while effort sits at high or below. At xhigh and max, the model refuses to run with thinking switched off. The check runs per request, so there is no "it passed at the start of the session" loophole.
Here is the smallest reproduction:
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "claude-opus-5",
"max_tokens": 1024,
"thinking": {"type": "disabled"},
"output_config": {"effort": "xhigh"},
"messages": [{"role": "user", "content": "ping"}]
}'Drop effort to high, or delete the thinking line entirely, and it goes through. The error text itself points at both exits: use effort high or below, or leave thinking on.
So far, so reasonable. The interesting part comes next.
Each model rejects a different set of values
The thinking types a model accepts have shifted from generation to generation. Reuse a config file and swap only the model name, and this is where you trip.
| Model | Thinking types | Default | Rejected with 400 |
|---|---|---|---|
| Claude Fable 5 | Adaptive only | Always on | enabled / disabled |
| Claude Opus 5 | Adaptive only | On | enabled, plus disabled at effort xhigh or max |
| Claude Opus 4.8 / 4.7 | Adaptive only | Off | enabled |
| Claude Sonnet 5 | Adaptive only | On | enabled |
| Claude Opus 4.6 / Sonnet 4.6 | Adaptive and extended (deprecated) | Off | None |
| Claude Opus 4.5 / Haiku 4.5 / Sonnet 4.5 | Extended only | Off | adaptive |
Two things are worth pulling out of that table.
First, the older thinking: {"type": "enabled", "budget_tokens": N} form is rejected on 4.7 and later. On the 4.6 models it still works while being deprecated, so the breakage surfaces as "but this ran fine on 4.6."
Second, the failure runs the other direction too. The 4.5 generation does not accept adaptive. Standardize on the newer form, then drop back a generation for a cost-sensitive job, and you get a 400 from the other side.
effort has the same kind of step. xhigh arrived later than max, so some models take max but not xhigh. Haiku 4.5 and Sonnet 4.5 do not support the effort parameter at all.
What is left after the error goes away
Claude Code v2.1.251, released on August 28, 2026, changed how this combination is handled. If thinking is disabled and effort is set to xhigh or max, the request is now sent as high instead of failing.
As a user-facing change, that is genuinely kind. You no longer get knocked out of a working session by a 400.
It still gave me pause, though. The failure did not go away; it stopped being visible. Running unattended pipelines nightly as an indie developer, I find loud errors comparatively easy to live with — a failed stage just gets rerun. A stage that completed at a level I never asked for is something I only start to suspect by staring at the output quality.
The same release also made the default /effort level save per model. Finer-grained settings are welcome, but they add another path: switch models, and whatever you last chose for that model comes back.
For now, I check /effort against /status before any heavy stage, so I can see with my own eyes what the session is carrying. It is a crude habit, but crude habits work well against things that change quietly. I audit for a close cousin of this problem too — Claude Code silently ignores a settings.json key when you misspell it by one character.
Rule the combination out before you send it
If you call the API directly, the reliable move is to reject bad combinations locally. Everything the check needs is in the table above, so keep the table as a constant and consult it right before you assemble the request.
#!/usr/bin/env python3
"""Validate thinking / effort combinations before sending the request."""
# thinking: accepted types, plus the model's default
THINKING = {
"claude-fable-5": {"accepts": {"adaptive"}, "default": "always-on"},
"claude-mythos-5": {"accepts": {"adaptive"}, "default": "always-on"},
"claude-opus-5": {"accepts": {"adaptive", "disabled"}, "default": "on"},
"claude-opus-4-8": {"accepts": {"adaptive", "disabled"}, "default": "off"},
"claude-opus-4-7": {"accepts": {"adaptive", "disabled"}, "default": "off"},
"claude-sonnet-5": {"accepts": {"adaptive", "disabled"}, "default": "on"},
"claude-opus-4-6": {"accepts": {"adaptive", "enabled", "disabled"}, "default": "off"},
"claude-sonnet-4-6": {"accepts": {"adaptive", "enabled", "disabled"}, "default": "off"},
"claude-opus-4-5": {"accepts": {"enabled", "disabled"}, "default": "off"},
"claude-haiku-4-5": {"accepts": {"enabled", "disabled"}, "default": "off"},
"claude-sonnet-4-5": {"accepts": {"enabled", "disabled"}, "default": "off"},
}
# effort: accepted levels (models absent here do not support effort at all)
EFFORT = {
"claude-fable-5": {"low", "medium", "high", "xhigh", "max"},
"claude-mythos-5": {"low", "medium", "high", "xhigh", "max"},
"claude-opus-5": {"low", "medium", "high", "xhigh", "max"},
"claude-opus-4-8": {"low", "medium", "high", "xhigh", "max"},
"claude-opus-4-7": {"low", "medium", "high", "xhigh", "max"},
"claude-sonnet-5": {"low", "medium", "high", "xhigh", "max"},
"claude-opus-4-6": {"low", "medium", "high", "max"},
"claude-sonnet-4-6": {"low", "medium", "high", "max"},
"claude-opus-4-5": {"low", "medium", "high", "max"},
}
NO_DISABLE_AT = {"xhigh", "max"} # thinking cannot be off at these levels
STRICT_MODELS = {"claude-opus-5"} # models that enforce the rule above
def normalize(model: str) -> str:
"""Strip a date suffix such as claude-haiku-4-5-20251001."""
parts = model.split("-")
while parts and parts[-1].isdigit() and len(parts[-1]) == 8:
parts.pop()
return "-".join(parts)
def preflight(model: str, thinking_type=None, effort=None):
key = normalize(model)
if key not in THINKING:
return [("unknown", f"{model} is not in the table; add it")]
findings = []
spec = THINKING[key]
if thinking_type and thinking_type not in spec["accepts"]:
findings.append(("400", f'{key} rejects thinking.type "{thinking_type}"'))
if effort:
allowed = EFFORT.get(key)
if allowed is None:
findings.append(("400", f"{key} does not support the effort parameter"))
elif effort not in allowed:
findings.append(("400", f'effort "{effort}" is unavailable on {key}'))
elif (key in STRICT_MODELS and effort in NO_DISABLE_AT
and thinking_type == "disabled"):
findings.append(("400", f'effort "{effort}" cannot be combined with thinking disabled'))
if effort in NO_DISABLE_AT and thinking_type is None and spec["default"] == "off":
findings.append(("warn", f"{key} defaults to thinking off; set adaptive "
f'explicitly if you want effort "{effort}" to matter'))
return findings or [("ok", "this combination is accepted")]Running nine cases through it locally produced this:
[ 400] claude-opus-5 / thinking=disabled / effort=xhigh
-> effort "xhigh" cannot be combined with thinking disabled
[ ok] claude-opus-5 / thinking=disabled / effort=high
-> this combination is accepted
[ ok] claude-opus-5 / thinking=(unset) / effort=xhigh
-> this combination is accepted
[ warn] claude-opus-4-7 / thinking=(unset) / effort=xhigh
-> claude-opus-4-7 defaults to thinking off; set adaptive explicitly if you want effort "xhigh" to matter
[ 400] claude-sonnet-5 / thinking=enabled / effort=high
-> claude-sonnet-5 rejects thinking.type "enabled"
[ ok] claude-sonnet-4-6 / thinking=enabled / effort=medium
-> this combination is accepted
[ 400] claude-haiku-4-5-20251001 / thinking=adaptive / effort=(unset)
-> claude-haiku-4-5 rejects thinking.type "adaptive"
[ 400] claude-haiku-4-5-20251001 / thinking=enabled / effort=low
-> claude-haiku-4-5 does not support the effort parameter
[ 400] claude-fable-5 / thinking=disabled / effort=(unset)
-> claude-fable-5 rejects thinking.type "disabled"
Five of the nine were 400-equivalent, one raised a warning, and three passed. The line to look at is the fourth one, the warn. The API would not reject it. Opus 4.7 simply defaults to thinking off, so asking for xhigh alone will not buy you the depth you had in mind.
I deliberately surface both classes from the same function: combinations the API rejects, and combinations that pass while missing your intent. You will eventually notice the first kind on your own. Nobody tells you about the second.
normalize() exists so that dated IDs like claude-haiku-4-5-20251001 resolve to a table key. It only strips trailing eight-digit runs, so version numbers such as claude-opus-4-5 survive intact.
Changing effort rewrites your cached prefix
There is a tail to this story.
Your thinking configuration and your effort value are both part of the cached prompt prefix. Change either mid-conversation and the prefix you have been building stops matching, which drops cache_read_input_tokens to zero.
So the natural reaction to a 400 — "fine, I will send high from the next request" — has a quiet second cost attached. The error disappears and so does the cache.
The fix is unremarkable: hold effort constant for the life of a conversation or session, and vary it across workloads instead. Writing a default out explicitly is treated the same as omitting it, so adding "effort": "high" does not by itself invalidate anything.
Where exactly to draw those boundaries is its own decision, and I wrote up how I split mine in Whether to stretch cache TTL to an hour depends on where you come back to, not how long you step away.
One check worth running today
Grep your launch scripts, agent definitions, and CI job files for xhigh and max:
grep -rn 'xhigh\|"max"' --include='*.json' --include='*.md' --include='*.sh' .For each hit, confirm that the model named in the same place actually supports xhigh, and that thinking is not switched off alongside it. Any config where those two disagree is not failing right now. It is finishing at a level you did not choose.
I spent longer than I would like blaming the wrong thing for a drop in output quality. If this shortens that detour for you, that is a good outcome.