From e2011cb805b48616f829ebd8e495cafc50001580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Wed, 10 Sep 2025 14:18:44 +0800 Subject: [PATCH] fix(claude): support array content format in tool_result and remove duplicate structs (#2892) --- .../extensions/ai-proxy/provider/claude.go | 17 ++++- .../ai-proxy/provider/claude_to_openai.go | 4 +- .../provider/claude_to_openai_test.go | 73 +++++++++++++++++++ .../extensions/ai-proxy/provider/moonshot.go | 37 +--------- .../extensions/ai-proxy/provider/zhipuai.go | 6 +- 5 files changed, 93 insertions(+), 44 deletions(-) diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go index bfba782f7..1dd7ec805 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go @@ -68,8 +68,8 @@ type claudeChatMessageContent struct { Name string `json:"name,omitempty"` // For tool_use Input map[string]interface{} `json:"input,omitempty"` // For tool_use // Tool result fields - ToolUseId string `json:"tool_use_id,omitempty"` // For tool_result - Content string `json:"content,omitempty"` // For tool_result + ToolUseId string `json:"tool_use_id,omitempty"` // For tool_result + Content claudeChatMessageContentWr `json:"content,omitempty"` // For tool_result - can be string or array } // UnmarshalJSON implements custom JSON unmarshaling for claudeChatMessageContentWr @@ -88,6 +88,8 @@ func (ccw *claudeChatMessageContentWr) UnmarshalJSON(data []byte) error { ccw.ArrayValue = arrayValue ccw.IsString = false return nil + } else { + log.Errorf("claude chat message unmarshal failed, data:%s, err:%v", data, err) } return fmt.Errorf("content field must be either a string or an array of content blocks") @@ -101,12 +103,19 @@ func (ccw claudeChatMessageContentWr) MarshalJSON() ([]byte, error) { return json.Marshal(ccw.ArrayValue) } -// GetStringValue returns the string representation if it's a string, empty string otherwise +// GetStringValue returns the string representation if it's a string, or concatenated text from array blocks func (ccw claudeChatMessageContentWr) GetStringValue() string { if ccw.IsString { return ccw.StringValue } - return "" + // If it's an array, concatenate text content from all blocks + var parts []string + for _, block := range ccw.ArrayValue { + if block.Text != "" { + parts = append(parts, block.Text) + } + } + return strings.Join(parts, "\n") } // GetArrayValue returns the array representation if it's an array, empty slice otherwise diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go index 9efa1b039..6f4622b6a 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go @@ -108,7 +108,7 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b for _, toolResult := range conversionResult.toolResults { toolMsg := chatMessage{ Role: "tool", - Content: toolResult.Content, + Content: toolResult.Content.GetStringValue(), ToolCallId: toolResult.ToolUseId, } openaiRequest.Messages = append(openaiRequest.Messages, toolMsg) @@ -271,7 +271,7 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.Http var input map[string]interface{} if toolCall.Function.Arguments != "" { if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &input); err != nil { - log.Errorf("Failed to parse tool call arguments: %v", err) + log.Errorf("Failed to parse tool call arguments: %v, arguments: %s", err, toolCall.Function.Arguments) input = map[string]interface{}{} } } else { diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go index f2da93c7b..10b9a1ac0 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go @@ -352,6 +352,79 @@ func TestClaudeToOpenAIConverter_ConvertClaudeRequestToOpenAI(t *testing.T) { assert.Equal(t, "toolu_01D7FLrfh4GYq7yT1ULFeyMV", toolMsg.ToolCallId) }) + t.Run("convert_tool_result_with_array_content", func(t *testing.T) { + // Test Claude tool_result with array content format (new format that was causing the error) + claudeRequest := `{ + "model": "anthropic/claude-sonnet-4", + "messages": [{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_vrtx_01UbCfwoTgoDBqbYEwkVaxd5", + "content": [{ + "text": "Search results for three.js libraries and frameworks", + "type": "text" + }] + }] + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // Should have one tool message + require.Len(t, openaiRequest.Messages, 1) + toolMsg := openaiRequest.Messages[0] + + assert.Equal(t, "tool", toolMsg.Role) + assert.Equal(t, "Search results for three.js libraries and frameworks", toolMsg.Content) + assert.Equal(t, "toolu_vrtx_01UbCfwoTgoDBqbYEwkVaxd5", toolMsg.ToolCallId) + }) + + t.Run("convert_tool_result_with_actual_error_data", func(t *testing.T) { + // Test using the actual JSON data from the error log to ensure our fix works + claudeRequest := `{ + "model": "anthropic/claude-sonnet-4", + "messages": [{ + "role": "user", + "content": [{ + "content": [{ + "text": "\n ## 结果 1\n - **id**: /websites/threejs\n - **title**: three.js\n - **description**: three.js is a JavaScript 3D library that makes it easy to create and display animated 3D computer graphics in a web browser. It provides a powerful and flexible way to build interactive 3D experiences.\n", + "type": "text" + }], + "tool_use_id": "toolu_vrtx_01UbCfwoTgoDBqbYEwkVaxd5", + "type": "tool_result" + }, { + "cache_control": {"type": "ephemeral"}, + "text": "继续", + "type": "text" + }] + }], + "max_tokens": 1000 + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // Should have one tool message (the text content is included in the same message array) + require.Len(t, openaiRequest.Messages, 1) + + // Should be tool message + toolMsg := openaiRequest.Messages[0] + assert.Equal(t, "tool", toolMsg.Role) + assert.Contains(t, toolMsg.Content, "three.js") + assert.Equal(t, "toolu_vrtx_01UbCfwoTgoDBqbYEwkVaxd5", toolMsg.ToolCallId) + }) + t.Run("convert_multiple_tool_calls", func(t *testing.T) { // Test multiple tool_use in single message claudeRequest := `{ diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/moonshot.go b/plugins/wasm-go/extensions/ai-proxy/provider/moonshot.go index 31dd0d8da..139c5767e 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/moonshot.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/moonshot.go @@ -7,10 +7,9 @@ import ( "strings" "github.com/alibaba/higress/plugins/wasm-go/extensions/ai-proxy/util" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" "github.com/higress-group/wasm-go/pkg/log" "github.com/higress-group/wasm-go/pkg/wrapper" - "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" - "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -81,39 +80,7 @@ func (m *moonshotProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiNam if !m.config.isSupportedAPI(apiName) { return types.ActionContinue, errUnsupportedApiName } - // 非chat类型的请求,不做处理 - if apiName != ApiNameChatCompletion { - return types.ActionContinue, nil - } - - request := &chatCompletionRequest{} - if err := m.config.parseRequestAndMapModel(ctx, request, body); err != nil { - return types.ActionContinue, err - } - - if m.config.moonshotFileId == "" && m.contextCache == nil { - return types.ActionContinue, replaceJsonRequestBody(request) - } - - apiKey := m.config.GetOrSetTokenWithContext(ctx) - err := m.getContextContent(apiKey, func(content string, err error) { - defer func() { - _ = proxywasm.ResumeHttpRequest() - }() - if err != nil { - log.Errorf("failed to load context file: %v", err) - _ = util.ErrorHandler("ai-proxy.moonshot.load_ctx_failed", fmt.Errorf("failed to load context file: %v", err)) - return - } - err = m.performChatCompletion(ctx, content, request) - if err != nil { - _ = util.ErrorHandler("ai-proxy.moonshot.insert_ctx_failed", fmt.Errorf("failed to perform chat completion: %v", err)) - } - }) - if err == nil { - return types.ActionPause, nil - } - return types.ActionContinue, err + return m.config.handleRequestBody(m, m.contextCache, ctx, apiName, body) } func (m *moonshotProvider) performChatCompletion(ctx wrapper.HttpContext, fileContent string, request *chatCompletionRequest) error { diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/zhipuai.go b/plugins/wasm-go/extensions/ai-proxy/provider/zhipuai.go index f6e0ca5c5..c2e01bcf7 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/zhipuai.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/zhipuai.go @@ -28,9 +28,9 @@ func (m *zhipuAiProviderInitializer) ValidateConfig(config *ProviderConfig) erro func (m *zhipuAiProviderInitializer) DefaultCapabilities() map[string]string { return map[string]string{ - string(ApiNameChatCompletion): zhipuAiChatCompletionPath, - string(ApiNameEmbeddings): zhipuAiEmbeddingsPath, - string(ApiNameAnthropicMessages): zhipuAiAnthropicMessagesPath, + string(ApiNameChatCompletion): zhipuAiChatCompletionPath, + string(ApiNameEmbeddings): zhipuAiEmbeddingsPath, + // string(ApiNameAnthropicMessages): zhipuAiAnthropicMessagesPath, } }