Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 43 additions & 15 deletions relay/channel/claude/relay-claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,17 +435,20 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}

func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
return streamResponseClaude2OpenAI(claudeResponse, nil)
}

func streamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ChatCompletionsStreamResponse {
var response dto.ChatCompletionsStreamResponse
response.Object = "chat.completion.chunk"
response.Model = claudeResponse.Model
response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0)
tools := make([]dto.ToolCallResponse, 0)
fcIdx := 0
if claudeResponse.Index != nil {
fcIdx = *claudeResponse.Index - 1
if fcIdx < 0 {
fcIdx = 0
toolCallIdx := func() int {
if claudeResponse.Index == nil {
return 0
}
return claudeInfo.getOpenAIToolCallIndex(*claudeResponse.Index)
}
var choice dto.ChatCompletionsStreamResponseChoice
if claudeResponse.Type == "message_start" {
Expand All @@ -464,7 +467,7 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
}
if claudeResponse.ContentBlock.Type == "tool_use" {
tools = append(tools, dto.ToolCallResponse{
Index: common.GetPointer(fcIdx),
Index: common.GetPointer(toolCallIdx()),
ID: claudeResponse.ContentBlock.Id,
Type: "function",
Function: dto.FunctionResponse{
Expand All @@ -481,11 +484,15 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
choice.Delta.Content = claudeResponse.Delta.Text
switch claudeResponse.Delta.Type {
case "input_json_delta":
arguments := ""
if claudeResponse.Delta.PartialJson != nil {
arguments = *claudeResponse.Delta.PartialJson
}
tools = append(tools, dto.ToolCallResponse{
Type: "function",
Index: common.GetPointer(fcIdx),
Index: common.GetPointer(toolCallIdx()),
Function: dto.FunctionResponse{
Arguments: *claudeResponse.Delta.PartialJson,
Arguments: arguments,
},
})
case "signature_delta":
Expand Down Expand Up @@ -518,6 +525,25 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
return &response
}

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

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.

if c.toolCallIndexByContentBlock == nil {
c.toolCallIndexByContentBlock = make(map[int]int)
}
if index, ok := c.toolCallIndexByContentBlock[contentBlockIndex]; ok {
return index
}
index := c.nextToolCallIndex
c.toolCallIndexByContentBlock[contentBlockIndex] = index
c.nextToolCallIndex++
return index
}

func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
choices := make([]dto.OpenAITextResponseChoice, 0)
fullTextResponse := dto.OpenAITextResponse{
Expand Down Expand Up @@ -582,12 +608,14 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
}

type ClaudeResponseInfo struct {
ResponseId string
Created int64
Model string
ResponseText strings.Builder
Usage *dto.Usage
Done bool
ResponseId string
Created int64
Model string
ResponseText strings.Builder
Usage *dto.Usage
Done bool
toolCallIndexByContentBlock map[int]int
nextToolCallIndex int
}

func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int {
Expand Down Expand Up @@ -816,7 +844,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
helper.ClaudeChunkData(c, claudeResponse, data)
} else if info.RelayFormat == types.RelayFormatOpenAI {
response := StreamResponseClaude2OpenAI(&claudeResponse)
response := streamResponseClaude2OpenAI(&claudeResponse, claudeInfo)

if !FormatClaudeResponseInfo(&claudeResponse, response, claudeInfo) {
return nil
Expand Down
49 changes: 49 additions & 0 deletions relay/channel/claude/relay_claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,55 @@ func TestFormatClaudeResponseInfo_ContentBlockDelta(t *testing.T) {
}
}

func TestStreamResponseClaude2OpenAIMapsParallelToolUseIndexes(t *testing.T) {
claudeInfo := &ClaudeResponseInfo{}

firstStart := streamResponseClaude2OpenAI(&dto.ClaudeResponse{
Type: "content_block_start",
Index: intPtr(0),
ContentBlock: &dto.ClaudeMediaMessage{Type: "tool_use", Id: "toolu_1", Name: "read_file"},
}, claudeInfo)
requireToolCallIndex(t, firstStart, 0)

firstDelta := streamResponseClaude2OpenAI(&dto.ClaudeResponse{
Type: "content_block_delta",
Index: intPtr(0),
Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: stringPtr(`{"file_path":"/a"}`)},
}, claudeInfo)
requireToolCallIndex(t, firstDelta, 0)

secondStart := streamResponseClaude2OpenAI(&dto.ClaudeResponse{
Type: "content_block_start",
Index: intPtr(1),
ContentBlock: &dto.ClaudeMediaMessage{Type: "tool_use", Id: "toolu_2", Name: "read_file"},
}, claudeInfo)
requireToolCallIndex(t, secondStart, 1)

secondDelta := streamResponseClaude2OpenAI(&dto.ClaudeResponse{
Type: "content_block_delta",
Index: intPtr(1),
Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: stringPtr(`{"file_path":"/b"}`)},
}, claudeInfo)
requireToolCallIndex(t, secondDelta, 1)
}

func requireToolCallIndex(t *testing.T, response *dto.ChatCompletionsStreamResponse, expected int) {
t.Helper()
require.NotNil(t, response)
require.Len(t, response.Choices, 1)
require.Len(t, response.Choices[0].Delta.ToolCalls, 1)
require.NotNil(t, response.Choices[0].Delta.ToolCalls[0].Index)
require.Equal(t, expected, *response.Choices[0].Delta.ToolCalls[0].Index)
}

func intPtr(v int) *int {
return &v
}

func stringPtr(v string) *string {
return &v
}

func TestBuildOpenAIStyleUsageFromClaudeUsage(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
Expand Down