Claude Opus 5.5 tool_choice 400: 3 SDKs Tested (2026)
September 25, 2026

Claude Opus 5.5 rejects forced tool use: tool_choice set to {"type": "any"} or {"type": "tool"} returns a 400. Whether your agent breaks depends on the SDK between you and the API. I captured the outgoing request body from three of them to find out which.
TL;DR: The claude opus 5.5 tool_choice rejection is one of four breaking changes against code already running on Claude Opus 5.1 The official guidance is to swap forced choice for auto plus strict tool use.2 But most agent code never writes tool_choice by hand — a framework writes it. So I pointed three SDKs at a local recorder and read the bytes.
Result: @ai-sdk/anthropic 4.0.63 quietly rewrites forced choice to {"type":"auto"} and warns you. @langchain/anthropic 1.5.11 does not — its withStructuredOutput() sends {"type":"tool","name":"..."}, which the API rejects, even though you never typed tool_choice. And the AI SDK's protection is keyed to a substring match on the model ID, so claude-opus-5.5 with a dot silently loses it.
What you'll learn
- The request settings Claude Opus 5.5 rejects, with the documented error string
- Why your framework, not your code, decides whether you get a 400
- How to capture the real request body from any SDK with no API key
- What each of three SDKs actually sends for "force a tool call"
- How the AI SDK's model gate works, and the model-ID spellings that defeat it
- The one-argument fix for LangChain structured output on Opus 5.5
- A framework-agnostic preflight check, and two bugs I hit building it
What Claude Opus 5.5 rejects
Claude Opus 5.5 is Anthropic's model built for long-running agentic coding and knowledge work, priced at $4 per million input tokens and $20 per million output tokens, against Claude Opus 5's $5 and $25.1 It is cheaper. It is also stricter.
Four breaking changes affect code already running on Claude Opus 5, and the first three also apply on Claude Fable 5.1.1 Forced tool use is the one that reaches the most code, because frameworks set it on your behalf.
Here are the request-shape settings that return a 400, assembled from both official pages:12
| Setting | Rejected value | Replacement |
|---|---|---|
tool_choice | {"type":"any"}, {"type":"tool","name":...} | {"type":"auto"} + strict: true |
thinking | {"type":"disabled"}, {"type":"enabled","budget_tokens":N} | omit it; set output_config.effort |
temperature | any non-default value | prompt instructions |
top_p / top_k | any non-default value | prompt instructions |
| assistant prefill | a trailing assistant message | structured outputs |
computer_20251124 | the tool entry itself | computer_toolset_20260801 |
That table covers request-shape rejections, which is what a preflight check can catch. It is not the complete set of 400s on this model — replaying a thinking block after editing an earlier turn is a separate one, and depends on your account's creation date.1
Two details in the table are easy to miss. The computer_20251124 rejection applies on the Claude API and Google Cloud but not on Amazon Bedrock, where the older tool keeps working.1
And the tool_choice validation also runs on the token counting endpoint, so a cost-estimation call with a forced choice fails too.1 The error string is exact:
tool_choice: type "tool" and "any" are not supported for this model.
Why your framework decides whether you 400
Read the migration guide and the fix looks like a find-and-replace in your own source.2 For raw Messages API code it is. For agent code it usually is not, because the phrase "force a tool call" rarely appears as tool_choice in an application.
It appears as toolChoice: 'required'. It appears as withStructuredOutput(schema). It appears as nothing at all — a framework binding a schema-shaped tool and forcing it so the model cannot reply in prose. Your diff has no tool_choice to change.
That makes "did Opus 5.5 break my agent?" a question about your dependency versions, not your code. The most direct way to answer it is to look at the bytes leaving your process.
Capturing the request body with no API key
You do not need a key, or a single billable token, to see what an SDK sends. Every one of these clients lets you override the base URL, so a twelve-line HTTP server is enough to stand in for the API.
// recorder.mjs — a local stand-in for the Claude API.
import http from 'node:http';
export const captured = [];
const REPLY = {
id: 'msg_local', type: 'message', role: 'assistant', model: 'claude-opus-5-5',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'get_weather', input: { city: 'Paris' } }],
stop_reason: 'tool_use', stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
};
export function startRecorder() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
captured.push({ path: req.url, body: JSON.parse(raw) });
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(REPLY));
});
});
server.listen(0, '127.0.0.1', () =>
resolve({ server, url: `http://127.0.0.1:${server.address().port}` }));
});
}
Returning a tool_use block rather than text matters more than it looks. An SDK that enforces a forced choice client-side will throw if the reply contains no tool call, and that error is easy to mistake for a real rejection. I hit exactly that and had to re-run the whole matrix.
Point each client at url with a throwaway key:
const raw = new Anthropic({ apiKey: 'k', baseURL: url, maxRetries: 0 });
const anthropic = createAnthropic({ apiKey: 'k', baseURL: url });
const lc = new ChatAnthropic({ apiKey: 'k', model: 'claude-opus-5-5',
anthropicApiUrl: url, maxRetries: 0 });
Set maxRetries: 0. Left at the default, both clients retry with backoff on the errors this test provokes, and the run takes minutes instead of seconds.
What three SDKs actually send
Versions installed from the npm registry on 2026-09-25: @anthropic-ai/sdk 0.128.0, @ai-sdk/anthropic 4.0.63 with ai 7.0.114, @langchain/anthropic 1.5.11 with @langchain/core 1.2.12, on Node v22.23.2.
Every row below is the tool_choice field read off the captured body, with model: 'claude-opus-5-5':
| Call | tool_choice on the wire | Verdict |
|---|---|---|
@anthropic-ai/sdk, hand-written {type:'any'} | {"type":"any"} | 400 |
@ai-sdk/anthropic toolChoice:'required' | {"type":"auto"} | downgraded |
@ai-sdk/anthropic toolChoice:{type:'tool'} | {"type":"auto"} | downgraded |
@ai-sdk/anthropic generateObject(schema) | absent | safe |
@langchain/anthropic bindTools(t,{tool_choice:'any'}) | {"type":"any"} | 400 |
@langchain/anthropic withStructuredOutput(schema) | {"type":"tool","name":"get_weather"} | 400 |
@langchain/anthropic withStructuredOutput(…,{method:'jsonSchema'}) | absent | safe |
The raw SDK behaving as a pure transport is correct — it is a transport, and it sent what I typed. The interesting rows are the two frameworks disagreeing about the same request.
The AI SDK downgrades, and says so
@ai-sdk/anthropic 4.0.63 recognises Claude Opus 5.5 and rewrites forced choice before the request leaves. It is not silent about it. The warnings array on the result carries this, verbatim from my run:
{
"type": "unsupported",
"feature": "toolChoice",
"details": "toolChoice 'required' is not supported by this model because it rejects forced tool use. Using 'auto' instead. Instruct the model to use a tool in the prompt and verify that a tool call was made."
}
The same text is logged to the console unless you set AI_SDK_LOG_WARNINGS to false. So this failure mode is visible in a dev terminal on the first run after you flip the model ID.
The downgrade is also not the whole mechanism. If the response comes back with no tool call, the SDK throws ToolChoiceViolationError — it relaxes the constraint on the wire, then checks client-side that the model complied anyway. That is a reasonable trade: a hard 400 becomes a warning plus a post-hoc assertion.
It does change your semantics, though. toolChoice: 'required' stops being a guarantee enforced by the API and becomes a hope, verified after the fact. Retry logic written on the assumption that a tool call is impossible to skip needs a second look. The same "is the constraint I asked for still the constraint on the wire?" gap shows up in AI SDK tool drift detection, for different reasons.
The gate is a substring match
Reading dist/index.js, the protection comes from a getModelCapabilities(modelId) function whose branches are plain substring tests, checked in order. The branch bodies are elided here; only the conditions matter:
function getModelCapabilities(modelId) {
if (modelId.includes("claude-opus-5-5")) {
} else if (modelId.includes("claude-opus-5")) {
} else if (modelId.includes("claude-fable-5-1")) {
// … 10 more branches, then a final else
Exactly two of the fourteen branches set rejectsForcedToolUse: true: claude-opus-5-5 and claude-fable-5-1, the two models Anthropic lists as sharing the restriction.1 Every other branch sets it to false, including the final unknown-model fallback. So the protection holds only for IDs that literally contain one of those two substrings.
I measured six spellings of the same model, reading both the tool_choice sent and the default max_tokens — which differs per branch and so reveals which one was taken:
| Model ID passed | max_tokens | tool_choice for 'required' | Branch |
|---|---|---|---|
claude-opus-5-5 | 128000 | {"type":"auto"} | Opus 5.5 |
anthropic.claude-opus-5-5 | 128000 | {"type":"auto"} | Opus 5.5 |
claude-opus-5-5-v1:0 | 128000 | {"type":"auto"} | Opus 5.5 |
claude-opus-5.5 | 128000 | {"type":"any"} | Opus 5 |
opus-5-5 | 4096 | {"type":"any"} | unknown |
opus-5.5 | 4096 | {"type":"any"} | unknown |
The Bedrock-prefixed and version-suffixed forms still contain the substring, so they keep the protection. Good.
The last three do not, and they fail in two different ways. claude-opus-5.5 with a dot falls into the claude-opus-5 branch and is treated as the previous model. The bare opus-5-5 forms match nothing and land in the unknown-model fallback, which also hands you a 4096-token output ceiling.
None of this is exotic. Gateways, proxies and internal config files rename models constantly, and this ID reads as "5.5" in every sentence written about it while the API spells it 5-5. Get it wrong and you lose a protection you never knew you had, silently — the warning only fires when the gate matches.
LangChain JS has no gate at all
@langchain/anthropic 1.5.11 sends the same tool_choice regardless of model. I ran claude-opus-5-5, claude-opus-5 and claude-fable-5-1 through both idioms and got identical bodies every time: {"type":"any"} for bindTools(tools, { tool_choice: 'any' }), and {"type":"tool","name":"get_weather"} for withStructuredOutput(schema).
That second one is the trap. withStructuredOutput() is the ordinary way to get a typed object out of a model in LangChain. It reads like a parsing helper. Under the hood it binds your schema as a tool and forces it — so on Opus 5.5 and Fable 5.1 it is a 400, from a call site that mentions neither tools nor tool_choice.
This is not only a JavaScript problem. LangChain issue #40777, opened on 2026-09-23 and still open, reports the same class of gap in the Python package: a _supports_forced_tool_choice() helper exists and is applied to with_structured_output(), but bind_tools() and the final payload path do not consult it.3 The packages are separate and the coverage differs, so check the one you actually ship.
The LangChain fix is one argument
Ask for native structured output instead of a forced tool, and the forced choice disappears:
// 400 on claude-opus-5-5 — binds the schema as a tool and forces it
await lc.withStructuredOutput(schema, { name: 'get_weather' }).invoke('…');
// works — sends output_config.format.json_schema, no tool_choice at all
await lc.withStructuredOutput(schema, { name: 'get_weather', method: 'jsonSchema' })
.invoke('…');
With method: 'jsonSchema' the captured body carries output_config.format.json_schema and no tool_choice field. That matches the official replacement path: move the schema to structured outputs rather than forcing a tool.2 For the schema-design side of that, see Claude structured outputs with TypeScript and Zod.
Two spelling notes from the run. The option is camelCase — 'json_schema' throws a TypeError. And 'jsonMode' prints "jsonMode" is not supported for Anthropic models. Falling back to "jsonSchema", so it works, but say what you mean.
The AI SDK needs no equivalent change: generateObject() on claude-opus-5-5 already sends output_config.format.json_schema with no tools and no tool_choice.
A preflight check for any framework
Per-framework fixes age badly. What I actually wanted was one assertion that runs on the final body, whatever produced it — because the body is the only thing the API judges.
Every client here accepts a custom fetch, so one wrapper covers all of them:
// preflight.mjs
const REJECTS_FORCED_TOOL_USE = [/opus-5-5/, /fable-5-1/];
// Gateways and hand-written config spell the same model several ways, so
// compare on a normalised form rather than an exact string.
export function normalizeModelId(modelId) {
return String(modelId).toLowerCase().replace(/[._]/g, '-');
}
export function checkOpus55Request(modelId, body) {
const problems = [];
const id = normalizeModelId(modelId);
const forced = REJECTS_FORCED_TOOL_USE.some((re) => re.test(id));
const tc = body.tool_choice?.type;
if (forced && (tc === 'any' || tc === 'tool')) {
problems.push(`tool_choice.type "${tc}" is rejected; use "auto" with strict tool use`);
}
const th = body.thinking?.type;
if (th === 'disabled' || th === 'enabled') {
problems.push(`thinking.type "${th}" is rejected; omit thinking and set output_config.effort`);
}
for (const p of ['temperature', 'top_p', 'top_k']) {
if (body[p] !== undefined) problems.push(`${p} is rejected; steer with the prompt instead`);
}
const last = body.messages?.[body.messages.length - 1];
if (last?.role === 'assistant') {
problems.push('assistant prefill is rejected; use structured outputs instead');
}
for (const t of body.tools ?? []) {
if (t?.type === 'computer_20251124') {
problems.push('computer_20251124 is rejected on the Claude API and Google Cloud');
}
}
return problems;
}
export function preflightFetch(realFetch = globalThis.fetch) {
return async (input, init) => {
if (typeof init?.body === 'string') {
let body = null;
try { body = JSON.parse(init.body); } catch {}
if (body?.model) {
const problems = checkOpus55Request(body.model, body);
if (problems.length) {
const err = new Error(
`Opus 5.5 preflight failed for "${body.model}": ${problems.join('; ')}`);
err.name = 'Opus55PreflightError';
// Provider SDKs rewrap a thrown fetch error as a generic connection
// error, so keep the detail reachable on `.cause` too.
err.cause = { opus55Problems: problems, model: body.model };
throw err;
}
}
}
return realFetch(input, init);
};
}
Install it where you build the client. All three clients in this post accept the override, and I confirmed the guard fires through each of them:
new Anthropic({ baseURL, fetch: guarded }); // @anthropic-ai/sdk
createAnthropic({ baseURL, fetch: guarded }); // @ai-sdk/anthropic
new ChatAnthropic({ clientOptions: { fetch: guarded } }); // @langchain/anthropic
What the preflight catches
Six probes, same guard, real output:
PASS ai-sdk toolChoice:'required' (claude-opus-5-5)
BLOCK ai-sdk toolChoice:'required' (claude-opus-5.5 alias)
tool_choice.type "any" is rejected; use "auto" with strict tool use
BLOCK langchain withStructuredOutput (claude-opus-5-5)
tool_choice.type "tool" is rejected; use "auto" with strict tool use
BLOCK langchain bindTools tool_choice:'any' (claude-opus-5-5)
tool_choice.type "any" is rejected; use "auto" with strict tool use
PASS langchain bindTools (no tool_choice) (claude-opus-5-5)
PASS langchain bindTools tool_choice:'any' (claude-opus-5)
The first row passes because the AI SDK already downgraded — there was nothing rejectable left to catch. The last row passes because forced choice is genuinely fine on Claude Opus 5. Both are the behaviour I want: the check fires on what the API would reject, not on what the code looks like.
Two bugs I hit building it
Neither was in the SDKs. Both are worth knowing if you write your own version.
The first draft let the dotted alias through. I wrote the pattern as /claude-opus-5-5/, matching the model ID Anthropic documents — which is exactly the mistake the AI SDK's own substring gate makes. claude-opus-5.5 sailed past my check and the SDK's. Normalising . and _ to - before matching, and dropping the claude- prefix from the pattern, fixed it.
The error message vanished. A guard that throws inside fetch does not surface cleanly: the provider SDK catches it and rewraps it, so LangChain reported a bare Connection error. with my text gone. Attaching the detail to err.cause and walking the cause chain in the handler recovers it:
let c = e, detail = null;
for (let i = 0; i < 4 && c; i++) {
if (c.opus55Problems) { detail = c.opus55Problems; break; }
c = c.cause;
}
If you only ever read error.message, a fetch-level preflight will look like a network fault. That is a worse debugging experience than the 400 it replaces, so wire the cause chain before you rely on it.
What this does not prove
No API key was used anywhere in this post, and no request reached Anthropic. The rejection rules are quoted from the official pages, fetched on 2026-09-25;12 what I measured is what each SDK puts on the wire. I have not observed a live 400 from Claude Opus 5.5 myself.
The measurements are pinned to the versions listed above. The AI SDK's gate is present by 4.0.63 — I did not bisect which release introduced it — and LangChain's JS package may well add one. Re-run the recorder against your own lockfile rather than trusting this table in a month.
Everything here is the JavaScript ecosystem. The Python packages are separate code with separate coverage, as issue #40777 shows.3 And I tested one dimension, forced tool choice. The other five rejection rules are in the preflight but I did not survey which frameworks trip them.
Bottom line
The migration guide tells you what to change in your code. On an agent stack the more useful question is what your dependencies change on your behalf, and the answer differed sharply between two popular SDKs on the same request.
One had already shipped a model-gated downgrade and a clear warning. The other sends the rejected shape from its most ordinary structured-output call. Neither fact is discoverable from your own diff, which is why the recorder is worth the twelve lines — and why the preflight belongs on the body, where the API's judgement actually lands.
If you are flipping a model ID this week, capture your wire first. For the agent-loop mechanics around tool_choice more generally, see Claude tool use and the agentic loop in TypeScript.
Footnotes
-
Anthropic, "What's new in Claude Opus 5.5" — breaking changes, exact error strings, pricing, and the Fable 5.1 note. https://platform.claude.com/docs/en/models/opus-5-5/whats-new-opus-5-5 (fetched 2026-09-25) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11
-
Anthropic, "Migrating to Claude Opus 5.5" — the full list of settings every request must satisfy, and the before/after for forced tool use. https://platform.claude.com/docs/en/models/opus-5-5/migration-guide (fetched 2026-09-25) ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
langchain-ai/langchain issue #40777, "
ChatAnthropicsends unsupported tool choice and thinking configurations for Claude Opus 5.5", opened 2026-09-23, open at the time of writing. https://github.com/langchain-ai/langchain/issues/40777 (fetched 2026-09-25) ↩ ↩2
