§ Writing13 min read

Sonnet 5.5 ends forced tool use. Here's how to keep structured output reliable

Sonnet 5.5 rejects forced tool_choice with a 400; Haiku 5.5 still accepts it. How to keep extraction reliable with auto plus strict tools, a retry and an eval.

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_choice of type any or tool. 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 (any or a named tool), but the response starts with the tool call and has no thinking block.

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_tokens stops 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 attempts goes 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 attempts log.
  • 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

  1. Grep for tool_choice, temperature, top_p, top_k, budget_tokens, "disabled", output_format and prefill, including token-counting calls.
  2. 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.
  3. 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.
  4. Add the explicit “no” tool, make strict properties required, and put Zod behind every route.
  5. Add the missing-call check and one bounded correction, and log each attempt.
  6. 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


  1. 12 min read

    More code, same software: review is the bottleneck

    A Harvard study of 718 firms finds coding agents raise code volume but not shipped output, because review became the constraint. What I'd copy from finance ops.


  2. 11 min read

    The outage was in billing

    Two Claude incidents refused requests because of account state, on every model at once. Fallback chains need model, provider and account error classes.


  3. 12 min read

    The model gateway: routing, timeouts, retries and what they cost

    Product code should make one call with one contract. A thin gateway underneath owns routing, timeouts, retries, fallbacks and the log of what each call cost.