Skip to content

fix: preserve parallel Claude tool block lifecycle in OpenAI→Claude stream conversion - #6394

Open
qianqiu-2020 wants to merge 1 commit into
QuantumNous:mainfrom
qianqiu-2020:fix/claude-parallel-tool-block-lifecycle
Open

fix: preserve parallel Claude tool block lifecycle in OpenAI→Claude stream conversion#6394
qianqiu-2020 wants to merge 1 commit into
QuantumNous:mainfrom
qianqiu-2020:fix/claude-parallel-tool-block-lifecycle

Conversation

@qianqiu-2020

@qianqiu-2020 qianqiu-2020 commented Jul 21, 2026

Copy link
Copy Markdown

⚠️ 提交说明 / PR Notice

Important

  • 请提供人工撰写的简洁摘要,避免直接粘贴未经整理的 AI 输出。

📝 变更描述 / Description

在 OpenAI 流式响应转 Claude SSE(StreamResponseOpenAI2Claude)时,并行 tool_use 路径会产生 "ghost" content_block_delta——发往一个从未 content_block_start 或已被 content_block_stop 关闭的 block index。新版 Claude Code(Opus 4.7 捆绑版本起)对 SSE 状态机做严格校验,遇到这种 delta 直接报 API Error: Content block not found

我用 GLM-5.2 复现了该问题:并行工具调用时,GLM-5.2 把多个工具参数打包在最后一个 chunk 一次性返回(而非逐个流式 delta),转换器就会向已关闭/未 start 的 block index 发 delta,触发 Content block not found

根因有三处:

  1. 后续 chunk 的 input_json_delta 守卫只判断 len(Arguments) > 0,未校验目标 block 是否仍 open。当上游在最后一个 chunk 把多个工具参数打包返回、且此前某 block 已被 stopOpenBlocks 关闭时,delta 落到已关闭的 index 上。
  2. stopOpenBlocksbase..base+maxOffset 的每个 offset 无条件发 content_block_stop,但 offset 可能不连续或部分 index 从未 start,产生 stop-only 幽灵 block。
  3. 首块(SendResponseCount == 1)只取 ToolCalls[0] 单独建 block 0,忽略并行工具,导致后续 chunk 的 offset≥1 工具在 base=0 上发 delta 却没有对应 start。

修复方式:给 ClaudeConvertInfo 增加 ToolCallOpenIndexes map[int]bool,精确记录哪些 block index 当前 open:

  • content_block_start 时置 ToolCallOpenIndexes[idx] = true
  • stopOpenBlocks 只对 ToolCallOpenIndexes[blockIndex] 为 true 的 index 发 content_block_stop
  • stopOpenBlocksAndAdvance 清空 ToolCallOpenIndexes
  • 后续 chunk 的 input_json_delta 守卫改为 len(Arguments) > 0 && ToolCallOpenIndexes[idx]
  • 首块改为遍历全部 ToolCalls,对每个工具建独立 content_block_start 并登记 open

这是与 sub2api #4193(PR #4294)同型的缺陷:其 resToAnthHandleFuncArgsDone 直接用 state.ContentBlockIndex 向已关闭 block 发 delta,而 Delta 路径用 OutputIndexToBlockIdx。两项目独立实现、同根因,本 PR 用 "open-index 登记" 在 OpenAI→Claude 方向堵住同类问题。

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix) - 请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug
  • ✨ 新功能 (New feature) - 重大特性建议先通过 Issue 沟通
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
  • 非重复提交: 我已搜索现有的 IssuesPRs,确认不是重复提交。
  • Bug fix 说明: 若此 PR 标记为 Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
  • 变更理解: 我已理解这些更改的工作原理及可能影响。
  • 范围聚焦: 本 PR 未包含任何与当前任务无关的代码改动。
  • 本地验证: 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
  • 安全合规: 代码中无敏感凭据,且符合项目代码规范。

📸 运行证明 / Proof of Work

go build ./service/... ./relay/common/...
go test ./service/ ./service/relayconvert/internal/oai_chat/ -run 'Parallel|Claude' -v -count=1

Result:

ok  	github.com/QuantumNous/new-api/service	0.593s
ok  	github.com/QuantumNous/new-api/service/relayconvert/internal/oai_chat	0.021s

新增回归测试 TestStreamResponseOpenAI2ClaudeParallelToolCallsHaveValidBlockLifecycle 断言:每个 content_block_delta 之前必有 content_block_start、之后未被 content_block_stop,且 started == stopped,覆盖了 sub2api #4193 中 "index=4 从未有 start 却收到 delta" 的同类场景。

Summary by CodeRabbit

  • Bug Fixes
    • Improved OpenAI→Claude streaming to support multiple parallel tool calls with correct per-block start, update, and stop behavior.
    • Prevented erroneous “ghost” deltas/stops for tool-use blocks that weren’t actually started (including replay cases).
    • Added defensive handling for unexpected tool-call indexes to avoid mis-tracking block lifecycles.
  • Tests
    • Added coverage verifying correct content-block lifecycle for concurrent tool-call indices.
    • Added coverage ensuring replayed tool metadata does not cause duplicate block start events.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d46a0299-f717-4fc5-9071-dacce5c77a91

