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
32 changes: 26 additions & 6 deletions relay/channel/claude/relay-claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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{
Expand All @@ -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,
},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions relay/channel/claude/relay_claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down