Claude Sonnet 5.5 launched on 28 September and it rejects the request a lot of extraction pipelines are built on: one tool called something like extract, forced with tool_choice, so the model’s whole reply is your JSON.
Update 13 October 2026: Claude Haiku 5.5 came out on 7 October and its migration guide says it still accepts a forced tool_choice, so this rule is per model and not per generation. I’ve added a Haiku 5.5 column to the table and corrected what I said about pattern in strict schemas. I reopened every Anthropic page cited here today.
Here’s the passage in the Sonnet 5.5 migration guide that does the damage:
Every earlier model on this page accepts a
tool_choiceof typeanyortool. Claude Sonnet 5.5 rejects both with a 400 error, including on the token counting endpoint
Anthropic, Migrating to Claude Sonnet 5.5
The replacement the guide recommends is tool_choice: auto with strict: true tools. My reading: that keeps the shape of the tool input guaranteed, and gives up the guarantee that the tool gets called at all. The guide says as much: “The model can then answer without calling the tool, so say in the prompt when to use it”. A line in a prompt is a request. The pipeline used to have a control.
This post is design analysis with illustrative code, based on Anthropic’s docs as I read them on 13 October 2026. I haven’t run the code or measured anything. The running example is an invoice-extraction step: a supplier invoice arrives as text, and the pipeline needs supplier, invoice number, date, currency and amounts as typed fields. Routing, timeouts and retries in general are in the gateway post.
What changed, model by model
Some rows were already rejected by Sonnet 4.6 or Sonnet 5, so the “before” column varies. The Haiku 5.5 column comes from the Haiku 5.5 migration guide and the errors page.
| Parameter or pattern | Before | On Sonnet 5.5 | On Haiku 5.5 | What to do |
|---|---|---|---|---|
tool_choice of tool or any |
Accepted by Sonnet 5, 4.6, 4.5, 4, 3.7 and Haiku 4.5 | 400: tool_choice: type "tool" and "any" are not supported for this model. Also on token counting |
Accepted, but the response “has no thinking block” |
On Sonnet 5.5 send auto with strict tools. On Bedrock, auto without strict, and validate in code |
tool_choice auto or none |
Accepted | Accepted | Accepted | Nothing |
Non-default temperature, top_p, top_k |
Accepted on Sonnet 4.6 and earlier and Haiku 4.5 | 400 | 400 | Remove them |
Assistant prefill (starting the reply with {) |
Accepted on Sonnet 4.5, Haiku 4.5 and older | 400 | 400, “even with thinking turned off” | Structured outputs, “or tools with enum fields for classification” |
thinking enabled with budget_tokens |
Deprecated on Sonnet 4.6, the only way to think on Sonnet 4.5 and Haiku 4.5 | 400 | 400 | Set an effort level. There’s no fixed mapping, “so run your evaluations at two or three levels” |
thinking: {"type": "disabled"} |
How Sonnet 5 turned thinking off | 400, pointing to between_tools |
Accepted at high effort or below. between_tools is a 400 here |
Per-model thinking config |
No thinking field |
No thinking on Sonnet 4.6 and earlier | Adaptive thinking | Adaptive thinking | Read blocks by type, pass thinking blocks back unchanged, revisit max_tokens |
output_format |
Deprecated | 400 without the beta header | Not covered in the Haiku guide | Move to output_config.format |
| Strict tools | Optional | Recommended, “at most 20 strict tools” per request | On the supported list | Mark only the tools that need it |
The define-tools page lists Opus 5.5, Fable 5.1 and Mythos 5.1 as rejecting any and tool too, so falling back to the newest Opus doesn’t bring forced tool use back. Then there’s the sentence that surprised me, from the Haiku guide:
Claude Haiku 5.5 accepts a forced
tool_choice(anyor a named tool), but the response starts with the tool call and has nothinkingblock.
Anthropic, Claude Haiku 5.5 migration guide
The September version of this post listed the other 5.5-generation models as rejecting forced tool use too, which made the 400 look like a generation-wide rule. It’s a per-model rule. Anthropic haven’t said why the line falls where it does, and I’m not going to guess. What I take from it: a gateway can’t infer the rule from the model family or the version number. It has to be written down per route.
It isn’t only Claude. OpenAI’s GPT-6.1 Sol model page says “Chat Completions is supported without tool calling” and “Use the Responses API for tool calling.” Same lesson, different vendor.
How extraction fails now
With forced tool use, the failure you worried about was shape: a string where you wanted an integer, a missing field. With auto plus strict, shape mostly stops being the problem. The strict tool use page says tool input “strictly follows the input_schema” and the name is always valid. So the failures move:
| Failure | Forced tool, no strict | auto + strict |
|---|---|---|
| Malformed or wrongly typed input | The main risk | Gone, for the schema subset strict supports |
| No tool call, answer in text | Not possible | New, and has to be detected |
| Valid shape, wrong values | Possible | Still possible, and now the main risk |
| Refusal | Possible | Possible: a 200 with stop_reason: "refusal", and output that “may not match your schema” |
| Truncation | Possible | More likely if you didn’t raise max_tokens, which now covers thinking too |
Two rows are easy to miss. A response with no tool call is still a 200, so a pipeline that checks the HTTP status and reads content[0] hands product code undefined, or a thinking block. And strict’s schema subset has gaps. The structured outputs page lists no support for numerical constraints (minimum, maximum, multipleOf) or minLength and maxLength, and it says enum capitalisation isn’t guaranteed, so you might get "Gbp" for "GBP". (A correction: the September version of this post said pattern was unsupported. The page today says simple regex patterns work and complex ones may return a 400. I’ve also dropped a claim about oneOf and conditional schemas, which the page doesn’t mention.) Either way, “schema-valid” is weaker than “valid for my business rules”. In reconciliation work the check that matters is subtotal plus tax equalling the total, and no JSON Schema subset does that for you.
If your extract tool was only ever a JSON trick, look at JSON outputs (output_config.format) first. It removes the “didn’t call the tool” failure entirely. I’d keep a tool when extraction sits inside an agent loop with other tools. The rest of this post assumes you keep one.
The migration pattern
Give the model a legitimate way out. A model may skip an extraction tool because the input doesn’t fit it: a credit note, a statement, a blurry scan. So I add a second strict tool, report_no_invoice, with a small enum of reasons. Now “no tool call” really is a failure, because there was always a structured way to say no.
Strict tools plus Zod. The JSON Schema goes to the model. Zod holds the rules strict can’t express and runs on every route.
import { z } from "zod";
const minor = z.number().int().nonnegative(); // pence, cents
// Rules strict mode can't enforce (bounds, lengths, arithmetic) live here.
export const Invoice = z
.object({
supplierName: z.string().min(1),
invoiceNumber: z.string().min(1),
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
currency: z.string().toUpperCase().pipe(z.enum(["GBP", "EUR", "USD"])), // enum casing isn't guaranteed
subtotalMinor: minor, taxMinor: minor, totalMinor: minor,
})
.strict()
.refine((i) => i.subtotalMinor + i.taxMinor === i.totalMinor, "subtotal + tax must equal total");
const REASONS = ["not_an_invoice", "unreadable", "multiple_invoices"] as const;
export const NoInvoice = z.object({ reason: z.enum(REASONS) }).strict();
const int = { type: "integer" } as const;
const str = { type: "string" } as const;
export const TOOLS = [
{
name: "record_invoice",
description: "Record the single supplier invoice in the document. Amounts are integers in minor units. issueDate is YYYY-MM-DD.",
input_schema: {
type: "object",
properties: {
supplierName: str, invoiceNumber: str,
issueDate: { type: "string", format: "date" },
currency: { type: "string", enum: ["GBP", "EUR", "USD"] },
subtotalMinor: int, taxMinor: int, totalMinor: int,
},
required: ["supplierName", "invoiceNumber", "issueDate", "currency", "subtotalMinor", "taxMinor", "totalMinor"],
additionalProperties: false,
},
},
{
name: "report_no_invoice",
description: "Call this instead of record_invoice when the document is not exactly one readable supplier invoice.",
input_schema: {
type: "object",
properties: { reason: { type: "string", enum: REASONS } },
required: ["reason"],
additionalProperties: false,
},
},
] as const;
Every property is required on purpose: the structured outputs page caps strict schemas at 24 optional parameters and 16 union-typed ones across a request.
Build the request per route, in the gateway. One body sent to several Claude models is the thing these releases break. The same body is valid on Sonnet 5 and Haiku 5.5 and a 400 on Sonnet 5.5. So the tool mode is part of the route, and product code never sees it.
type ToolMode = "auto_strict" | "auto_plain" | "forced_strict";
type Route = { id: string; platform: "anthropic" | "bedrock"; model: string; toolMode: ToolMode };
export const INVOICE_ROUTES: Route[] = [
{ id: "sonnet-5-5", platform: "anthropic", model: "claude-sonnet-5-5", toolMode: "auto_strict" },
{ id: "sonnet-5", platform: "anthropic", model: "claude-sonnet-5", toolMode: "forced_strict" },
// Candidate since 7 October: { id: "haiku-5-5", ..., model: "claude-haiku-5-5", toolMode: "forced_strict" }
];
export function buildRequest(route: Route, messages: Message[]) {
const strict = route.toolMode !== "auto_plain";
return {
model: route.model,
max_tokens: 4096, // covers thinking as well as the tool call
system: EXTRACTION_SYSTEM_PROMPT, // "Always respond by calling exactly one tool."
messages,
tools: TOOLS.map((t) => (strict ? { ...t, strict: true } : t)),
// "any" = must call one of the two tools. Only send it to models that accept it.
tool_choice: route.toolMode === "forced_strict" ? { type: "any" } : { type: "auto" },
};
}
The fallback is Sonnet 5 because the guide says it accepts forced tool choice and it’s on the structured outputs supported-models list. The define-tools page says that on models that support forced tool use, any plus strict guarantees “both that one of your tools is called and that the tool inputs strictly follow your schema”. That’s the old guarantee, on the model you’re trying to leave, so treat it as a stopgap and watch how often it’s used.
Haiku 5.5 is the new candidate, commented out above. It’s on the same supported-models list. Two things in the docs make me cautious: a forced call gets no thinking, and Haiku 5.5 has no server-side refusal fallback, so a refusal is yours to handle. I don’t know whether it reads invoices well enough. That’s a question for the golden set.
Detect, correct once, fall back, validate.
type Read =
| { kind: "ok"; value: z.infer<typeof Invoice> }
| { kind: "no_invoice"; reason: string }
| { kind: "no_tool_call" }
| { kind: "invalid"; toolUseIds: string[]; issues: string[] }
| { kind: "refusal" | "truncated" };
function readExtraction(res: ModelResponse): Read {
if (res.stop_reason === "refusal") return { kind: "refusal" };
if (res.stop_reason === "max_tokens") return { kind: "truncated" };
const calls = res.content.filter((b) => b.type === "tool_use"); // by type, never content[0]
if (calls.length === 0) return { kind: "no_tool_call" };
const toolUseIds = calls.map((c) => c.id);
if (calls.length > 1) return { kind: "invalid", toolUseIds, issues: ["call exactly one tool"] };
const [call] = calls;
const bad = (e: z.ZodError): Read => ({ kind: "invalid", toolUseIds, issues: e.issues.map((i) => i.message) });
if (call.name === "report_no_invoice") {
const no = NoInvoice.safeParse(call.input);
return no.success ? { kind: "no_invoice", reason: no.data.reason } : bad(no.error);
}
const inv = Invoice.safeParse(call.input);
return inv.success ? { kind: "ok", value: inv.data } : bad(inv.error);
}
function correction(read: Extract<Read, { kind: "no_tool_call" | "invalid" }>) {
if (read.kind === "no_tool_call") return "Respond only by calling record_invoice or report_no_invoice.";
// The model did call a tool, so the next turn must answer each call.
return read.toolUseIds.map((id) => ({
type: "tool_result", tool_use_id: id, is_error: true,
content: `Rejected: ${read.issues.join("; ")}. Call exactly one tool with corrected values.`,
}));
}
const MAX_CORRECTIONS = 1;
export async function extractInvoice(doc: string, routes = INVOICE_ROUTES) {
const attempts: { route: string; outcome: Read["kind"] }[] = [];
for (const route of routes) {
const messages: Message[] = [{ role: "user", content: invoicePrompt(doc) }];
for (let turn = 0; turn <= MAX_CORRECTIONS; turn++) {
const res = await gateway.send(route, buildRequest(route, messages));
const read = readExtraction(res);
attempts.push({ route: route.id, outcome: read.kind });
if (read.kind === "ok") return { kind: "invoice", value: read.value, attempts } as const;
if (read.kind === "no_invoice") return { kind: "no_invoice", reason: read.reason, attempts } as const;
if (read.kind === "refusal" || read.kind === "truncated") break; // a nudge won't fix these
// Append-only: the model's turn goes back exactly as returned, thinking blocks included.
messages.push({ role: "assistant", content: res.content });
messages.push({ role: "user", content: correction(read) });
}
}
return { kind: "needs_review", attempts } as const; // exception queue: a person enters the fields
}
A few choices in there:
- One correction, then move on. The bound is config.
- The correction appends and never edits. Sonnet 5.5 thinking blocks are signed over the conversation before them. For accounts created on or after 31 August 2026, a replay after an edit to earlier history returns a 400. The Haiku guide describes the same check.
- Refusal and truncation skip the correction. For
max_tokensstops the docs say to retry with a higher value. Anthropic’s server-side fallback (beta, Claude API only) retries some Sonnet 5.5 refusal categories on Sonnet 5. Decide which layer owns that retry, and don’t run both. - Zod runs on every route, and
attemptsgoes into the per-call record from the gateway post.
Platforms and testing
On Amazon Bedrock the migration guide is explicit: structured outputs, “which include strict tool use, aren’t available for Claude Sonnet 5.5”. The Haiku 5.5 guide says the same for Haiku. So on Bedrock, Sonnet 5.5 gives you no forced call and no enforced shape, and Zod is the only check. That’s a separate route with toolMode: "auto_plain". Claude Platform on AWS is a different offering that the structured outputs page lists as generally available with no model note, so don’t let “AWS” become one flag. Google Cloud and Microsoft Foundry are listed the same way. I didn’t find a page that states Sonnet 5.5 strict-tool support on either directly, so I’d confirm with a test request.
Before switching traffic, run the old configuration (Sonnet 5, forced) and the new one (Sonnet 5.5, auto plus strict) over the same golden set: real, redacted documents with hand-checked fields, including credit notes, statements, two invoices in one PDF and poor scans. Anthropic’s own launch page is a reminder why: it mentions a pre-release deployment found “to have a bug that could degrade responses to requests that use structured outputs”. What I’d measure, per route:
- Tool-call rate, first turn and after one correction. Forced tool use made this metric pointless. Now it’s the first to watch.
- Zod pass rate and per-field correctness: exact match for IDs and amounts, normalised match for names. An aggregate hides “supplier name is wrong 1 time in 10”.
- False no-invoice rate. The way out is only safe if you measure it.
- Refusal, truncation and fallback rates, from the
attemptslog. - Cost and p95 latency per successful extraction, counting every attempt, correction and fallback.
Effort levels are recalibrated on Sonnet 5.5, so run the set at two or three of them.
What I’d do on Monday
- Grep for
tool_choice,temperature,top_p,top_k,budget_tokens,"disabled",output_formatand prefill, including token-counting calls. - Pin routes that send forced tool use to a model that accepts it, so no alias moves them to Sonnet 5.5 before you’ve tested.
- Move request construction into the gateway as a per-route
toolMode, with a unit test per route. Don’t derive it from the model family: Sonnet 5.5 and Haiku 5.5 disagree. - Add the explicit “no” tool, make strict properties required, and put Zod behind every route.
- Add the missing-call check and one bounded correction, and log each attempt.
- Run both configurations over the golden set before switching traffic, with Haiku 5.5 as a third if you want a cheaper forced route. Treat Bedrock as its own route.
Limitations
Everything cited is vendor documentation as I read it on 13 October 2026: Anthropic’s docs and launch pages, and one OpenAI model page. These pages change (one of my own claims about pattern went stale in two weeks), and per-platform availability changes most often. I haven’t run any of these configurations, the code is an unrun sketch with stand-ins for gateway.send, Message, ModelResponse and the prompts, and nothing here says anything about tool-call rates, accuracy, latency or cost on Sonnet 5.5 or Haiku 5.5.
Out of scope: other providers’ structured-output features beyond the one OpenAI sentence, JSON outputs in depth, streaming partial tool input, and batch extraction. Google Cloud and Microsoft Foundry aren’t confirmed beyond the platform-level listing.
Sources
- Migrating to Claude Sonnet 5.5
- What's new in Claude Sonnet 5.5
- Claude Sonnet 5.5 launch page
- Claude docs: Define tools (forcing tool use)
- Claude docs: Strict tool use
- Claude docs: Structured outputs
- Claude API errors
- Claude docs: Refusals and fallback
- Claude Haiku 5.5 migration guide
- Claude Haiku 5.5 launch page
- OpenAI docs: GPT-6.1 Sol model page