📥 Commits

Reviewing files that changed from the base of the PR and between d9de5e7 and 50b4ad8.

📒 Files selected for processing (3)
  • relaykit/relayconvert/convmeta/meta.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • relaykit/relayconvert/convmeta/meta.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go

Walkthrough

OpenAI-to-Claude streaming conversion now tracks multiple concurrently open tool-use blocks by index, guards delta and stop events, resets tracking between tool phases, and tests valid block lifecycles.

Changes

Parallel tool-call lifecycle

Layer / File(s) Summary
Track parallel tool blocks
relaykit/relayconvert/convmeta/meta.go, relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
ClaudeConvertInfo records open tool indexes, validates upstream indexes, and starts and tracks one Claude tool_use block per streamed tool call.
Guard tool block events
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go, relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
Stop and argument-delta events target only active indexes, tracking resets between tool phases, and tests verify parallel block ordering and replay handling.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OpenAIStream
  participant StreamResponseOpenAI2Claude
  participant ClaudeContentBlocks
  OpenAIStream->>StreamResponseOpenAI2Claude: tool-call deltas by index
  StreamResponseOpenAI2Claude->>ClaudeContentBlocks: start one tool_use block per index
  StreamResponseOpenAI2Claude->>ClaudeContentBlocks: emit input_json_delta for open indexes
  StreamResponseOpenAI2Claude->>ClaudeContentBlocks: stop started tool_use blocks
Loading

Poem

I’m a rabbit with tools in a row,
Each block starts before deltas can flow.
No ghost stops appear,
Open indexes stay clear,
And parallel calls hop to-and-fro! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: fixing parallel Claude tool block lifecycle handling in OpenAI→Claude streaming.
Linked Issues check ✅ Passed The changes address #4389 by preventing invalid content block lifecycles during OpenAI→Claude streaming, including parallel tool calls.
Out of Scope Changes check ✅ Passed The code and tests stay focused on the reported content-block lifecycle bug and do not introduce unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
service/convert_stream_test.go (1)

12-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the actual ghost-delta scenario from #4389.

This test validates the happy-path lifecycle well, but never exercises the new guard's negative branch — i.e., a delta/stop arriving for a block index that's already closed or never opened (the packed-final-chunk case the source comment explicitly calls out). Since that's the concrete bug being fixed, a case asserting such stray events are dropped (or don't panic/emit invalid events) would directly protect the regression path.

