fix(claude): preserve parallel tool call stream indexes - #4730
Conversation
Anthropic content block indexes count every content block, including text and thinking blocks, while OpenAI streaming tool_call indexes count only tool calls. The previous conversion subtracted one from the content_block index, which collapsed tool blocks 0 and 1 into OpenAI index 0 when Claude emitted parallel tool calls without an earlier text block. This keeps a per-stream content_block.index to tool_call.index map and reuses it for subsequent input_json_delta chunks. Constraint: Existing StreamResponseClaude2OpenAI signature is kept for compatibility. Rejected: Use content_block.index - 1 | fails when tool_use starts at content block 0 or follows more than one non-tool block Confidence: high Scope-risk: narrow Tested: mise x go@1.25.1 -- go test ./relay/channel/claude -run TestStreamResponseClaude2OpenAIMapsParallelToolUseIndexes -count=1 -v Tested: mise x go@1.25.1 -- go test ./relay/channel/claude -run 'TestFormatClaudeResponseInfo|TestBuildOpenAIStyleUsageFromClaudeUsage' -count=1 Not-tested: Full ./relay/channel/claude package passes; existing file-content conversion tests fail on current main
WalkthroughThis PR refactors tool-call index computation in Claude-to-OpenAI streaming response conversion. The public ChangesClaude-to-OpenAI Tool-Call Index Mapping
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@relay/channel/claude/relay-claude.go`:
- Around line 528-534: The nil-state fallback in
ClaudeResponseInfo.getOpenAIToolCallIndex currently maps multiple
contentBlockIndex values (e.g., 0 and 1) to the same tool index by returning
contentBlockIndex-1; change the fallback so it preserves uniqueness: when c ==
nil, return contentBlockIndex (with a guard to clamp negative values to 0)
instead of contentBlockIndex-1 so distinct parallel tool blocks map to distinct
indices. Ensure the updated logic handles contentBlockIndex < 0 by returning 0.
🪄 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
Run ID: e2356989-dca5-4dd8-a5ce-11166ed7a04e
📒 Files selected for processing (2)
relay/channel/claude/relay-claude.gorelay/channel/claude/relay_claude_test.go
| func (c *ClaudeResponseInfo) getOpenAIToolCallIndex(contentBlockIndex int) int { | ||
| if c == nil { | ||
| if contentBlockIndex <= 0 { | ||
| return 0 | ||
| } | ||
| return contentBlockIndex - 1 | ||
| } |
There was a problem hiding this comment.
Nil-state fallback still allows tool-call index collisions.
When claudeInfo is nil, Line 533 returns contentBlockIndex - 1; parallel tool blocks at indexes 0 and 1 both map to index 0. Since Line 438 uses this path in the exported wrapper, direct callers can still merge distinct tool arguments incorrectly.
Suggested fix
func (c *ClaudeResponseInfo) getOpenAIToolCallIndex(contentBlockIndex int) int {
if c == nil {
- if contentBlockIndex <= 0 {
+ if contentBlockIndex < 0 {
return 0
}
- return contentBlockIndex - 1
+ // Stateless fallback: keep indices unique to avoid collisions.
+ return contentBlockIndex
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func (c *ClaudeResponseInfo) getOpenAIToolCallIndex(contentBlockIndex int) int { | |
| if c == nil { | |
| if contentBlockIndex <= 0 { | |
| return 0 | |
| } | |
| return contentBlockIndex - 1 | |
| } | |
| func (c *ClaudeResponseInfo) getOpenAIToolCallIndex(contentBlockIndex int) int { | |
| if c == nil { | |
| if contentBlockIndex < 0 { | |
| return 0 | |
| } | |
| // Stateless fallback: keep indices unique to avoid collisions. | |
| return contentBlockIndex | |
| } |
🤖 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 `@relay/channel/claude/relay-claude.go` around lines 528 - 534, The nil-state
fallback in ClaudeResponseInfo.getOpenAIToolCallIndex currently maps multiple
contentBlockIndex values (e.g., 0 and 1) to the same tool index by returning
contentBlockIndex-1; change the fallback so it preserves uniqueness: when c ==
nil, return contentBlockIndex (with a guard to clamp negative values to 0)
instead of contentBlockIndex-1 so distinct parallel tool blocks map to distinct
indices. Ensure the updated logic handles contentBlockIndex < 0 by returning 0.
Summary
Fixes Claude/Anthropic streaming conversion to OpenAI chat-completions chunks when multiple tool_use content blocks are emitted in parallel. Anthropic content_block.index counts every content block, while OpenAI tool_calls[].index counts only tool calls. The previous
index - 1conversion can collapse content blocks 0 and 1 into OpenAI tool_call index 0, which causes clients to merge tool arguments into the wrong call.This PR keeps the existing
StreamResponseClaude2OpenAIsignature and adds per-stream state insideClaudeResponseInfosocontent_block.indexmaps stably to OpenAItool_calls[].indexacrosscontent_block_startand laterinput_json_deltachunks.Tests
mise x go@1.25.1 -- go test ./relay/channel/claude -run TestStreamResponseClaude2OpenAIMapsParallelToolUseIndexes -count=1 -vmise x go@1.25.1 -- go test ./relay/channel/claude -run 'TestFormatClaudeResponseInfo|TestBuildOpenAIStyleUsageFromClaudeUsage' -count=1\n\n## Notes\n\nmise x go@1.25.1 -- go test ./relay/channel/claudecurrently fails on unrelated existing file-content conversion tests (TestRequestOpenAI2ClaudeMessage_*FileContent). The new regression test and adjacent Claude response formatting/usage tests pass.Summary by CodeRabbit
Bug Fixes
Tests