From a07f5024a9d6fe24b170de6c7bbeddcda8e3fb76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Sun, 15 Feb 2026 22:45:09 +0800 Subject: [PATCH] fix(ai-proxy): convert OpenAI tool role to Claude user role with tool_result (#3517) --- .../extensions/ai-proxy/provider/claude.go | 93 ++++++++++++++++ .../ai-proxy/provider/claude_test.go | 104 ++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go index f2e57679b..4b763ce75 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go @@ -498,9 +498,102 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe continue } + // Handle OpenAI "tool" role messages - convert to Claude "user" role with tool_result content + if message.Role == roleTool { + toolResultContent := claudeChatMessageContent{ + Type: "tool_result", + ToolUseId: message.ToolCallId, + } + // Tool result content can be string or array + if message.IsStringContent() { + toolResultContent.Content = &claudeChatMessageContentWr{ + StringValue: message.StringContent(), + IsString: true, + } + } else { + // For array content, extract text parts + var textParts []string + for _, part := range message.ParseContent() { + if part.Type == contentTypeText { + textParts = append(textParts, part.Text) + } + } + toolResultContent.Content = &claudeChatMessageContentWr{ + StringValue: strings.Join(textParts, "\n"), + IsString: true, + } + } + + // Check if the last message is a user message with tool_result, merge if so + if len(claudeRequest.Messages) > 0 { + lastMsg := &claudeRequest.Messages[len(claudeRequest.Messages)-1] + if lastMsg.Role == roleUser && !lastMsg.Content.IsString { + // Check if last message contains tool_result + hasToolResult := false + for _, content := range lastMsg.Content.ArrayValue { + if content.Type == "tool_result" { + hasToolResult = true + break + } + } + if hasToolResult { + // Merge with existing tool_result message + lastMsg.Content.ArrayValue = append(lastMsg.Content.ArrayValue, toolResultContent) + continue + } + } + } + + // Create new user message with tool_result + claudeMessage := claudeChatMessage{ + Role: roleUser, + Content: NewArrayContent([]claudeChatMessageContent{toolResultContent}), + } + claudeRequest.Messages = append(claudeRequest.Messages, claudeMessage) + continue + } + claudeMessage := claudeChatMessage{ Role: message.Role, } + + // Handle assistant messages with tool_calls - convert to Claude tool_use content blocks + if message.Role == roleAssistant && len(message.ToolCalls) > 0 { + chatMessageContents := make([]claudeChatMessageContent, 0) + + // Add text content if present + if message.IsStringContent() && message.StringContent() != "" { + chatMessageContents = append(chatMessageContents, claudeChatMessageContent{ + Type: contentTypeText, + Text: message.StringContent(), + }) + } + + // Convert tool_calls to tool_use content blocks + for _, tc := range message.ToolCalls { + var inputMap map[string]interface{} + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &inputMap); err != nil { + log.Errorf("failed to parse tool call arguments: %v", err) + inputMap = make(map[string]interface{}) + } + } else { + inputMap = make(map[string]interface{}) + } + + chatMessageContents = append(chatMessageContents, claudeChatMessageContent{ + Type: "tool_use", + Id: tc.Id, + Name: tc.Function.Name, + Input: inputMap, + }) + } + + claudeMessage.Content = NewArrayContent(chatMessageContents) + claudeRequest.Messages = append(claudeRequest.Messages, claudeMessage) + continue + } + if message.IsStringContent() { claudeMessage.Content = NewStringContent(message.StringContent()) } else { diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go index fb93ed20b..71494c8e5 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go @@ -315,3 +315,107 @@ func TestClaudeProvider_GetApiName(t *testing.T) { assert.Equal(t, ApiName(""), provider.GetApiName("/unknown")) }) } + +func TestClaudeProvider_BuildClaudeTextGenRequest_ToolRoleConversion(t *testing.T) { + provider := &claudeProvider{ + config: ProviderConfig{ + claudeCodeMode: false, + }, + } + + t.Run("converts_single_tool_role_to_user_with_tool_result", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 1024, + Messages: []chatMessage{ + {Role: roleUser, Content: "What's the weather?"}, + {Role: roleAssistant, Content: nil, ToolCalls: []toolCall{ + {Id: "call_123", Type: "function", Function: functionCall{Name: "get_weather", Arguments: `{"city": "Beijing"}`}}, + }}, + {Role: roleTool, ToolCallId: "call_123", Content: "Sunny, 25°C"}, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + // Should have 3 messages: user, assistant with tool_use, user with tool_result + require.Len(t, claudeReq.Messages, 3) + + // First message should be user + assert.Equal(t, roleUser, claudeReq.Messages[0].Role) + + // Second message should be assistant with tool_use + assert.Equal(t, roleAssistant, claudeReq.Messages[1].Role) + require.False(t, claudeReq.Messages[1].Content.IsString) + require.Len(t, claudeReq.Messages[1].Content.ArrayValue, 1) + assert.Equal(t, "tool_use", claudeReq.Messages[1].Content.ArrayValue[0].Type) + assert.Equal(t, "call_123", claudeReq.Messages[1].Content.ArrayValue[0].Id) + assert.Equal(t, "get_weather", claudeReq.Messages[1].Content.ArrayValue[0].Name) + + // Third message should be user with tool_result + assert.Equal(t, roleUser, claudeReq.Messages[2].Role) + require.False(t, claudeReq.Messages[2].Content.IsString) + require.Len(t, claudeReq.Messages[2].Content.ArrayValue, 1) + assert.Equal(t, "tool_result", claudeReq.Messages[2].Content.ArrayValue[0].Type) + assert.Equal(t, "call_123", claudeReq.Messages[2].Content.ArrayValue[0].ToolUseId) + }) + + t.Run("merges_multiple_tool_results_into_single_user_message", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 1024, + Messages: []chatMessage{ + {Role: roleUser, Content: "What's the weather and time?"}, + {Role: roleAssistant, Content: nil, ToolCalls: []toolCall{ + {Id: "call_1", Type: "function", Function: functionCall{Name: "get_weather", Arguments: `{"city": "Beijing"}`}}, + {Id: "call_2", Type: "function", Function: functionCall{Name: "get_time", Arguments: `{"timezone": "Asia/Shanghai"}`}}, + }}, + {Role: roleTool, ToolCallId: "call_1", Content: "Sunny, 25°C"}, + {Role: roleTool, ToolCallId: "call_2", Content: "3:00 PM"}, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + // Should have 3 messages: user, assistant with 2 tool_use, user with 2 tool_results + require.Len(t, claudeReq.Messages, 3) + + // Assistant message should have 2 tool_use blocks + require.Len(t, claudeReq.Messages[1].Content.ArrayValue, 2) + assert.Equal(t, "tool_use", claudeReq.Messages[1].Content.ArrayValue[0].Type) + assert.Equal(t, "tool_use", claudeReq.Messages[1].Content.ArrayValue[1].Type) + + // User message should have 2 tool_result blocks merged + assert.Equal(t, roleUser, claudeReq.Messages[2].Role) + require.Len(t, claudeReq.Messages[2].Content.ArrayValue, 2) + assert.Equal(t, "tool_result", claudeReq.Messages[2].Content.ArrayValue[0].Type) + assert.Equal(t, "call_1", claudeReq.Messages[2].Content.ArrayValue[0].ToolUseId) + assert.Equal(t, "tool_result", claudeReq.Messages[2].Content.ArrayValue[1].Type) + assert.Equal(t, "call_2", claudeReq.Messages[2].Content.ArrayValue[1].ToolUseId) + }) + + t.Run("handles_assistant_tool_calls_with_text_content", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 1024, + Messages: []chatMessage{ + {Role: roleUser, Content: "What's the weather?"}, + {Role: roleAssistant, Content: "Let me check the weather for you.", ToolCalls: []toolCall{ + {Id: "call_123", Type: "function", Function: functionCall{Name: "get_weather", Arguments: `{"city": "Beijing"}`}}, + }}, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + require.Len(t, claudeReq.Messages, 2) + + // Assistant message should have both text and tool_use + assert.Equal(t, roleAssistant, claudeReq.Messages[1].Role) + require.False(t, claudeReq.Messages[1].Content.IsString) + require.Len(t, claudeReq.Messages[1].Content.ArrayValue, 2) + assert.Equal(t, contentTypeText, claudeReq.Messages[1].Content.ArrayValue[0].Type) + assert.Equal(t, "Let me check the weather for you.", claudeReq.Messages[1].Content.ArrayValue[0].Text) + assert.Equal(t, "tool_use", claudeReq.Messages[1].Content.ArrayValue[1].Type) + }) +}