Based on path instructions, **/*_test.go guidelines require tests to "protect real behavior, API contracts, billing/accounting invariants, compatibility, or regression paths" — this is the regression path the PR title cites but leaves partially untested.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/convert_stream_test.go` around lines 12 - 62, Add a regression case
in TestStreamResponseOpenAI2ClaudeParallelToolCallsHaveValidBlockLifecycle that
sends a packed final chunk or otherwise produces a delta/stop for a tool-call
index that was never opened or was already closed. Assert
StreamResponseOpenAI2Claude drops the stray events without panicking and emits
no invalid lifecycle events, while preserving the existing valid
start/delta/stop assertions.

Source: Path instructions

service/relayconvert/internal/oai_chat/to_claude_messages_resp.go (1)

155-180: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider unifying the two tool-block start/delta loops.

Both loops implement the same start-block → mark-open → guarded-delta pattern, but Loop A (init chunk) relies on an implicit invariant (block is always started here, so the open-check is skipped) while Loop B (streaming chunk) makes the same check explicit because its start is conditional. This works today, but the divergence is exactly the kind of subtle asymmetry that could reintroduce the ghost-delta bug (#4389) if one loop is edited without the other.

Extracting a shared helper (e.g. startToolBlockIfNeeded/emitToolDeltaIfOpen) that both call sites use would centralize the open-index invariant and make future edits safer. Two call sites justify a package-level helper here.

Also applies to: 305-357

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/relayconvert/internal/oai_chat/to_claude_messages_resp.go` around
lines 155 - 180, Unify the tool-block start and guarded-delta behavior used by
the initialization and streaming loops. Extract package-level helpers such as
startToolBlockIfNeeded and emitToolDeltaIfOpen, then update both loop paths
around ClaudeConvertInfo.ToolCallOpenIndexes and claudeResponses to use them,
ensuring deltas are emitted only for open blocks and preserving existing index
handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@service/convert_stream_test.go`:
- Around line 12-62: Add a regression case in
TestStreamResponseOpenAI2ClaudeParallelToolCallsHaveValidBlockLifecycle that
sends a packed final chunk or otherwise produces a delta/stop for a tool-call
index that was never opened or was already closed. Assert
StreamResponseOpenAI2Claude drops the stray events without panicking and emits
no invalid lifecycle events, while preserving the existing valid
start/delta/stop assertions.

In `@service/relayconvert/internal/oai_chat/to_claude_messages_resp.go`:
- Around line 155-180: Unify the tool-block start and guarded-delta behavior
used by the initialization and streaming loops. Extract package-level helpers
such as startToolBlockIfNeeded and emitToolDeltaIfOpen, then update both loop
paths around ClaudeConvertInfo.ToolCallOpenIndexes and claudeResponses to use
them, ensuring deltas are emitted only for open blocks and preserving existing
index handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 76c21e3a-2e45-4688-b678-2a353875fc18

📥 Commits

Reviewing files that changed from the base of the PR and between 1721144 and 3c25052.

📒 Files selected for processing (3)
  • relay/common/relay_info.go
  • service/convert_stream_test.go
  • service/relayconvert/internal/oai_chat/to_claude_messages_resp.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go`:
- Around line 256-277: Extend the event validation in the test’s event loop to
capture each content_block_delta event’s PartialJson payload by block index.
Assert that the collected values include {"city":"Tokyo"} for the expected block
and {} for the other, while preserving the existing ordering and start/stop
assertions.

In `@relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go`:
- Around line 177-187: Update both content_block_start emission paths in
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go at lines
177-187 and 349-360 to require a non-empty Function.Name and an index not
already present in ToolCallOpenIndexes before emitting a start; preserve the
existing state update after emission. In
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go lines
229-277, add a replayed name for an existing index and assert that no duplicate
content_block_start is produced.
- Around line 28-35: Replace the range scan in
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go:28-35 with
deterministic iteration over the tracked open indexes, stopping only active
keys. In
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go:168-187,
validate or normalize negative and unbounded initial tool indexes before
emitting Claude blocks; apply the same validation to subsequent tool deltas in
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go:336-377.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b9cd67b4-9535-47c0-8a0e-bd58342090c3

📥 Commits

Reviewing files that changed from the base of the PR and between 3c25052 and 9fb4eeb.

📒 Files selected for processing (3)
  • relaykit/relayconvert/convmeta/meta.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go

Comment thread relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go Outdated
Comment thread relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go Outdated
@qianqiu-2020
qianqiu-2020 force-pushed the fix/claude-parallel-tool-block-lifecycle branch from 9fb4eeb to d9de5e7 Compare July 28, 2026 03:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go`:
- Around line 324-333: Update the event validation in the test around the
content_block_start loop to collect every content_block_start event’s index,
including non-zero indexes, and assert the collected indexes exactly equal [0].
Remove the current non-zero-index filtering and starts counter while preserving
the existing replay scenario coverage.
- Around line 276-283: Strengthen the event lifecycle assertions in this test’s
content_block_stop handling to reject duplicate stops for the same block. Before
marking stopped[idx], assert that the block has not already been stopped, while
preserving the existing started-before-stop check and final started/stopped
equality assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ddb2390-f82d-4cd4-924c-5ae90439c379

📥 Commits

Reviewing files that changed from the base of the PR and between 9fb4eeb and d9de5e7.

📒 Files selected for processing (3)
  • relaykit/relayconvert/convmeta/meta.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • relaykit/relayconvert/convmeta/meta.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go

Comment thread relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go Outdated
@qianqiu-2020

Copy link
Copy Markdown
Author

@Calcium-Ion Please help me review this PR.

neimaravila pushed a commit to neimaravila/new-api that referenced this pull request Aug 6, 2026
DeepSeek V4 breaks Claude Code two ways through the OpenAI->Claude stream
conversion: the sglang dsv4 parser emits a trailing content='\n' after
tool_calls, which puts a text block after tool_use and trips "Content block
not found"; and a reasoning run that spends the whole max_tokens budget on
thinking closes with no non-thinking content block at all, which Anthropic's
streaming protocol does not allow.

PR QuantumNous#6629 fixes both. It collides with PR QuantumNous#6394, which we already carry, in
two places: the ClaudeConvertInfo struct tail, where both append a field, and
the tool-call branch, where QuantumNous#6394 inserted the ToolCallOpenIndexes
initialisation the new HasContentBlock assignment was anchored to. Kept both
fields and re-anchored the assignment; the rest is verbatim. The fixes do not
overlap in behaviour, since appendEmptyTextFallback only runs when no text or
tool_use block was ever emitted.

Verified over production with scripts/apply-patches.sh: all fourteen apply in
order, relaykit builds with GOWORK=off, and both PRs' tests pass together --
ParallelToolCallsHaveValidBlockLifecycle and ReplayedToolNameDoesNotDuplicate
Start from QuantumNous#6394 alongside DiscardsTrailingTextAfterToolUse,
AppendsEmptyTextForThinkingOnlyStream and ThinkingThenTextDoesNotGetFallback
from QuantumNous#6629.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZsKS6An5YHvZTW3cpVTNX
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.

An error occurs in the specific CC CLI: API Error: Content block not found

1 participant