Skip to content

[BUG] tool_addition blocks are sent to a custom ANTHROPIC_BASE_URL, rejected with 400, and silently retried on every session start #95087

Open

Description

@Jiangw2718i

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 carries tool_addition content blocks (beta mid-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

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

  1. Serve a model behind an Anthropic-compatible endpoint that requires input_schema on every tools[] entry (vLLM's /v1/messages does; AnthropicTool.input_schema in vllm/entrypoints/anthropic/protocol.py is a required field).
  2. Point Claude Code at it with ANTHROPIC_BASE_URL, and set ENABLE_TOOL_SEARCH=true.
  3. Run any task, for example claude -p "list the .py files in this directory".
  4. The session completes normally.
  5. 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

  1. Jiangw2718i commented on Sep 17, 2026

    @Jiangw2718i
    Author

    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, custom ANTHROPIC_BASE_URL)

    tools: 15 entries, 0 without input_schema (4 have defer_loading: true, and all 4 carry an input_schema)
    
    messages[0]  role=user    [text, text, text]
    messages[1]  role=system  [text, tool_addition, tool_addition, tool_addition]
    
    {"type": "tool_addition", "tool": {"type": "tool_reference", "name": "mcp__..."}}

    The request carries mid-conversation-tool-changes-2026-07-01 in anthropic-beta. vLLM's /v1/messages does not know the tool_addition block type and rejects the whole request:

    {'type': 'literal_error', 'loc': ('body', 'messages', 1, 'content', 'list[AnthropicContentBlock]', 1, 'type'),
     'msg': "Input should be 'text', 'image', 'tool_use', 'tool_result', 'tool_reference', 'thinking' or 'redacted_thinking'",
     'input': 'tool_addition', ...}
    

    What the silent retry changes

    Comparing the rejected request with the retry that gets the 200: tools and system are identical. Two things differ, and only these two:

    • messages[1] is rewritten without the tool_addition blocks; the three tools are instead listed with the other deferred tools in its text block
    • mid-conversation-tool-changes-2026-07-01 is gone from anthropic-beta; every other beta is unchanged

    So 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

    result
    captured request, validated offline against the server's request model rejected, all errors on messages[1]
    same request with the three tool_addition blocks removed validates
    2.1.273 and 2.1.274 same shape, same error
    ENABLE_TOOL_SEARCH=false no tool_addition blocks, no 400

    What in the original report is wrong

    • "deferred tool entries that have no input_schema": no such entries exist in any request I captured.
    • The curl error I quoted (body.tools.0.input_schema) came from a hand-written payload of mine, not from Claude Code.
    • The "~163 entries" extrapolation: the ~20 KB error body is the server echoing the message content back, not a per-tool error list.
    • The third suggestion under "What Should Happen" (omit schema-less entries) is moot.
    • The relation to [BUG] Deferred custom tools break non-Anthropic models behind custom ANTHROPIC_BASE_URL #89211 was my assumption based on the wrong cause. I have not checked whether that report is the same mechanism.

    What I would ask for instead

    Gate the tool_addition path 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 custom ANTHROPIC_BASE_URL with 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=262144 works: the session envelope then reports contextWindow: 262144 instead of 200000.
    • The prefill comparison did not hold up. A second pair of runs reversed the direction, because the "off" side is dominated by prefix-cache hit-rate jitter on a ~290K-token prompt total. I would not draw a conclusion from those numbers in either direction.

    Server side is corrected in the same way at vllm-project/vllm#57324.

  2. changed the title [-][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[/+] on Sep 17, 2026
  3. OUENMING commented on Oct 13, 2026

    @OUENMING

    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), custom ANTHROPIC_BASE_URL (a local passthrough in front of OpenCode Go / opencode.ai/zen/go/v1/messages, model deepseek-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:

    messages[1]  role=system  [ text, tool_addition, tool_addition, tool_addition ]
    
    {"type":"tool_addition","tool":{"type":"tool_definition","definition":{
       "name":"mcp__claude_ai_Claude_Docs__batch","description":"...","input_schema":{...}}}}
    

    anthropic-beta carried mid-conversation-system-2026-04-07, mid-conversation-tool-changes-2026-07-01 and inline-tools-2026-09-15.

    How the gateway rejects it. Not with a validation error naming the block - with a generic one:

    422 {"type":"error","error":{"type":"api_error","message":"Upstream request failed: Endpoint is unavailable."}}
    

    What the client does with that. In an instrumented run, exactly one request went out and Claude Code gave up:

    [log_ad913f] post http://127.0.0.1:.../v1/messages?beta=true failed with status 422 in 1901ms - error; not retryable
    API Error: 422 Upstream request failed: Endpoint is unavailable.
    

    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:

    • every new user message fails with the same 422
    • /compact fails the same way
    • claude --resume <session-id> in a brand-new process, reading the same transcript, fails the same way
    • a brand-new session against the same gateway, key and model works fine

    So 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_addition block 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-tools betas 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.

  4. TheHyperist-hub commented on Oct 27, 2026

    @TheHyperist-hub

    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 /anthropic endpoint, model deepseek-flash[1M]. A session that had been running normally began failing on every request mid-session (the request started carrying newly-registered deferred tools); 422 on every subsequent turn, on /compact, and on claude --resume in 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:

    422 {"error":{"message":"Failed to deserialize the JSON body into the target type: messages[256]: unknown variant `tool_definition`, expected `tool_reference` at line 1 column 1234884 (request_id: ...)","type":"invalid_request_error",...}}
    

    Minimal repro against the real endpoint, no Claude Code involved (same second, same key, same model — controls like @OUENMING's):

    • system message carrying [{"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``
    • byte-identical request with the block removed and the tool declared in tools[] → 200 with a normal tool_use response

    So 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 substring tool_definition. A looser rule ("any 4xx whose body mentions tool_definition while the request carried the inline-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) minus CLAUDE_CODE_INLINE_TOOLS; nothing inspects what ANTHROPIC_BASE_URL points at before emitting the new block type. CLAUDE_CODE_INLINE_TOOLS=false re-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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions