fix(claude): support array content format in tool_result and remove duplicate structs (#2892)

This commit is contained in:
澄潭
2025-09-10 14:18:44 +08:00
committed by GitHub
parent 4edf79a1f6
commit e2011cb805
5 changed files with 93 additions and 44 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 := `{

View File

@@ -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 {

View File

@@ -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,
}
}