A streamed tool_use block arrives as content_block_start with input literally {}, a run of input_json_delta fragments whose first one is an empty string, then content_block_stop and a message_delta carrying stop_reason tool_use. input_json_delta is a response-side delta type and is not among the tags accepted inside messages[].content, so replaying it raw produces a 400 that names the exact array position, as filed against MaxKB v2.10.4-lts on 2026-07-21 and still open. Five more breaks of the same assembly are documented in public reports: an empty text block that trips the minLength 1 rule, a correct tool_use block emitted under stop_reason end_turn on LiteLLM v1.93.0, plain text mis-emitted as empty input_json_delta events on LiteLLM v1.85.0, content_block_stop fired before the accumulated JSON parses, and a measured 329.5 second silence produced by the documented default that buffers each parameter value before streaming it back. Setting eager_input_streaming to true on that tool removes the buffering step, and nothing client-side can. vLLM v0.27.1's own Anthropic entrypoint opens tool blocks with input {} and maps tool_calls to stop_reason tool_use correctly, but emits content_block_stop with no check that the accumulated arguments parse. The Python, TypeScript, Go, Java, C# and Ruby SDKs all ship an accumulator, so hand-rolling one is only required when you are the translation layer. Record one SSE capture of a real tool call this week, add a truncated variant that never sends content_block_stop, and replay both against your gateway.
The error, verbatim, from a bug report filed on 21 July 2026:
BadRequestError: Error code: 400 - {'type': 'error', 'error': {'type': 'invalid_request_error',
'message': "messages.1.content.1: Input tag 'input_json_delta' found using 'type' does not match any of the expected tags"}}input_json_delta does not match any of the expected tags is not a schema quirk and not a model problem. It says a raw streaming delta was posted back inside messages: a response-side delta type carrying a partial_json string fragment, which nothing accepts on the request side.
Five of the six failures below sit in the same place: code that is not a first-party SDK assembling a streamed tool_use block, which means a gateway, a proxy, or an inference server terminating its own /v1/messages. The sixth is documented server-side behavior that gets misread as a gateway stall. Each is pinned to an open report or to source read at a released tag.
The sibling failure, where the block is assembled but the ids never pair up, is tool_use ids found without tool_result blocks. Broader agent architecture lives in the building production AI agents pillar.
The wire sequence, and the one line everything else depends on
Anthropic's streaming docs publish the exact events for a tool call. Trimmed to one block:
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01T1x1fJ34qAmk2tNTrN7Up6","name":"get_weather","input":{}}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"location\":"}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" \"San"}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" Francisc"}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"o,"}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" CA\"}"}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":89}}Three details decide whether your translator works. The input on content_block_start is literally {}, a placeholder marking the slot in the content array while the delta strings build the real value. The first partial_json is an empty string. And the boundaries are arbitrary, splitting Francisco across two events, so no fragment parses alone. The deltas are strings, the final tool_use.input is always an object, and the object you send back in messages never exists on the wire. You construct it.
Each block's index matches its position in the final Message content array, and server_tool_use blocks stream input_json_delta fragments the same way. A translator switching only on type == "tool_use" drops server-side tool blocks.
| Failure | What you see | Where it was observed | Fix |
|---|---|---|---|
Raw delta replayed into messages | 400: Input tag 'input_json_delta' found using 'type' does not match any of the expected tags | MaxKB v2.10.4-lts, issue filed 2026-07-21 | Assemble partial_json into a tool_use block with id, name and an object input |
| Empty text block replayed | 400: messages: text content blocks must be non-empty | Same MaxKB report; visible in LiteLLM v1.93.0 output | Drop zero-length text blocks; text has minLength 1 |
Correct block, wrong stop_reason | end_turn with a tool_use block present; downstream reports tools=0 | LiteLLM v1.93.0 with ollama_chat, issue filed 2026-07-26 | Set stop_reason to tool_use when the message carries one |
| Text emitted as tool deltas | Repeated input_json_delta with partial_json "" and no streamed text | LiteLLM v1.85.0 over vLLM v0.21.0, issue filed 2026-05-29 | Treat an empty tool_calls array as no tool call; route delta.content to text_delta |
content_block_stop before the JSON closes | Client sees input {} or malformed input | Requested for Dynamo in an issue filed 2026-07-28; absent in vLLM v0.27.1 | Hold the stop event until the accumulation parses, or fail the call |
| Server-side buffering on a large argument | Connection open, zero bytes, then the whole argument at once | Measured at 329.5s of silence on a 35,899 output-token response | Set eager_input_streaming true on the tool whose argument is large |
Failure 1: replaying the raw delta into message history
The report behind that error came from MaxKB, reproduced on v2.10.4-lts and v2.10.2-lts with claude-sonnet-5. Reading apps/application/flow/tools.py at tag v2.10.4-lts confirms the cause first-hand: the tool-streaming path carries tool_call_chunks, tool_calls and invalid_tool_calls, and no occurrence of input_json_delta, partial_json or content_block. It understands OpenAI-shaped deltas only. The same agent and prompt on an OpenAI-shaped provider with gpt-4o-mini returns the tool's value.
Two things make it worse than a loud 400. The product returns the BadRequestError text as HTTP 200 with that text as the answer body, so end users read the exception as the assistant's reply. And tools on that path require stream true, so no non-streaming workaround exists. The one maintainer comment, dated 2026-08-12, says they have no Anthropic account and cannot reproduce it.
The shape that produced it:
"messages": [
{"role": "user", "content": "How much is the DJI Neo?"},
{"role": "assistant", "content": [
{"type": "text", "text": "Let me check the price."},
{"type": "input_json_delta", "partial_json": "{\"query\": \"DJI Neo\"}"}
]}
]messages.1.content.1 is the second message and its second content block: the raw delta sitting where an assembled block belongs. The corrected turn keeps both blocks in order, so you can diff them:
{
"role": "assistant",
"content": [
{"type": "text", "text": "Let me check the price."},
{
"type": "tool_use",
"id": "toolu_01T1x1fJ34qAmk2tNTrN7Up6",
"name": "live_price",
"input": {"query": "DJI Neo"}
}
]
}The id is the one content_block_start carried, not a fresh one. A request-side tool_use block requires id matching ^[a-zA-Z0-9_-]+$, name of 1 to 200 characters, and input typed as an object. input_json_delta is not among the tags accepted inside messages[].content:
That union grows as beta tool families ship, so validate against membership rather than a frozen list. No delta type name belongs in it: input_json_delta carries partial_json for tool_use and server_tool_use, text_delta carries text, and thinking_delta and signature_delta carry the two halves of a thinking block. Each names a stream event, not a block.
| Family | Accepted type values inside messages[].content |
|---|---|
| Text and media | text, image, document, search_result |
| Reasoning replayed back | thinking, redacted_thinking |
| The tool exchange | tool_use, tool_result |
| Server tool blocks replayed back | server_tool_use, web_search_tool_result, web_fetch_tool_result, code_execution_tool_result, bash_code_execution_tool_result, text_editor_code_execution_tool_result, tool_search_tool_result |
| Container references | container_upload |
Failure 2: the empty text block that rides along with it
The same MaxKB defect throws a second, different 400: messages: text content blocks must be non-empty. The report treats it as the other variant of one failure, and the single OpenAI-shaped code path above explains both: the unassembled delta goes out verbatim and the text block beside it is empty.
The Messages API declares text on a text content block as a string with minLength 1, so an empty text block is rejected rather than ignored. A translator that opens a block at index 0 for streamed prose, gets none because the turn was a tool call, and serializes it anyway fails validation even when the tool_use block beside it is perfect. The LiteLLM report in Failure 3 shows that block on the wire, {"type": "text", "text": ""} at index 0 followed immediately by content_block_stop.
The fix is a filter at assembly time: drop any text block whose accumulated string has length zero, so it never enters the stored transcript.
Failure 3: the right tool call under the wrong stop_reason
An open report filed 2026-07-26 against LiteLLM v1.93.0 on the ollama_chat provider behind /v1/messages shows a turn where the tool block is correct and only the framing is wrong. The log carries content_block_start at index 2 with {"type": "tool_use", "id": "...", "name": "read", "input": {}}, one content_block_delta with {"type": "input_json_delta", "partial_json": "{\"path\": \"/tmp/ab/report.txt\"}"}, content_block_stop, then message_delta with {"stop_reason": "end_turn"}.
Anthropic-shaped tool runners key off stop_reason == "tool_use" to decide whether to execute the block. Under end_turn the turn reads as final assistant text and the tool never runs. The reported effect is payloads=0, tools=0 and an agent saying it could not generate a response. The identical request with stream false returns stop_reason tool_use with the full input, which puts the break in the streaming translation path, and it reproduces deterministically from a single-tool request of roughly 284 input tokens up to 40 tools and roughly 27,900 input tokens.
If you own the translator, set stop_reason to tool_use whenever the streamed message carries a tool_use block, whatever the upstream finish reason was called. If you own the client, this is one of the shapes that makes an agent look finished when it did nothing, the problem in detecting agents that report success without acting.
Failure 4: text emitted as tool deltas because tool_calls was present but empty
The mirror image is an open report filed 2026-05-29 against LiteLLM Proxy v1.85.0 in front of a vLLM v0.21.0 backend serving DeepSeek V4 Pro. No tool is involved: the response is plain text, and the symptom is an Anthropic-compatible client receiving no streamed text at all. On /v1/chat/completions the same stack streams correctly with delta.content; on /v1/messages it emits repeated {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}} until the message ends. Every one of those fragments is empty, so the answer's characters are dropped rather than misrouted.
The reporter's hypothesis is that the adapter treats the presence of a tool_calls key as a tool-use signal even when the array is empty; the upstream chunk shown is {"delta": {"content": "1\n", "tool_calls": []}}. That has not been confirmed against the adapter source, so treat it as a lead, not a root cause. The remedy is safe either way: branch on a non-empty tool_calls array, and route delta.content to text_delta. Why the array is empty at all is covered in why a vLLM backend returns an empty tool_calls array.
Failure 5: content_block_stop before the JSON is finished
An open request filed 2026-07-28 against Dynamo asks for one guarantee: do not emit content_block_stop for a tool_use block until the accumulated input_json_delta fragments form complete JSON. Some Anthropic clients treat that event as the point where the tool input is final, so sending it early leads them to discard trailing fragments or observe {}.
Closing a block runs through a generic stop helper that emits content_block_stop unconditionally. That is the guarantee the Dynamo issue asks for, absent from the release read above.
That gap matters more inside a private perimeter than on a hosted endpoint. A regulated deployment running an Anthropic-shaped agent runtime against its own GPUs terminates /v1/messages on the inference server's entrypoint or on a translating proxy, and both are the code that gets this wrong. A hosted endpoint answers a malformed replay with a 400 you can log; a self-hosted path may accept the same array and run a tool with the wrong arguments, or none at all, which is an agent that quietly loses its tools rather than a loud rejection. Which product sits in that seat is covered in choosing the gateway in front of your models.
What a self-hosted Anthropic endpoint does and does not do
vLLM ships its own Anthropic Messages endpoint under vllm/entrypoints/anthropic/. Reading serving.py at tag v0.27.1, released 2026-08-11:
| Behavior | vLLM v0.27.1 Anthropic entrypoint | Consequence for your accumulator |
|---|---|---|
content_block_start carries input {} | Yes, emitted as input={} | The placeholder contract holds, so index-keyed buffering works |
stop_reason on a tool turn | Mapped through stop_reason_map, tool_calls becomes tool_use | Correct, unlike some proxy paths |
partial_json fragmentation | Forwarded verbatim from tool_call.function.arguments, never accumulated | Boundaries are the OpenAI tool parser's, not Anthropic's key-at-a-time pattern |
Parse check before content_block_stop | None | The stop event is not a completeness signal; guard the parse yourself |
Failure 6: the multi-minute silence is documented default behavior, not a stall
An open report filed 2026-07-28 in the copilot-cli repository measured the same request twice against an Anthropic-shaped /v1/messages endpoint with claude-opus-5, streaming, varying only max_tokens.
Time to first byte in the first run was 3.8 seconds, after which the connection sat open with zero bytes for 329.5 seconds and then delivered all 85,744 characters of tool arguments in about 3.3 seconds. Both output_tokens figures are totals for the response, not the size of the tool argument, and the per-token column is the reporter's own arithmetic.
The second run is the instructive one. Capping max_tokens at 8,000 did not make the tool call arrive sooner, it made it arrive never: the turn stopped at max_tokens with zero characters delivered. Anthropic's guidance is to retry with a higher max_tokens, because a partially streamed tool call cannot be resumed.
The mechanism is documented, and it is not a proxy. Anthropic's fine-grained tool streaming page states that without eager_input_streaming the API buffers and validates each parameter value before streaming it back, so nothing prints for a large parameter until the model has finished it. Current models also emit one complete key and value from input at a time, so gaps between events are expected. ping events are no heartbeat: the docs promise only that streams may include any number of them, and the measured run saw zero. The abort reported here is a threshold inside the caller, not an API timeout, surfaced by Claude Code as a stalled mid-stream response after 300 seconds without content events.
The fix is one field on the tool definition, and it is not a beta feature.
curl https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-opus-5",
"max_tokens": 65536,
"tools": [
{
"name": "make_file",
"description": "Write text to a file",
"eager_input_streaming": true,
"input_schema": {
"type": "object",
"properties": {
"filename": {"type": "string"},
"lines_of_text": {"type": "array"}
},
"required": ["filename", "lines_of_text"]
}
}
],
"messages": [{"role": "user", "content": "Write a long poem into a file called poem.txt"}],
"stream": true
}'The field is tri-state, per the Python SDK's own type: true streams the parameters incrementally with types inferred on the fly, false keeps that tool buffered even when the fine-grained-tool-streaming beta is active, and null follows the default from beta headers. The legacy fine-grained-tool-streaming-2025-05-14 header still enables it for tools that leave the field unset, but the per-tool field replaces it, and that header cannot be combined with a computer use or browser use toolset entry.
Two consequences. With buffering off the accumulated string is no longer guaranteed to parse, so this fix and the accumulator below ship together. And a self-hosted endpoint has no equivalent knob: that trade-off is not yours to set, and latency work happens in the serving layer instead.
| max_tokens | output_tokens | Longest silence | ms per output token | Tool args delivered | stop_reason | Wall clock |
|---|---|---|---|---|---|---|
| 64000 | 35,899 | 329.5s | 9.18 | 85,744 chars | tool_use | 339.5s |
| 8000 | 8,000 | 78.3s | 9.79 | 0 chars | max_tokens | 84.4s |
The accumulator: index by block, guard the parse, and hand the failure back
Before writing one, check whether you need to.
Four rules are specific to this protocol rather than to partial JSON in general. The generic version, including rendering half-built fields into a UI, is streaming partial JSON without breaking state.
Key the buffer on the content block index. Anthropic's index is the block's position in the final Message content array, which includes text and thinking blocks, not the position in an OpenAI tool_calls array. The two diverge the moment the turn opens a text or thinking block first, and keying on the wrong one merges two calls' arguments or scatters one across two buffers.
Tolerate an empty first fragment. The documented example opens with partial_json of "", and so do the traces above. Concatenation handles that for free; a guard treating the first fragment as the start of an object does not.
Treat a stream that ends without content_block_stop as a failed tool call. Anthropic's error recovery guidance states that tool use and extended thinking blocks cannot be partially recovered, and that streaming resumes only from the most recent text block.
Reject, do not repair. When the accumulation does not parse, return it to the model rather than completing it yourself.
import json
import anthropic
client = anthropic.Anthropic()
tool_inputs: dict[int, str] = {} # content block index -> accumulated JSON string
with client.messages.stream(
model="claude-opus-5",
max_tokens=1024,
tools=[
{
"name": "get_weather",
"description": "Get current weather for a city",
"eager_input_streaming": True,
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
],
messages=[{"role": "user", "content": "Weather in Paris?"}],
) as stream:
for event in stream:
match event.type:
case "content_block_start" if event.content_block.type == "tool_use":
tool_inputs[event.index] = ""
case "content_block_delta" if event.delta.type == "input_json_delta":
tool_inputs[event.index] += event.delta.partial_json
case "content_block_stop" if event.index in tool_inputs:
raw_input = tool_inputs.pop(event.index)
try:
parsed = json.loads(raw_input)
except json.JSONDecodeError:
print(f"Invalid tool input: {raw_input}")
else:
print(f"Tool input: {parsed}")
# Any index still left in tool_inputs never received content_block_stop.
for index, partial in tool_inputs.items():
print(f"Block {index} never closed, do not execute: {partial}")The final loop is rule three in code. Add an error branch beside it, because error events are in-band: the docs show event: error carrying an overloaded_error that would be an HTTP 529 outside streaming, and a parser switching only on content_block_* reads it as a stream that simply stopped.
For rule four, Anthropic publishes the remedy: hand the raw string back as a tool_result with is_error true, wrapped under a single key and built with a JSON library rather than by concatenating strings.
{
"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"is_error": true,
"content": "{\"INVALID_JSON\": \"<the unparseable input you received>\"}"
}Brace balancing and repair libraries do the opposite: they invent arguments the model never emitted. When the truncation came from max_tokens, retry with a higher limit.
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_test_truncated","name":"write_report","input":{}}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\": \"/tmp/report.txt\", \"body\": \"The quarter"}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":8000}}
event: message_stop
data: {"type":"message_stop"}The fixture your harness needs, and what to do this week
Assemble a truncated stream by hand and keep it as a fixture. It never sends content_block_stop and the turn ends at max_tokens, the shape the second measured run produced: A correct accumulator fails that call. An incorrect one runs write_report against a truncated body, or replays the half-built block into the next request and takes the 400 this post opened with. Do three things this week. Capture the raw SSE bytes of one real tool call through your gateway to a file. Strip the framing and read the fragments with sed -n 's/^data: //p' capture.sse | jq -rj 'select(.delta.type? == "input_json_delta") | .delta.partial_json': steady fragments mean streaming works, nothing followed by everything means the buffered default. Then add the truncated fixture beside it and replay both on every version bump, because no model evaluation covers these six behaviors.
| Runtime | Accumulator helper | You hand-roll? |
|---|---|---|
| Python | .get_final_message() | No |
| TypeScript | .finalMessage() | No |
| Go | message.Accumulate(event) | No |
| Java | MessageAccumulator.create() then accumulator.accumulate(event) | No |
| C# | .Aggregate(), or MessageContentAggregator passed to .CollectAsync() | No |
| Ruby | .accumulated_message | No |
| PHP, or any gateway translating another provider's stream | None | Yes, and this is where every failure above lives |
FAQ
Quick answers to the questions this post tends to raise.



