Skip to content

fix: streaming OpenAI→Anthropic block index collisions (qwen3.7-max, deepseek-v4-flash)#1

Open
yinnho wants to merge 1 commit into
mainfrom
fix/streaming-block-index-collision
Open

fix: streaming OpenAI→Anthropic block index collisions (qwen3.7-max, deepseek-v4-flash)#1
yinnho wants to merge 1 commit into
mainfrom
fix/streaming-block-index-collision

Conversation

@yinnho

@yinnho yinnho commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Problem

Claude Code reported Content block not found when proxying streaming responses from qwen3.7-max and deepseek-v4-flash (OpenAI-format providers).

Root Cause

convert_openai_stream_to_anthropic used a single shared block_index that only incremented on close, causing index collisions when multiple blocks were open simultaneously:

  1. qwen3.7-max / DeepSeek send reasoning_content: "" to mark end of thinking. The empty string was skipped by !is_empty(), leaving the thinking block open → next text block reused the same index.

  2. Parallel tool calls (deepseek-v4-flash, common in Claude Code) each opened a tool_use block at the current block_index without incrementing, so two tools shared index 0 and their argument deltas were misrouted.

Fix

Refactored StreamState so each block records its own uniquely allocated index:

Before After
block_index: u32 (shared, increment on close) next_block_index: u32 (alloc on open)
thinking_block_open: bool thinking_block: Option<u32>
text_block_open: bool text_block: Option<u32>
tool_blocks_open: Vec<bool> tool_blocks: Vec<Option<u32>>

Every content_block_start now calls alloc_index() which assigns and increments immediately, guaranteeing unique indices regardless of how many blocks are open or the order of transitions.

Also closes the thinking block on empty/absent reasoning_content and adds a safety net before opening text/tool blocks.

Tests

6 new regression tests, all 91 tests pass:

  • test_qwen37_max_empty_reasoning_content_closes_thinking_block
  • test_reasoning_content_disappearing_closes_thinking_block
  • test_safety_net_closes_thinking_before_text
  • test_deepseek_parallel_tool_calls_unique_indices ← the key one
  • test_deepseek_reasoning_then_tool_calls
  • test_deepseek_flash_reasoning_content_tools

🤖 Generated with Claude Code

Claude Code reported "Content block not found" when proxying streaming
responses from qwen3.7-max and deepseek-v4-flash (OpenAI-format providers).

Root cause: convert_openai_stream_to_anthropic used a single shared
block_index that only incremented on close, so multiple simultaneously
open blocks collided:

1. qwen3.7-max / DeepSeek send reasoning_content: "" to mark the end of
   thinking. The empty string fell into the reasoning branch but was
   skipped by !is_empty(), leaving the thinking block open. The next
   text block then reused the same index.

2. Parallel tool calls (common in Claude Code) each opened a tool_use
   block at the current block_index without incrementing, so two tools
   shared index 0 and their argument deltas were misrouted.

Fix: refactor StreamState so each block records its own uniquely
allocated index. alloc_index() assigns and increments next_block_index
on every content_block_start, and all delta/stop events use the block's
recorded index. State is now Option<u32> per block instead of bool flags.

- thinking_block_open: bool  -> thinking_block: Option<u32>
- text_block_open: bool      -> text_block: Option<u32>
- tool_blocks_open: Vec<bool> -> tool_blocks: Vec<Option<u32>>

Also closes the thinking block on empty/absent reasoning_content and
adds a safety net that closes it before opening a text or tool block.

Added 6 regression tests covering qwen3.7-max empty-reasoning,
deepseek parallel tool calls, and reasoning→text→tool sequences.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant