Skip to content

fix(claude): preserve parallel tool call stream indexes - #4730

Open
ghostg00 wants to merge 1 commit into
QuantumNous:mainfrom
ghostg00:fix/claude-tool-call-index
Open

fix(claude): preserve parallel tool call stream indexes#4730
ghostg00 wants to merge 1 commit into
QuantumNous:mainfrom
ghostg00:fix/claude-tool-call-index

Conversation

@ghostg00

@ghostg00 ghostg00 commented May 10, 2026

Copy link
Copy Markdown

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 - 1 conversion 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 StreamResponseClaude2OpenAI signature and adds per-stream state inside ClaudeResponseInfo so content_block.index maps stably to OpenAI tool_calls[].index across content_block_start and later input_json_delta chunks.

Tests

  • mise x go@1.25.1 -- go test ./relay/channel/claude -run TestStreamResponseClaude2OpenAIMapsParallelToolUseIndexes -count=1 -v
  • mise 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/claude currently 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

    • Fixed tool-call indexing in streaming responses when handling multiple concurrent tool uses.
  • Tests

    • Added test coverage for parallel tool-use indexing scenarios.

Review Change Stack

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
@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR refactors tool-call index computation in Claude-to-OpenAI streaming response conversion. The public StreamResponseClaude2OpenAI function now delegates to an internal variant that uses per-stream index mapping stored in ClaudeResponseInfo instead of deriving indices from fixed arithmetic. A new test validates that parallel tool-use blocks receive correct sequential indices across content blocks.

Changes

Claude-to-OpenAI Tool-Call Index Mapping

Layer / File(s) Summary
Data Shape / State Structure
relay/channel/claude/relay-claude.go
ClaudeResponseInfo gains toolCallIndexByContentBlock map and nextToolCallIndex counter to track stable tool-call indices per content block.
Index Mapping Helper
relay/channel/claude/relay-claude.go
New getOpenAIToolCallIndex(contentBlockIndex) method caches and increments sequential indices for each unique Claude content-block index.
Public API Wrapper
relay/channel/claude/relay-claude.go
StreamResponseClaude2OpenAI refactored into a thin wrapper that delegates to internal streamResponseClaude2OpenAI(claudeResponse, nil).
Streaming Conversion Implementation
relay/channel/claude/relay-claude.go
streamResponseClaude2OpenAI applies mapped tool-call indices when emitting tool_use blocks and deltas; standardizes arguments population from PartialJson (empty string if absent).
Stream Handler Integration
relay/channel/claude/relay-claude.go
HandleStreamResponseData updated to pass claudeInfo to streamResponseClaude2OpenAI so index mapping state persists across stream events.
Tests & Helpers
relay/channel/claude/relay_claude_test.go
TestStreamResponseClaude2OpenAIMapsParallelToolUseIndexes verifies parallel tool-use blocks receive correct sequential indices; includes helpers requireToolCallIndex, intPtr, stringPtr.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • QuantumNous/new-api#3080: Both PRs extend ClaudeResponseInfo with per-content-block tool-call state and mapping to stabilize tool-call indices across streamed content.
  • QuantumNous/new-api#1761: Both PRs modify StreamResponseClaude2OpenAI streaming conversion in relay/channel/claude/relay-claude.go to adjust tool-call handling and stop-reason mapping.
  • QuantumNous/new-api#2804: Both PRs modify ClaudeResponseInfo state and the streaming code path (StreamResponseClaude2OpenAI / HandleStreamResponseData) for tool-call indexing stability.

Suggested reviewers

  • seefs001

Poem

🐰 A rabbit hops through Claude's streaming delight,
Tool-calls now indexed in sequential flight,
No more arithmetic confusion or mess,
The mapping state brings stability blessed,
Content blocks dance in perfect array!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: fixing the preservation of parallel tool call stream indexes in Claude/Anthropic streaming conversion, which is the core problem addressed by the refactoring and new mapping logic.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 and usage tips.

@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: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 543cc64 and 81c312f.

📒 Files selected for processing (2)
  • relay/channel/claude/relay-claude.go
  • relay/channel/claude/relay_claude_test.go

Comment on lines +528 to +534
func (c *ClaudeResponseInfo) getOpenAIToolCallIndex(contentBlockIndex int) int {
if c == nil {
if contentBlockIndex <= 0 {
return 0
}
return contentBlockIndex - 1
}

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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.

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