Skip to content

[Bug] Anthropic /v1/messages streaming emits text_delta on open tool_use block (crashes Anthropic SDK with 'Content block is not a text block') #24293

Description

@VarV0id

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

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