diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 18d7455e9f22..aad8c2706b97 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -438,15 +438,18 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe return &claudeRequest, nil } -func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse { +func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ChatCompletionsStreamResponse { + if claudeInfo == nil { + claudeInfo = &ClaudeResponseInfo{} + } 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 + blockIdx := 0 if claudeResponse.Index != nil { - fcIdx = *claudeResponse.Index + blockIdx = *claudeResponse.Index } var choice dto.ChatCompletionsStreamResponseChoice if claudeResponse.Type == "message_start" { @@ -465,7 +468,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(claudeInfo.toolCallIndex(blockIdx)), ID: claudeResponse.ContentBlock.Id, Type: "function", Function: dto.FunctionResponse{ @@ -484,7 +487,7 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo case "input_json_delta": tools = append(tools, dto.ToolCallResponse{ Type: "function", - Index: common.GetPointer(fcIdx), + Index: common.GetPointer(claudeInfo.toolCallIndex(blockIdx)), Function: dto.FunctionResponse{ Arguments: *claudeResponse.Delta.PartialJson, }, @@ -589,6 +592,23 @@ type ClaudeResponseInfo struct { ResponseText strings.Builder Usage *dto.Usage Done bool + ToolIndexMap map[int]int +} + +// toolCallIndex maps a Claude content-block index to a 0-based OpenAI tool_call +// ordinal (Claude counts all blocks; OpenAI counts only tool calls). Call it +// only for tool_use blocks — for text/thinking blocks it would consume an +// ordinal and shift later tool indices. +func (info *ClaudeResponseInfo) toolCallIndex(blockIndex int) int { + if info.ToolIndexMap == nil { + info.ToolIndexMap = make(map[int]int) + } + if idx, ok := info.ToolIndexMap[blockIndex]; ok { + return idx + } + idx := len(info.ToolIndexMap) + info.ToolIndexMap[blockIndex] = idx + return idx } func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int { @@ -817,7 +837,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 495c500bbd13..34a90b6b26ce 100644 --- a/relay/channel/claude/relay_claude_test.go +++ b/relay/channel/claude/relay_claude_test.go @@ -13,6 +13,78 @@ func commonPointer[T any](value T) *T { return &value } +func mkToolUseStart(i int, id string) *dto.ClaudeResponse { + idx := i + return &dto.ClaudeResponse{ + Type: "content_block_start", + Index: &idx, + ContentBlock: &dto.ClaudeMediaMessage{Type: "tool_use", Id: id, Name: "f"}, + } +} + +// claude-opus-4-8 回归:前导 text 块不得把后续 tool_use 的工具序号顶离 0。 +func TestStreamToolCallIndex_LeadingTextDoesNotShiftToolIndex(t *testing.T) { + info := &ClaudeResponseInfo{Usage: &dto.Usage{}} + + text := "好的,我来安排执行。" + textIdx := 0 + resp := StreamResponseClaude2OpenAI(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: &textIdx, + ContentBlock: &dto.ClaudeMediaMessage{Type: "text", Text: &text}, + }, info) + require.Len(t, resp.Choices[0].Delta.ToolCalls, 0) + require.Empty(t, info.ToolIndexMap, "前导文本块不应分配工具序号") + + blk := 1 + resp = StreamResponseClaude2OpenAI(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: &blk, + ContentBlock: &dto.ClaudeMediaMessage{Type: "tool_use", Id: "toolu_1", Name: "execute_task"}, + }, info) + require.Len(t, resp.Choices[0].Delta.ToolCalls, 1) + require.NotNil(t, resp.Choices[0].Delta.ToolCalls[0].Index) + require.Equal(t, 0, *resp.Choices[0].Delta.ToolCalls[0].Index) + require.Equal(t, "toolu_1", resp.Choices[0].Delta.ToolCalls[0].ID) + + pj := `{"intent":"x"}` // input_json_delta 复用 start 分配的序号 + resp = StreamResponseClaude2OpenAI(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: &blk, + Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: &pj}, + }, info) + require.Len(t, resp.Choices[0].Delta.ToolCalls, 1) + require.Equal(t, 0, *resp.Choices[0].Delta.ToolCalls[0].Index) + require.Equal(t, pj, resp.Choices[0].Delta.ToolCalls[0].Function.Arguments) +} + +// 前导 thinking 块同样不应占用工具序号;其后两个工具应得到连续的 0、1。 +func TestStreamToolCallIndex_LeadingThinkingThenTools(t *testing.T) { + info := &ClaudeResponseInfo{Usage: &dto.Usage{}} + + think := "let me think" + thIdx := 0 + StreamResponseClaude2OpenAI(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: &thIdx, + Delta: &dto.ClaudeMediaMessage{Type: "thinking_delta", Thinking: &think}, + }, info) + + r1 := StreamResponseClaude2OpenAI(mkToolUseStart(1, "a"), info) + r2 := StreamResponseClaude2OpenAI(mkToolUseStart(2, "b"), info) + require.Equal(t, 0, *r1.Choices[0].Delta.ToolCalls[0].Index) + require.Equal(t, 1, *r2.Choices[0].Delta.ToolCalls[0].Index) +} + +// 无前导块、多工具从块 0 起:序号 0、1 不撞键(对应 ff06067a1)。 +func TestStreamToolCallIndex_MultipleToolsFromZeroNoCollision(t *testing.T) { + info := &ClaudeResponseInfo{Usage: &dto.Usage{}} + r0 := StreamResponseClaude2OpenAI(mkToolUseStart(0, "a"), info) + r1 := StreamResponseClaude2OpenAI(mkToolUseStart(1, "b"), info) + require.Equal(t, 0, *r0.Choices[0].Delta.ToolCalls[0].Index) + require.Equal(t, 1, *r1.Choices[0].Delta.ToolCalls[0].Index) +} + func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) { claudeInfo := &ClaudeResponseInfo{ Usage: &dto.Usage{},