Repository navigation
Description
Correction from the author (2026-09-17): the cause given below is wrong. No request from Claude Code contains a tool without
input_schema. The first request is rejected because it carriestool_additioncontent blocks (betamid-conversation-tool-changes-2026-07-01) that the endpoint does not accept; the silent retry drops the blocks and the beta header. Captured request, headers and checks are in this comment. The symptom described below is accurate; the cause, the quoted curl error and the ~163-entry extrapolation are not. Original text kept unchanged for the record.
Preflight Checklist
- I have searched existing issues. [BUG] Deferred custom tools break non-Anthropic models behind custom ANTHROPIC_BASE_URL #89211 reports the same root cause with a different symptom; this is a separate manifestation, described below.
- This is a single bug report
- I am using the latest version of Claude Code (2.1.274)
What's Wrong?
Related to #89211, different symptom, and quieter.
With ENABLE_TOOL_SEARCH=true behind a custom ANTHROPIC_BASE_URL, the first request of every session carries deferred tool entries that have no input_schema. A strict Anthropic-compatible server rejects the whole request at validation. Claude Code then swallows the 400, resends with full schemas, and the retry succeeds.
The session completes normally. Nothing appears on the command line, claude -p --output-format json reports is_error: false, and the transcript records no error. The only trace is server-side.
#89211 describes the visible form of this, where the provider returns a specific "Deferred custom tools are only supported on…" message and the session stalls until the user repeatedly types continue. Against vLLM the failure is invisible instead, so the cost is paid silently on every session start: one rejected round-trip plus a ~20 KB error body.
What Should Happen?
Option 1 from #89211 — detect whether the endpoint handles deferred tools before enabling tool search — would fix this case.
Option 3 from that report, a startup warning, would not help here. Nothing looks wrong to the user, so a warning about a failure they never see is easy to dismiss.
A third possibility specific to this shape: omit schema-less entries from tools[] entirely until they are revealed, rather than sending names that a strict server cannot validate.
Error Messages/Logs
Proxy log in front of the server, showing the rejection and its size:
[INFO] Request ... "POST /v1/messages HTTP/1.1" 400 20263 "claude-cli/2.1.274 (external, sdk-cli)" 65.576149ms
[INFO] Request ... "POST /v1/messages HTTP/1.1" 200 11151 "claude-cli/2.1.274 (external, sdk-cli)" 12.540035545s
Server log, same connection, 400 then 200:
INFO: ... - "POST /v1/messages?beta=true HTTP/1.1" 400 Bad Request
INFO: ... - "POST /v1/messages?beta=true HTTP/1.1" 200 OK
The server's validation error, reproduced directly with curl:
{"error":{"message":"1 validation error:\n {'type': 'missing', 'loc': ('body', 'tools', 0, 'input_schema'), 'msg': 'Field required', 'input': {'type': 'tool_search_tool_20250101', 'name': 'tool_search'}}","type":"Bad Request","param":"body.tools.0.input_schema","code":400}}
Measured over one afternoon on 2.1.273: 29 × 200 and 3 × 400. Each 400 landed on the first request of one of the three sessions that announced deferred tools, matching to the second; the four sessions without deferred tools produced none. All three bodies were 20214 bytes. After upgrading to 2.1.274 the behaviour is unchanged (the 20263-byte rejection above).
I did not capture the request body. Sending N name-only tools to the same server grows the error body linearly at ~123 bytes each (1 → 234, 10 → 1343, 30 → 3823, 60 → 7543, 100 → 12504), so 20214 bytes extrapolates to roughly 163 entries; the session transcript recorded total_deferred_tools: 148.
Steps to Reproduce
- Serve a model behind an Anthropic-compatible endpoint that requires
input_schemaon everytools[]entry (vLLM's/v1/messagesdoes;AnthropicTool.input_schemainvllm/entrypoints/anthropic/protocol.pyis a required field). - Point Claude Code at it with
ANTHROPIC_BASE_URL, and setENABLE_TOOL_SEARCH=true. - Run any task, for example
claude -p "list the .py files in this directory". - The session completes normally.
- Check the server log. The first request of the session was rejected with 400 and immediately retried.
Server-side tracking issue: vllm-project/vllm#57324.
Claude Model
Other
Is this a regression?
I don't know
Claude Code Version
2.1.274
Platform
Other
Operating System
macOS
Terminal/Shell
Other
Additional Information
Endpoint is vLLM serving Qwen3.8-Flash-Next, --max-model-len 262144 --enable-prefix-caching, with llama-swap in front.
Two observations from the same setup, both minor next to the above:
contextWindow is reported as 200000 in the session envelope while the server was started with --max-model-len 262144, because the model name is not in the client's catalog. The startup warning names behavesAs and CLAUDE_CODE_MAX_CONTEXT_TOKENS as remedies; I have not tested either.
Whether tool search is worth enabling here is a separate question from this bug. On this setup it measured cheaper overall despite the extra round-trip: at equal work (6 turns, 5 engine requests, same final answer), 11,046 tokens of real prefill with it on against 16,605 with it off, even though the prefix cache hit rate fell from 94% to 80%.
Activity
Jiangw2718i commentedon Sep 17, 2026
Correction from the author: the cause I gave in this report is wrong. The symptom is as described (first request of every session rejected, silent retry, nothing on the command line). The cause is not schema-less entries in
tools[]. I have now captured the actual request, which I had not done when I filed this.What Claude Code actually sends (2.1.273 and 2.1.274,
ENABLE_TOOL_SEARCH=true, customANTHROPIC_BASE_URL){"type": "tool_addition", "tool": {"type": "tool_reference", "name": "mcp__..."}}The request carries
mid-conversation-tool-changes-2026-07-01inanthropic-beta. vLLM's/v1/messagesdoes not know thetool_additionblock type and rejects the whole request:What the silent retry changes
Comparing the rejected request with the retry that gets the 200:
toolsandsystemare identical. Two things differ, and only these two:messages[1]is rewritten without thetool_additionblocks; the three tools are instead listed with the other deferred tools in its text blockmid-conversation-tool-changes-2026-07-01is gone fromanthropic-beta; every other beta is unchangedSo the client already has a working fallback for an endpoint that does not support this beta. It just discovers the need for it by getting a 400, once per session, every session.
Checks
messages[1]tool_additionblocks removedENABLE_TOOL_SEARCH=falsetool_additionblocks, no 400What in the original report is wrong
input_schema": no such entries exist in any request I captured.body.tools.0.input_schema) came from a hand-written payload of mine, not from Claude Code.What I would ask for instead
Gate the
tool_additionpath on the endpoint/model rather than finding out through a rejected request. A comment on #81967 reports that some first-party models also answer this beta with a 400, so a per-model gate seems to be needed anyway; a customANTHROPIC_BASE_URLwith a model name outside the catalog looks like a case where it should default to off, or at least where a rejection should be remembered beyond the current session.Two smaller corrections to "Additional Information":
CLAUDE_CODE_MAX_CONTEXT_TOKENS=262144works: the session envelope then reportscontextWindow: 262144instead of 200000.Server side is corrected in the same way at vllm-project/vllm#57324.
[-][BUG] Deferred tools are sent without input_schema, rejected, and silently retried behind a custom ANTHROPIC_BASE_URL[/-][+][BUG] tool_addition blocks are sent to a custom ANTHROPIC_BASE_URL, rejected with 400, and silently retried on every session start[/+]OUENMING commentedon Oct 13, 2026
Independent reproduction on a different gateway, with a failure mode that is worse than the one described here: the fallback never fires, and the conversation becomes permanently unusable.
Setup. Claude Code 2.1.296 (
claude --version; latest published at the time of writing), customANTHROPIC_BASE_URL(a local passthrough in front of OpenCode Go /opencode.ai/zen/go/v1/messages, modeldeepseek-v4.1-flash). Session had run for days and had been compacted 20 times; context ~181K tokens.What was on the wire - captured from the client, same shape as your capture but a newer block variant:
anthropic-betacarriedmid-conversation-system-2026-04-07,mid-conversation-tool-changes-2026-07-01andinline-tools-2026-09-15.How the gateway rejects it. Not with a validation error naming the block - with a generic one:
What the client does with that. In an instrumented run, exactly one request went out and Claude Code gave up:
Headers on that request included
x-stainless-retry-count: "0"; there was no resend without the blocks and no resend without the betas, unlike the vLLM case you documented where the retry succeeds.Consequence. Because the block lives in the transcript, the conversation is stuck for good:
/compactfails the same wayclaude --resume <session-id>in a brand-new process, reading the same transcript, fails the same waySo a long session is unrecoverable client-side. That is a bigger cost than one wasted round-trip at session start: a 181K-token working session is effectively destroyed, and nothing in the CLI explains why.
The block is provably the trigger, independent of Claude Code. A hand-written 5-line request to the gateway containing a single
tool_additionblock returns the same 422; the byte-identical request with that block swapped for{"type":"text","text":"note"}returns 200, in the same second, same key, same model. So it is not schemas, tool count, context size, thinking,context_management, images or request size - I ruled each of those out with controls.Ask. The fallback appears to need a rejection it can recognize. Could it also fire when the request carried
mid-conversation-*/inline-toolsbetas and the endpoint returned an unrecognized 4xx/5xx, i.e. treat "unexplained rejection while carrying these betas" as grounds to resend without the blocks and the betas? Failing that, surfacing anything actionable - the block type, the tool name, or even "retrying without mid-conversation tool changes" - would turn a dead session into a diagnosable one.Workaround, verified on the same transcript:
CLAUDE_CODE_INLINE_TOOLS=false(found in.local/share/claude/versions/2.1.296) stops the client from emitting inline tool definitions; the identical 122 MB / ~180K-token conversation that returned 422 on every request returns 200 with it set.Filed the gateway side separately: anomalyco/opencode#55187.
TheHyperist-hub commentedon Oct 27, 2026
Independent confirmation on DeepSeek's official Anthropic-compatible endpoint (
https://api.deepseek.com/anthropic), on current 2.1.311 — same failure mode as @OUENMING's, plus one detail that sharpens the ask.Setup. Claude Code 2.1.311,
ANTHROPIC_BASE_URL→ local provider-switcher passthrough → DeepSeek's/anthropicendpoint, modeldeepseek-flash[1M]. A session that had been running normally began failing on every request mid-session (the request started carrying newly-registered deferred tools);422on every subsequent turn, on/compact, and onclaude --resumein a fresh process reading the same transcript. A brand-new session against the same endpoint/key/model works.DeepSeek's rejection is machine-matchable — and the client still misses it. Unlike the generic gateway error above, DeepSeek names the block type; it just doesn't use Anthropic's exact phrasing:
Minimal repro against the real endpoint, no Claude Code involved (same second, same key, same model — controls like @OUENMING's):
[{"type":"tool_addition","tool":{"type":"tool_definition","definition":{"name":"late_tool","description":"x","input_schema":{"type":"object","properties":{}}}}}]→422 unknown variant \tool_definition`, expected `tool_reference``tools[]→200with a normaltool_useresponseSo the fallback detector — which in 2.1.311 requires
(status===400||status===422) && /Input tag 'tool_definition'/.test(body), Anthropic's exact wording — rejects an error message that literally contains the substringtool_definition. A looser rule ("any 4xx whose body mentionstool_definitionwhile the request carried theinline-tools/mid-conversation-*betas → resend without the blocks and the betas") would have caught the original Anthropic case, this one, and most future phrasings, at essentially zero false-positive cost.Gate detail, from the 2.1.311 binary: inline tools are enabled purely by the statsig gate
tengu_foamy_spring(default-on when the flag service is unreachable) minusCLAUDE_CODE_INLINE_TOOLS; nothing inspects whatANTHROPIC_BASE_URLpoints at before emitting the new block type.CLAUDE_CODE_INLINE_TOOLS=falsere-verified as the workaround on this endpoint — the same conversation that 422'd on every request returns 200 with it set (we set it per-provider in our provider-switcher config so first-party endpoints keep the feature).