diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index e177e56dab14..943a5715cab0 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -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" { @@ -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{ @@ -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": @@ -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 + } + 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{ @@ -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 { @@ -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 diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go index fdc7b38e5ecc..c9210fe6b7a0 100644 --- a/relay/channel/claude/relay_claude_test.go +++ b/relay/channel/claude/relay_claude_test.go @@ -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,