mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
fix(relaykit): keep tool calls when merging adjacent assistant messages for Claude (#7921)
* fix(relaykit): keep tool calls when merging adjacent assistant messages Adjacent assistant messages with string content were merged keeping only the second message's tool calls, so tool results for the first message's calls had no matching tool_use and Claude rejected the request. - concatenate both messages' tool calls in input order - skip the "..." placeholder of a tool-call message in the merged text * fix(relaykit): preserve literal ellipses in assistant message merges --------- Co-authored-by: seefs001 <i@seefs.me>
This commit is contained in:
1 parent
afe73b08ff
commit
783735607d
2 files changed
+183
-2
No files matched your search
@@ -2,6 +2,7 @@ package oaichat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"context"
|
||||
|
||||
@@ -127,6 +128,7 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
|
||||
lastMessage := dto.Message{
|
||||
Role: "tool",
|
||||
}
|
||||
lastMessageHasPlaceholder := false
|
||||
for _, message := range textRequest.Messages {
|
||||
switch message.Role {
|
||||
case "":
|
||||
@@ -159,15 +161,35 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
|
||||
}
|
||||
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
|
||||
if lastMessage.IsStringContent() && message.IsStringContent() {
|
||||
fmtMessage.SetStringContent(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()))
|
||||
previousToolCalls := lastMessage.ParseToolCalls()
|
||||
currentToolCalls := fmtMessage.ParseToolCalls()
|
||||
if len(previousToolCalls) > 0 || len(currentToolCalls) > 0 {
|
||||
// Keep both messages' tool calls so later tool results still
|
||||
// match a tool_use block.
|
||||
texts := make([]string, 0, 2)
|
||||
// Omit only text injected for an empty tool-call message.
|
||||
isToolPlaceholder := lastMessageHasPlaceholder && len(previousToolCalls) > 0
|
||||
if previousText := lastMessage.StringContent(); previousText != "" && !isToolPlaceholder {
|
||||
texts = append(texts, previousText)
|
||||
}
|
||||
if currentText := message.StringContent(); currentText != "" {
|
||||
texts = append(texts, currentText)
|
||||
}
|
||||
fmtMessage.SetStringContent(strings.Join(texts, " "))
|
||||
fmtMessage.SetToolCalls(append(previousToolCalls, currentToolCalls...))
|
||||
} else {
|
||||
fmtMessage.SetStringContent(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()))
|
||||
}
|
||||
formatMessages = formatMessages[:len(formatMessages)-1]
|
||||
}
|
||||
}
|
||||
if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") {
|
||||
hasPlaceholder := fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "")
|
||||
if hasPlaceholder {
|
||||
fmtMessage.SetStringContent("...")
|
||||
}
|
||||
formatMessages = append(formatMessages, fmtMessage)
|
||||
lastMessage = fmtMessage
|
||||
lastMessageHasPlaceholder = hasPlaceholder
|
||||
}
|
||||
|
||||
claudeMessages := make([]dto.ClaudeMessage, 0)
|
||||
|
||||
@@ -79,3 +79,162 @@ func TestOpenAIChatRequestToClaudeMessagesNormalizesToolInputSchema(t *testing.T
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChatRequestToClaudeMessagesMergesAdjacentAssistantToolCalls(t *testing.T) {
|
||||
toolCall := func(id string) []dto.ToolCallRequest {
|
||||
return []dto.ToolCallRequest{{
|
||||
ID: id,
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{Name: "lookup_" + id, Arguments: `{"q":"` + id + `"}`},
|
||||
}}
|
||||
}
|
||||
assistant := func(content any, toolCalls []dto.ToolCallRequest) dto.Message {
|
||||
message := dto.Message{Role: "assistant", Content: content}
|
||||
if toolCalls != nil {
|
||||
message.SetToolCalls(toolCalls)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []dto.Message
|
||||
wantTexts []string
|
||||
wantToolUses []string
|
||||
}{
|
||||
{
|
||||
name: "both assistants have tool calls",
|
||||
messages: []dto.Message{
|
||||
{Role: "user", Content: "Look up a and b."},
|
||||
assistant("", toolCall("call_a")),
|
||||
assistant("", toolCall("call_b")),
|
||||
{Role: "tool", ToolCallId: "call_a", Content: "result a"},
|
||||
{Role: "tool", ToolCallId: "call_b", Content: "result b"},
|
||||
},
|
||||
wantTexts: []string{"..."},
|
||||
wantToolUses: []string{"call_a", "call_b"},
|
||||
},
|
||||
{
|
||||
name: "tool calls followed by plain text",
|
||||
messages: []dto.Message{
|
||||
{Role: "user", Content: "Look up a."},
|
||||
assistant(nil, toolCall("call_a")),
|
||||
assistant("Checking a.", nil),
|
||||
{Role: "tool", ToolCallId: "call_a", Content: "result a"},
|
||||
},
|
||||
wantTexts: []string{"Checking a."},
|
||||
wantToolUses: []string{"call_a"},
|
||||
},
|
||||
{
|
||||
name: "literal ellipsis with tool calls is preserved",
|
||||
messages: []dto.Message{
|
||||
{Role: "user", Content: "Look up a and b."},
|
||||
assistant("...", toolCall("call_a")),
|
||||
assistant("Checking b.", toolCall("call_b")),
|
||||
{Role: "tool", ToolCallId: "call_a", Content: "result a"},
|
||||
{Role: "tool", ToolCallId: "call_b", Content: "result b"},
|
||||
},
|
||||
wantTexts: []string{"... Checking b."},
|
||||
wantToolUses: []string{"call_a", "call_b"},
|
||||
},
|
||||
{
|
||||
name: "literal ellipsis replacing a placeholder survives the next merge",
|
||||
messages: []dto.Message{
|
||||
{Role: "user", Content: "Look up a."},
|
||||
assistant(nil, toolCall("call_a")),
|
||||
assistant("...", nil),
|
||||
assistant("Checking a.", nil),
|
||||
{Role: "tool", ToolCallId: "call_a", Content: "result a"},
|
||||
},
|
||||
wantTexts: []string{"... Checking a."},
|
||||
wantToolUses: []string{"call_a"},
|
||||
},
|
||||
{
|
||||
name: "literal ellipsis before tool calls survives repeated merges",
|
||||
messages: []dto.Message{
|
||||
{Role: "user", Content: "Look up a."},
|
||||
assistant("...", nil),
|
||||
assistant("", toolCall("call_a")),
|
||||
assistant("Checking a.", nil),
|
||||
{Role: "tool", ToolCallId: "call_a", Content: "result a"},
|
||||
},
|
||||
wantTexts: []string{"... Checking a."},
|
||||
wantToolUses: []string{"call_a"},
|
||||
},
|
||||
{
|
||||
name: "three tool-only assistants keep one placeholder",
|
||||
messages: []dto.Message{
|
||||
{Role: "user", Content: "Look up a, b and c."},
|
||||
assistant(nil, toolCall("call_a")),
|
||||
assistant("", toolCall("call_b")),
|
||||
assistant("", toolCall("call_c")),
|
||||
{Role: "tool", ToolCallId: "call_a", Content: "result a"},
|
||||
{Role: "tool", ToolCallId: "call_b", Content: "result b"},
|
||||
{Role: "tool", ToolCallId: "call_c", Content: "result c"},
|
||||
},
|
||||
wantTexts: []string{"..."},
|
||||
wantToolUses: []string{"call_a", "call_b", "call_c"},
|
||||
},
|
||||
{
|
||||
name: "both assistants are plain text",
|
||||
messages: []dto.Message{
|
||||
{Role: "user", Content: "Hello."},
|
||||
assistant("First.", nil),
|
||||
assistant("Second.", nil),
|
||||
},
|
||||
wantTexts: []string{"First. Second."},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
maxTokens := uint(1024)
|
||||
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), nil, dto.GeneralOpenAIRequest{
|
||||
Model: "claude-test",
|
||||
MaxTokens: &maxTokens,
|
||||
Messages: tt.messages,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var wantToolResults []dto.ClaudeMediaMessage
|
||||
for _, message := range tt.messages {
|
||||
if message.Role == "tool" {
|
||||
wantToolResults = append(wantToolResults, dto.ClaudeMediaMessage{
|
||||
Type: "tool_result",
|
||||
ToolUseId: message.ToolCallId,
|
||||
Content: message.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(wantToolResults) > 0 {
|
||||
require.Len(t, got.Messages, 3)
|
||||
assert.Equal(t, "user", got.Messages[2].Role)
|
||||
assert.Equal(t, wantToolResults, got.Messages[2].Content)
|
||||
} else {
|
||||
require.Len(t, got.Messages, 2)
|
||||
}
|
||||
assert.Equal(t, "assistant", got.Messages[1].Role)
|
||||
|
||||
var texts, toolUses []string
|
||||
switch content := got.Messages[1].Content.(type) {
|
||||
case string:
|
||||
texts = append(texts, content)
|
||||
case []dto.ClaudeMediaMessage:
|
||||
for _, block := range content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
texts = append(texts, block.GetText())
|
||||
case "tool_use":
|
||||
toolUses = append(toolUses, block.Id)
|
||||
assert.Equal(t, "lookup_"+block.Id, block.Name)
|
||||
assert.Equal(t, map[string]any{"q": block.Id}, block.Input)
|
||||
}
|
||||
}
|
||||
default:
|
||||
require.Failf(t, "unexpected assistant content type", "%T", content)
|
||||
}
|
||||
assert.Equal(t, tt.wantTexts, texts)
|
||||
assert.Equal(t, tt.wantToolUses, toolUses)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user