Bug
The /v1/messages (Anthropic-compatible) streaming endpoint emits a content_block_delta of type text_delta carrying the same index as a still-open tool_use content block. The Anthropic SDK / Claude Code refuses such streams with "Content block is not a text block".
The Anthropic spec requires that a content_block_delta of type text_delta only target an open content block of type text (and input_json_delta only target tool_use). When the model emits multiple tool calls in the same turn, sglang inserts the \n separator between two </tool_call><tool_call> boundaries as text_delta on the previous tool_use block instead of opening a new text block (or discarding it, since it is just whitespace).
Reproduction
Environment
- sglang
0.5.10.post2.dev915+gf3dbadb82
- torch
2.9.1+cu130
- 2× RTX 3090
- Model:
Qwen/Qwen3.6-27B-FP8
- Flags:
--reasoning-parser qwen3 --tool-call-parser qwen3_coder --kv-cache-dtype fp8_e4m3 --enable-hierarchical-cache
Request
curl -sN http://<host>:<port>/v1/messages \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "Qwen3.6-27B",
"max_tokens": 400,
"stream": true,
"tools": [
{"name":"get_weather","description":"Get weather for a city","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}},
{"name":"get_news","description":"Get news for a topic","input_schema":{"type":"object","properties":{"topic":{"type":"string"}},"required":["topic"]}}
],
"messages":[{"role":"user","content":"Get the weather in Paris AND get news about France in parallel. Output both tool calls in the same response."}]
}'
Actual stream (excerpt)
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_…","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":"\"city\": \"Paris\""}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"}"}}
event: content_block_delta ← BUG
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"\n"}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}
event: content_block_start
data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"call_…","name":"get_news","input":{}}}
The text_delta event with index=1 lands on the still-open tool_use content block at that index. Anthropic SDK applies it as block.text += delta.text after asserting block.type === "text", which fails:
Error: Content block is not a text block
expected_type: "text"
actual_type: "tool_use"
Expected stream
Either:
(a) Discard whitespace between consecutive tool calls (the qwen3_coder parser's normal_text path already returns this \n from parse_streaming_increment — the Anthropic adapter just needs to drop it when the previous block was tool_use).
(b) Open a new text block for the inter-tool whitespace:
content_block_stop index=1 (close the tool_use)
content_block_start index=2 (text block)
content_block_delta index=2 (text_delta "\n")
content_block_stop index=2
content_block_start index=3 (next tool_use)
Root cause
python/sglang/srt/entrypoints/anthropic/serving.py (lines 622-635 in 0.5.10.post2.dev915):
# Handle text content deltas
if delta.content is not None and delta.content != "":
# Start a text content block if needed
if not content_block_open: # ← only checks "any block open"
start_event = AnthropicStreamEvent(
type="content_block_start",
index=content_block_index,
content_block=AnthropicContentBlock(type="text", text=""),
)
yield _wrap_sse_event(...)
content_block_open = True
# Emit text delta on the SAME index, regardless of which block type is open
delta_event = AnthropicStreamEvent(
type="content_block_delta",
index=content_block_index,
delta=AnthropicDelta(type="text_delta", text=delta.content),
)
yield _wrap_sse_event(...)
The branch that handles tool_calls (around line 562) correctly closes the previous block and increments content_block_index before opening a new tool_use. The text-handling branch never does this — it assumes the open block is a text block whenever one is open, which is not true after a tool_use was just opened.
Proposed fix
Track the open block's type, not just whether one is open, and close + reopen on type change. Minimal patch:
# At init:
content_block_open = False
content_block_type = None # "text" | "tool_use"
# In the tool-calls branch, after opening tool_use:
content_block_type = "tool_use"
# In the text-content branch:
if delta.content is not None and delta.content != "":
if content_block_open and content_block_type != "text":
# close the tool_use first
yield _wrap_sse_event(
AnthropicStreamEvent(
type="content_block_stop", index=content_block_index
).model_dump_json(exclude_none=True),
"content_block_stop",
)
content_block_index += 1
content_block_open = False
content_block_type = None
if not content_block_open:
# open new text block
...
content_block_open = True
content_block_type = "text"
# now safe to emit text_delta
...
Alternative (safer for Qwen specifically, since the inter-tool \n is not user-visible content): the qwen3_coder detector could surface a hint that the just-emitted normal_text was the inter-tool separator and drop it at the adapter layer. But the adapter-level type tracking above also handles any future model that interleaves text with tool_use, which is a valid pattern in the Anthropic protocol (Claude itself does it for <thinking>...</thinking> followed by tool_use followed by more text).
Impact
Any client using the official @anthropic-ai/sdk (TS) or anthropic (Python) SDK against sglang's /v1/messages endpoint will hard-crash on the second tool call of any multi-tool turn. We hit this in production with Claude Code routed to Qwen3.6-27B and were forced to add a stream sanitizer in our gateway to drop the corrupt deltas before forwarding (commit).
Related issues
Bug
The
/v1/messages(Anthropic-compatible) streaming endpoint emits acontent_block_deltaof typetext_deltacarrying the sameindexas a still-opentool_usecontent block. The Anthropic SDK / Claude Code refuses such streams with"Content block is not a text block".The Anthropic spec requires that a
content_block_deltaof typetext_deltaonly target an open content block of typetext(andinput_json_deltaonly targettool_use). When the model emits multiple tool calls in the same turn, sglang inserts the\nseparator between two</tool_call><tool_call>boundaries astext_deltaon the previous tool_use block instead of opening a new text block (or discarding it, since it is just whitespace).Reproduction
Environment
0.5.10.post2.dev915+gf3dbadb822.9.1+cu130Qwen/Qwen3.6-27B-FP8--reasoning-parser qwen3 --tool-call-parser qwen3_coder --kv-cache-dtype fp8_e4m3 --enable-hierarchical-cacheRequest
Actual stream (excerpt)
The
text_deltaevent withindex=1lands on the still-opentool_usecontent block at that index. Anthropic SDK applies it asblock.text += delta.textafter assertingblock.type === "text", which fails:Expected stream
Either:
(a) Discard whitespace between consecutive tool calls (the
qwen3_coderparser's normal_text path already returns this\nfromparse_streaming_increment— the Anthropic adapter just needs to drop it when the previous block was tool_use).(b) Open a new text block for the inter-tool whitespace:
Root cause
python/sglang/srt/entrypoints/anthropic/serving.py(lines 622-635 in0.5.10.post2.dev915):The branch that handles tool_calls (around line 562) correctly closes the previous block and increments
content_block_indexbefore opening a new tool_use. The text-handling branch never does this — it assumes the open block is a text block whenever one is open, which is not true after a tool_use was just opened.Proposed fix
Track the open block's type, not just whether one is open, and close + reopen on type change. Minimal patch:
Alternative (safer for Qwen specifically, since the inter-tool
\nis not user-visible content): theqwen3_coderdetector could surface a hint that the just-emittednormal_textwas the inter-tool separator and drop it at the adapter layer. But the adapter-level type tracking above also handles any future model that interleaves text with tool_use, which is a valid pattern in the Anthropic protocol (Claude itself does it for<thinking>...</thinking>followed by tool_use followed by more text).Impact
Any client using the official
@anthropic-ai/sdk(TS) oranthropic(Python) SDK against sglang's/v1/messagesendpoint will hard-crash on the second tool call of any multi-tool turn. We hit this in production with Claude Code routed to Qwen3.6-27B and were forced to add a stream sanitizer in our gateway to drop the corrupt deltas before forwarding (commit).Related issues
[Bug] qwen3 function call parser is too eager(different symptom, same general parser interaction)[Bug] Streaming Multiple Tool Calls Fail with tool_choice="auto" in Qwen3-Thinking(similar multi-tool failure mode)Qwen3-30B-A3B tool-call-parser