
Handle Fable 5 Refusals With Fallbacks in Python
Summary
Catch Claude Fable 5's stop_reason refusal and auto-retry on Opus 4.8 without breaking production.
Handle Fable 5 Refusals With Fallbacks in Python
Anthropic shipped Claude Fable 5 on June 9, 2026 as its most capable widely released model, built for demanding reasoning and long-horizon agentic work. It has a 1M-token context window and runs at $10 per million input tokens and $50 per million output tokens. But the headline change is not the benchmark score. It is one new response shape that will silently break integrations that were written for Opus or Sonnet.
Fable 5 ships with safety classifiers that can decline a request. When that happens you do not get an HTTP error. You get a normal 200 OK with stop_reason: "refusal", an empty content array, and zero output tokens. If your code reaches for response.content[0].text, it throws an IndexError on a successful HTTP call. If your monitoring watches 5xx rates, it never sees the problem at all.
This guide shows you how to detect a Fable 5 refusal and retry it on Claude Opus 4.8 so your users still get an answer. We cover all three official paths: the one-line fallbacks parameter, the SDK middleware, and the manual fallback-credit flow for raw HTTP. Every code block is checked against Anthropic's API docs, and the core handler is unit-tested with a mocked SDK at the end.
Prerequisites
- Python 3.10+ (the manual example uses structural pattern matching).
- An Anthropic API key in the
ANTHROPIC_API_KEYenvironment variable. - The Python SDK:
pip install "anthropic>=0.40". - Note: Fable 5 carries mandatory 30-day data retention and is not available under zero data retention. Plan for that before sending production traffic.
pip install "anthropic>=0.40"
export ANTHROPIC_API_KEY="sk-ant-..."
Step 1: A normal Fable 5 call
Start with a plain request so you know the happy path. Two things are specific to Fable 5: adaptive thinking is always on (you cannot pass thinking: {"type": "disabled"}), and you steer thinking depth and cost with the effort parameter inside output_config. Fable 5 accepts low, medium, high (the default), xhigh, and max. Start at high and step down to save tokens.
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
resp = client.messages.create(
model="claude-fable-5",
max_tokens=1024,
output_config={"effort": "high"}, # low | medium | high | xhigh | max
messages=[{"role": "user", "content": "Explain prompt caching in two sentences."}],
)
print(resp.stop_reason) # -> "end_turn"
print(resp.content[0].text) # the answer
Example output:
end_turn
Prompt caching stores a reusable prefix of your prompt on Anthropic's servers so repeated
requests skip re-processing it. You pay a one-time cache-write cost, then cheaper cache reads
on every later call that shares the prefix.
Step 2: Recognize a refusal
A refusal is a successful response, not an exception. The body looks like this (four categories exist: cyber, bio, frontier_llm, and reasoning_extraction):
{
"id": "msg_01XFUDYJgAACzvnptvVoYEL",
"type": "message",
"role": "assistant",
"model": "claude-fable-5",
"content": [],
"stop_reason": "refusal",
"stop_details": {
"type": "refusal",
"category": "cyber",
"explanation": "This request was declined because it could enable cyber harm."
},
"usage": { "input_tokens": 412, "output_tokens": 0 }
}
Two rules that the docs are explicit about. First, branch on stop_reason, never on stop_details or content. stop_details is informational and can be null even on a real refusal (for example in batch results). Second, you are not billed for a refusal that arrives before any output, and it does not count against your rate limits. The explanation text is not stable, so display it, do not parse it.
def is_refusal(resp) -> bool:
# The one correct check. Works on streaming, non-streaming, and batch results.
return resp.stop_reason == "refusal"
def refusal_category(resp):
# category and explanation may both be None even on a real refusal.
details = resp.stop_details
return getattr(details, "category", None) if details else None
Step 3: The one-line fix (server-side fallback)
The simplest production answer is to name a fallback model on the request and let the API do the retry inside a single call. When Fable 5 declines, the API runs the next model in the chain on the same prompt and returns one response. This is in beta on the Claude API and Claude Platform on AWS. It is not available on Bedrock, Vertex, Microsoft Foundry, or the Batches API, where you use the SDK middleware instead.
import anthropic, json
client = anthropic.Anthropic()
resp = client.beta.messages.create(
model="claude-fable-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello, Claude"}],
fallbacks=[{"model": "claude-opus-4-8"}], # tried in order, up to 3
betas=["server-side-fallback-2026-06-01"], # header must be this exact date
)
# A fallback_message entry in usage.iterations means a fallback model ran;
# pair it with stop_reason to confirm the fallback actually served the answer.
fallback_ran = any(
it.type == "fallback_message" for it in (resp.usage.iterations or [])
)
served_by_fallback = fallback_ran and resp.stop_reason != "refusal"
print(json.dumps({
"stop_reason": resp.stop_reason,
"model": resp.model, # the model that produced the message
"served_by_fallback": served_by_fallback,
}))
On a refusal-before-output, the returned model field is the fallback model, and content opens with a fallback block that marks the hand-off: {"type": "fallback", "from": {"model": "claude-fable-5"}, "to": {"model": "claude-opus-4-8"}}. Example output:
{"stop_reason": "end_turn", "model": "claude-opus-4-8", "served_by_fallback": true}
A few rules govern the fallbacks list: entries are tried in order, each must be distinct, and each must be one of the requested model's permitted targets (published as allowed_fallback_models on the Models API). At launch, Fable 5's permitted target is Claude Opus 4.8. Only a safety decline triggers fallback; a rate limit, overload, or 5xx is returned to you as-is.
Step 4: Configure once with SDK middleware
If you run on Bedrock, Vertex, or Foundry, or you simply do not want to thread a parameter through every call site, use the refusal-fallback middleware. You configure it once on the client and every call through client.beta.messages retries refusals automatically. It also sends the fallback-credit beta header for you, so retries are repriced without extra setup. Share one BetaFallbackState across a conversation so follow-ups stay pinned to the model that accepted.
from anthropic import Anthropic
from anthropic.lib.middleware import BetaRefusalFallbackMiddleware, BetaFallbackState
client = Anthropic(
middleware=[BetaRefusalFallbackMiddleware([{"model": "claude-opus-4-8"}])],
)
state = BetaFallbackState() # pins follow-up turns to the model that accepted
with state:
message = client.beta.messages.create(
model="claude-fable-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello, Claude"}],
)
print("served by:", message.model) # claude-fable-5 or claude-opus-4-8
The middleware walks your fallback list in order, strips Fable 5's thinking blocks from the retry, and only surfaces the original refusal when every model in the list declines. Do not combine the middleware and the server-side fallbacks parameter on the same request; pick one. The helper exists in the TypeScript, Python, Go, Java, and C# SDKs (not yet Ruby or PHP).
Step 5: Manual retry with fallback credit (raw HTTP, Ruby, PHP)
If you build the retry yourself, you still want the cost optimization the managed paths get for free. Prompt caches are per-model, so re-sending a refused prompt to Opus 4.8 would normally re-write the whole prefix into a second cache at the higher write price. Fallback credit fixes that: the refusal carries a fallback_credit_token, you echo it on the retry, and the retry is billed as if the conversation had always been on Opus 4.8.
Opt in with the fallback-credit-2026-06-01 beta header, read the token from stop_details, and resend with model set to the fallback plus the token as a top-level parameter:
import anthropic, json
from anthropic import BadRequestError
client = anthropic.Anthropic()
request = {"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello, Claude"}]}
def send(model, body):
return client.beta.messages.create(
model=model, betas=["fallback-credit-2026-06-01"], **body
)
resp = send("claude-fable-5", request)
if (resp.stop_reason == "refusal"
and (details := resp.stop_details)
and (token := details.fallback_credit_token)):
exact_body = request | {"fallback_credit_token": token}
try:
resp = send("claude-opus-4-8", exact_body)
except BadRequestError as err:
if "redemption temporarily unavailable" in str(err):
raise # transient: retry within the 5-min window
# Token rejected: forfeit it and retry without (re-runs server tools, if any).
resp = send("claude-opus-4-8", request)
print(json.dumps({"stop_reason": resp.stop_reason, "model": resp.model}))
Confirm the credit applied by checking usage on the retry: cache_creation_input_tokens drops and cache_read_input_tokens rises by the same amount. A shift of zero just means there was nothing to reprice. The refused model's permitted retry target at launch is Claude Opus 4.8.
Step 6: Refusals while streaming
A refusal can also arrive mid-stream, after some text has already been delivered. With server-side fallback the retry happens on the same stream, so nothing you already received is invalidated. If the decline lands before any output, message_start names the fallback model and the fallback block is the first content block. If it lands mid-output, the open block closes, a fallback block marks the boundary, and the fallback model continues from the partial text.
The practical rule: read the serving model from the fallback block's to.model (or the fallback_message entry in the final message_delta's usage.iterations), not from message_start, which may still name Fable 5. With the SDK middleware, the events are spliced onto the open stream for you:
from anthropic import Anthropic
from anthropic.lib.middleware import BetaRefusalFallbackMiddleware, BetaFallbackState
client = Anthropic(middleware=[BetaRefusalFallbackMiddleware([{"model": "claude-opus-4-8"}])])
state = BetaFallbackState()
with state, client.beta.messages.stream(
model="claude-fable-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello, Claude"}],
) as stream:
for event in stream:
if event.type == "text":
print(event.text, end="", flush=True)
final = stream.get_final_message()
print("\nserved by:", final.model)
One caveat for non-streaming requests: a mid-output decline behaves differently. The response drops the declined model's partial output and the fallback answers from scratch, so it looks like a decline-before-output with the fallback block first. The declined attempt still appears in usage.iterations. Treat any partial output from a refused attempt as incomplete and discard it.
Worked example: a refusal-safe answer() helper
Here is a small, self-contained helper you can drop into a support bot or API endpoint. It tries Fable 5, redeems the credit token on a refusal, returns the served model so you can log it, and emits a metric per refusal (because a refusal is an HTTP 200, your error dashboards will never catch it otherwise).
import anthropic
client = anthropic.Anthropic()
PRIMARY, FALLBACK = "claude-fable-5", "claude-opus-4-8"
def answer(messages, *, max_tokens=1024, effort="high", emit_metric=print):
body = {"max_tokens": max_tokens,
"output_config": {"effort": effort},
"messages": messages}
resp = client.beta.messages.create(
model=PRIMARY, betas=["fallback-credit-2026-06-01"], **body
)
if resp.stop_reason != "refusal":
return {"text": _text(resp), "served_by": PRIMARY, "fell_back": False}
# Instrument refusals as their own signal (one event per refusal).
cat = getattr(resp.stop_details, "category", None)
emit_metric(f"fable5.refusal category={cat}")
retry = dict(body)
token = getattr(resp.stop_details, "fallback_credit_token", None)
if token:
retry["fallback_credit_token"] = token
resp = client.beta.messages.create(
model=FALLBACK, betas=["fallback-credit-2026-06-01"], **retry
)
return {"text": _text(resp), "served_by": resp.model, "fell_back": True}
def _text(resp):
return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
if __name__ == "__main__":
out = answer([{"role": "user", "content": "Summarize the CAP theorem."}])
print(out["served_by"], "->", out["text"][:80])
Example output on a normal request:
claude-fable-5 -> The CAP theorem says a distributed data store can guarantee at most two of
Common pitfalls
- Indexing
content[0]blindly. On a refusalcontentis[], soresp.content[0].textraisesIndexErroron a 200 response. Always checkstop_reasonfirst. - Branching on
stop_detailsorcontent.stop_detailsis informational and can benullon a genuine refusal (notably in Batches). Checkstop_reason == "refusal"directly. - Retrying on the same model. Re-sending a refused prompt to Fable 5 usually earns another refusal. Point the retry at the permitted target, Opus 4.8.
- Monitoring only 5xx rates. A refusal is a 200, so error dashboards never see it. Emit one event per refusal and one per fallback-served response, then alert on the gap.
- Budgeting retries per turn or session. One turn can fire several refusals (an agent plus its sub-agents). Budget retries per request.
- Forgetting sub-agent fallbacks. The
fallbacksparameter does not propagate into model calls made inside tool execution. Give each sub-agent call its own fallback. - Wrong beta-header date. The header must be exactly
server-side-fallback-2026-06-01(orfallback-credit-2026-06-01). Any other date is rejected with a 400. - Passing
thinking: {"type": "disabled"}. Adaptive thinking is always on for Fable 5; disabling it is rejected. Use theeffortparameter to control depth instead.
Quick reference
Which fallback approach to use:
| Your situation | Use | Beta header |
|---|---|---|
| Claude API / AWS, simplest setup | Server-side fallbacks param | server-side-fallback-2026-06-01 |
| Any platform, official SDK | BetaRefusalFallbackMiddleware | (sent by middleware) |
| Raw HTTP, Ruby, PHP, custom logic | Manual retry + credit token | fallback-credit-2026-06-01 |
| Message Batches | Collect refused items, resubmit on Opus 4.8 | n/a (server-side not allowed) |
Refusal categories returned in stop_details.category:
| category | Meaning |
|---|---|
| cyber | Could enable cyber harm (malware, exploits). Benign security work can trigger it. |
| bio | Could enable biological harm. Beneficial life-sciences work can trigger it. |
| frontier_llm | Could help build competing AI models. Benign ML work can trigger it. |
| reasoning_extraction | Asks the model to reproduce its internal reasoning in the text. |
Key facts:
| Item | Value |
|---|---|
| Model ID | claude-fable-5 |
| Permitted fallback target | claude-opus-4-8 |
| Refusal signal | HTTP 200, stop_reason = "refusal", content = [] |
| Billing on pre-output refusal | Not billed; no rate-limit charge |
| Context / output | 1M tokens in, up to 128k out |
| Pricing (Fable 5) | $10 / 1M input, $50 / 1M output |
| Effort levels | low, medium, high (default), xhigh, max |
Next steps
- Add a per-refusal counter and a fallback-served counter to your metrics, and alert when the gap grows.
- Run an eval at
mediumandloweffort before lowering it in production; Fable 5 at lower effort often beats prior models atxhigh. - If you call Fable 5 from inside agent tools, wire a fallback into each sub-agent call, not just the top-level request.
- For Bedrock, Vertex, or Foundry deployments, standardize on the SDK middleware since the server-side parameter is API/AWS-only.
Sources: Anthropic Claude API docs — Introducing Claude Fable 5 and Claude Mythos 5; Refusals and fallback; Fallback credit; Effort. Verified June 11, 2026.
Comments
Be the first to comment
Found this useful?
Get new AI guides for builders by email. Free.
Join 2,012 builders reading daily.