From b77a0748315b4b6477ebb0cb23e0e4c8837b864f Mon Sep 17 00:00:00 2001 From: Betula-L Date: Thu, 7 May 2026 19:27:48 -0700 Subject: [PATCH] fix(ai-proxy): preserve Bedrock Claude reasoning blocks (#3788) Signed-off-by: Betula-L <6059935+Betula-L@users.noreply.github.com> Co-authored-by: Betula-L <6059935+Betula-L@users.noreply.github.com> --- .../extensions/ai-proxy/provider/bedrock.go | 270 +++++++-- .../provider/bedrock_thinking_test.go | 540 ++++++++++++++++++ .../extensions/ai-proxy/provider/claude.go | 130 ++++- .../ai-proxy/provider/claude_test.go | 177 ++++++ .../ai-proxy/provider/claude_to_openai.go | 347 ++++++++--- .../extensions/ai-proxy/provider/model.go | 30 +- .../extensions/ai-proxy/provider/provider.go | 36 ++ .../ai-proxy/provider/provider_test.go | 32 ++ 8 files changed, 1420 insertions(+), 142 deletions(-) create mode 100644 plugins/wasm-go/extensions/ai-proxy/provider/bedrock_thinking_test.go diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/bedrock.go b/plugins/wasm-go/extensions/ai-proxy/provider/bedrock.go index 4fd4cda0b..5cb91e6fb 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/bedrock.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/bedrock.go @@ -115,6 +115,7 @@ func (b *bedrockProvider) OnStreamingResponseBody(ctx wrapper.HttpContext, name } func (b *bedrockProvider) convertEventFromBedrockToOpenAI(ctx wrapper.HttpContext, bedrockEvent ConverseStreamEvent) ([]byte, error) { + needClaudeResponseConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) choices := make([]chatCompletionChoice, 0) chatChoice := chatCompletionChoice{ Delta: &chatMessage{}, @@ -122,7 +123,17 @@ func (b *bedrockProvider) convertEventFromBedrockToOpenAI(ctx wrapper.HttpContex if bedrockEvent.Role != nil { chatChoice.Delta.Role = *bedrockEvent.Role } + if bedrockEvent.ContentBlockStop != nil { + if !needClaudeResponseConversion { + return []byte{}, nil + } + index := bedrockEvent.ContentBlockStop.ContentBlockIndex + chatChoice.Delta.ClaudeContentBlockStop = &index + } if bedrockEvent.Start != nil { + if bedrockEvent.Start.ToolUse == nil { + return nil, nil + } toolCallIndex := getBedrockOpenAIToolCallIndex(ctx, bedrockEvent.ContentBlockIndex) chatChoice.Delta.Content = nil chatChoice.Delta.ToolCalls = []toolCall{ @@ -136,24 +147,22 @@ func (b *bedrockProvider) convertEventFromBedrockToOpenAI(ctx wrapper.HttpContex }, }, } + if needClaudeResponseConversion { + index := bedrockEvent.ContentBlockIndex + chatChoice.Delta.ClaudeContentBlockIndex = &index + } } if bedrockEvent.Delta != nil { if bedrockEvent.Delta.ReasoningContent != nil { - var content string - if ctx.GetContext("thinking_start") == nil { - content += reasoningStartTag - ctx.SetContext("thinking_start", true) - } - content += bedrockEvent.Delta.ReasoningContent.Text - chatChoice.Delta = &chatMessage{Content: &content} + chatChoice.Delta.ReasoningContent = bedrockEvent.Delta.ReasoningContent.Text + chatChoice.Delta.ReasoningSignature = bedrockEvent.Delta.ReasoningContent.Signature + chatChoice.Delta.ReasoningRedactedContent = bedrockEvent.Delta.ReasoningContent.RedactedContent } else if bedrockEvent.Delta.Text != nil { - var content string - if ctx.GetContext("thinking_start") != nil && ctx.GetContext("thinking_end") == nil { - content += reasoningEndTag - ctx.SetContext("thinking_end", true) - } - content += *bedrockEvent.Delta.Text - chatChoice.Delta = &chatMessage{Content: &content} + chatChoice.Delta.Content = *bedrockEvent.Delta.Text + } + if needClaudeResponseConversion { + index := bedrockEvent.ContentBlockIndex + chatChoice.Delta.ClaudeContentBlockIndex = &index } if bedrockEvent.Delta.ToolUse != nil { toolCallIndex := getBedrockOpenAIToolCallIndex(ctx, bedrockEvent.ContentBlockIndex) @@ -227,6 +236,11 @@ type ConverseStreamEvent struct { StopReason *string `json:"stopReason,omitempty"` Usage *tokenUsage `json:"usage,omitempty"` Start *contentBlockStart `json:"start,omitempty"` + ContentBlockStop *contentBlockStop `json:"contentBlockStop,omitempty"` +} + +type contentBlockStop struct { + ContentBlockIndex int `json:"contentBlockIndex"` } type converseStreamEventContentBlockDelta struct { @@ -249,8 +263,9 @@ type toolUseBlockDelta struct { } type reasoningContentDelta struct { - Text string `json:"text,omitempty"` - Signature string `json:"signature,omitempty"` + Text string `json:"text,omitempty"` + Signature string `json:"signature,omitempty"` + RedactedContent string `json:"redactedContent,omitempty"` } type bedrockImageGenerationResponse struct { @@ -346,6 +361,9 @@ func extractAmazonEventStreamEvents(ctx wrapper.HttpContext, chunk []byte) []Con } var event ConverseStreamEvent if err = json.Unmarshal(msg.Payload, &event); err == nil { + if eventType, ok := amazonEventType(msg.Headers); ok && eventType == "contentBlockStop" { + event.ContentBlockStop = &contentBlockStop{ContentBlockIndex: event.ContentBlockIndex} + } events = append(events, event) } lastRead = r.Size() - int64(r.Len()) @@ -358,6 +376,16 @@ func extractAmazonEventStreamEvents(ctx wrapper.HttpContext, chunk []byte) []Con return events } +func amazonEventType(headers headers) (string, bool) { + for _, header := range headers { + if header.Name == ":event-type" { + value, ok := header.Value.Get().(string) + return value, ok + } + } + return "", false +} + type bedrockStreamMessage struct { Headers headers Payload []byte @@ -821,7 +849,6 @@ func (b *bedrockProvider) onChatCompletionRequestBody(ctx wrapper.HttpContext, b if err != nil { return nil, err } - streaming := request.Stream headers.Set("Accept", "*/*") if streaming { @@ -841,13 +868,16 @@ func (b *bedrockProvider) buildBedrockTextGenerationRequest(origRequest *chatCom case roleSystem: systemMessages = append(systemMessages, systemContentBlock{Text: msg.StringContent()}) case roleTool: - toolResultContent := chatToolMessage2BedrockToolResultContent(msg) + toolResultContents := []bedrockMessageContent{chatToolMessage2BedrockToolResultContent(msg)} + if len(msg.ClaudeContentBlocks) > 0 { + toolResultContents = claudeContentBlocksToBedrockContents(msg.ClaudeContentBlocks) + } if len(messages) > 0 && messages[len(messages)-1].Role == roleUser && messages[len(messages)-1].Content[0].ToolResult != nil { - messages[len(messages)-1].Content = append(messages[len(messages)-1].Content, toolResultContent) + messages[len(messages)-1].Content = append(messages[len(messages)-1].Content, toolResultContents...) } else { messages = append(messages, bedrockMessage{ Role: roleUser, - Content: []bedrockMessageContent{toolResultContent}, + Content: toolResultContents, }) } default: @@ -882,7 +912,13 @@ func (b *bedrockProvider) buildBedrockTextGenerationRequest(origRequest *chatCom log.Warnf("skip prompt cache injection for unsupported model: %s", origRequest.Model) } - if origRequest.ReasoningEffort != "" { + thinking := bedrockThinkingFromClaudeConfig(origRequest.ClaudeThinking) + if thinking != nil { + if origRequest.ClaudeThinking.Type == "adaptive" && origRequest.ClaudeOutputConfig != nil && bedrockSupportsAdaptiveEffort(origRequest.ClaudeOutputConfig.Effort) { + thinking["effort"] = origRequest.ClaudeOutputConfig.Effort + } + request.AdditionalModelRequestFields["thinking"] = thinking + } else if origRequest.ReasoningEffort != "" { thinkingBudget := 1024 // default switch origRequest.ReasoningEffort { case "low": @@ -897,8 +933,14 @@ func (b *bedrockProvider) buildBedrockTextGenerationRequest(origRequest *chatCom "budget_tokens": thinkingBudget, } } + if outputConfig := origRequest.ClaudeOutputConfig; outputConfig != nil { + if len(outputConfig.Format) > 0 { + request.OutputConfig = &bedrockOutputConfig{TextFormat: outputConfig.Format} + } + } if origRequest.Tools != nil && origRequest.getToolChoiceType() != "none" { + hasThinking := thinking != nil || origRequest.ReasoningEffort != "" request.ToolConfig = &bedrockToolConfig{} request.ToolConfig.ToolChoice.Auto = &struct{}{} if choice_type := origRequest.getToolChoiceType(); choice_type != "" { @@ -906,12 +948,14 @@ func (b *bedrockProvider) buildBedrockTextGenerationRequest(origRequest *chatCom // "any" is accepted for direct Anthropic-compatible callers; OpenAI // uses "required" for the same "must call at least one tool" behavior. case "required", "any": - request.ToolConfig.ToolChoice.Auto = nil - request.ToolConfig.ToolChoice.Any = &struct{}{} + if !hasThinking { + request.ToolConfig.ToolChoice.Auto = nil + request.ToolConfig.ToolChoice.Any = &struct{}{} + } case "auto": request.ToolConfig.ToolChoice.Auto = &struct{}{} case "function": - if choice := origRequest.getToolChoiceObject(); choice != nil && choice.Function.Name != "" { + if choice := origRequest.getToolChoiceObject(); !hasThinking && choice != nil && choice.Function.Name != "" { request.ToolConfig.ToolChoice.Auto = nil request.ToolConfig.ToolChoice.Tool = &bedrockSpecificToolChoice{ Name: choice.Function.Name, @@ -939,25 +983,62 @@ func (b *bedrockProvider) buildBedrockTextGenerationRequest(origRequest *chatCom } func (b *bedrockProvider) buildChatCompletionResponse(ctx wrapper.HttpContext, bedrockResponse *bedrockConverseResponse) *chatCompletionResponse { - var outputContent, reasoningContent, normalContent string + var reasoningContent, normalContent string + var claudeContent []claudeTextGenContent for _, content := range bedrockResponse.Output.Message.Content { if content.ReasoningContent != nil { - reasoningContent = content.ReasoningContent.ReasoningText.Text + if content.ReasoningContent.ReasoningText != nil { + text := content.ReasoningContent.ReasoningText.Text + signature := content.ReasoningContent.ReasoningText.Signature + if text != "" || signature != "" { + reasoningContent += text + claudeContent = append(claudeContent, claudeTextGenContent{ + Type: "thinking", + Thinking: &text, + Signature: &signature, + }) + } + } + if content.ReasoningContent.RedactedContent != "" { + claudeContent = append(claudeContent, claudeTextGenContent{ + Type: "redacted_thinking", + Data: content.ReasoningContent.RedactedContent, + }) + } + } + if content.ToolUse != nil { + input := content.ToolUse.Input + if input == nil { + input = map[string]interface{}{} + } + claudeContent = append(claudeContent, claudeTextGenContent{ + Type: "tool_use", + Id: content.ToolUse.ToolUseId, + Name: content.ToolUse.Name, + Input: &input, + }) } if content.Text != "" { - normalContent = content.Text + normalContent += content.Text + text := content.Text + claudeContent = append(claudeContent, claudeTextGenContent{ + Type: "text", + Text: &text, + }) } } - if reasoningContent != "" { - outputContent = reasoningStartTag + reasoningContent + reasoningEndTag + normalContent - } else { - outputContent = normalContent + if ctx != nil { + needClaudeResponseConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if needClaudeResponseConversion && len(claudeContent) > 0 { + ctx.SetContext(ctxKeyClaudeNativeResponseContent, claudeContent) + } } choice := chatCompletionChoice{ Index: 0, Message: &chatMessage{ - Role: bedrockResponse.Output.Message.Role, - Content: outputContent, + Role: bedrockResponse.Output.Message.Role, + Content: normalContent, + ReasoningContent: reasoningContent, }, FinishReason: util.Ptr(stopReasonBedrock2OpenAI(bedrockResponse.StopReason)), } @@ -1163,11 +1244,16 @@ type bedrockTextGenRequest struct { Messages []bedrockMessage `json:"messages"` System []systemContentBlock `json:"system,omitempty"` InferenceConfig bedrockInferenceConfig `json:"inferenceConfig,omitempty"` + OutputConfig *bedrockOutputConfig `json:"outputConfig,omitempty"` AdditionalModelRequestFields map[string]interface{} `json:"additionalModelRequestFields,omitempty"` PerformanceConfig PerformanceConfiguration `json:"performanceConfig,omitempty"` ToolConfig *bedrockToolConfig `json:"toolConfig,omitempty"` } +type bedrockOutputConfig struct { + TextFormat json.RawMessage `json:"textFormat,omitempty"` +} + type bedrockToolConfig struct { Tools []bedrockTool `json:"tools,omitempty"` ToolChoice bedrockToolChoice `json:"toolChoice,omitempty"` @@ -1207,11 +1293,12 @@ type bedrockMessage struct { } type bedrockMessageContent struct { - Text string `json:"text,omitempty"` - Image *imageBlock `json:"image,omitempty"` - ToolResult *toolResultBlock `json:"toolResult,omitempty"` - ToolUse *toolUseBlock `json:"toolUse,omitempty"` - CachePoint *bedrockCachePoint `json:"cachePoint,omitempty"` + Text string `json:"text,omitempty"` + Image *imageBlock `json:"image,omitempty"` + ToolResult *toolResultBlock `json:"toolResult,omitempty"` + ToolUse *toolUseBlock `json:"toolUse,omitempty"` + ReasoningContent *reasoningContent `json:"reasoningContent,omitempty"` + CachePoint *bedrockCachePoint `json:"cachePoint,omitempty"` } type systemContentBlock struct { @@ -1240,7 +1327,8 @@ type toolResultBlock struct { } type toolResultContentBlock struct { - Text string `json:"text"` + Text *string `json:"text,omitempty"` + Image *imageBlock `json:"image,omitempty"` } type toolUseBlock struct { @@ -1283,11 +1371,12 @@ type contentBlock struct { } type reasoningContent struct { - ReasoningText reasoningText `json:"reasoningText"` + ReasoningText *reasoningText `json:"reasoningText,omitempty"` + RedactedContent string `json:"redactedContent,omitempty"` } type reasoningText struct { - Text string `json:"text,omitempty"` + Text string `json:"text"` Signature string `json:"signature,omitempty"` } @@ -1315,7 +1404,7 @@ func chatToolMessage2BedrockToolResultContent(chatMessage chatMessage) bedrockMe if text, ok := chatMessage.Content.(string); ok { toolResultContent.Content = []toolResultContentBlock{ { - Text: text, + Text: util.Ptr(text), }, } } else if contentList, ok := chatMessage.Content.([]any); ok { @@ -1324,7 +1413,7 @@ func chatToolMessage2BedrockToolResultContent(chatMessage chatMessage) bedrockMe if ok && contentMap["type"] == contentTypeText { if text, ok := contentMap[contentTypeText].(string); ok { toolResultContent.Content = append(toolResultContent.Content, toolResultContentBlock{ - Text: text, + Text: util.Ptr(text), }) } } @@ -1339,6 +1428,12 @@ func chatToolMessage2BedrockToolResultContent(chatMessage chatMessage) bedrockMe func chatMessage2BedrockMessage(chatMessage chatMessage) bedrockMessage { var result bedrockMessage + if len(chatMessage.ClaudeContentBlocks) > 0 { + return bedrockMessage{ + Role: chatMessage.Role, + Content: claudeContentBlocksToBedrockContents(chatMessage.ClaudeContentBlocks), + } + } if len(chatMessage.ToolCalls) > 0 { contents := make([]bedrockMessageContent, 0, len(chatMessage.ToolCalls)) for _, toolCall := range chatMessage.ToolCalls { @@ -1396,6 +1491,95 @@ func chatMessage2BedrockMessage(chatMessage chatMessage) bedrockMessage { return result } +func claudeContentBlocksToBedrockContents(blocks []claudeChatMessageContent) []bedrockMessageContent { + result := make([]bedrockMessageContent, 0, len(blocks)) + for _, block := range blocks { + switch block.Type { + case "text": + result = append(result, bedrockMessageContent{Text: block.Text}) + case "image": + if block.Source != nil && block.Source.Type == "base64" { + result = append(result, bedrockMessageContent{Image: &imageBlock{ + Format: strings.TrimPrefix(block.Source.MediaType, "image/"), + Source: imageSource{Bytes: block.Source.Data}, + }}) + } + case "tool_use": + result = append(result, bedrockMessageContent{ToolUse: &toolUseBlock{ + Input: block.Input, + Name: block.Name, + ToolUseId: block.Id, + }}) + case "tool_result": + result = append(result, bedrockMessageContent{ToolResult: claudeToolResultBlockToBedrock(block)}) + case "thinking": + result = append(result, bedrockMessageContent{ReasoningContent: &reasoningContent{ + ReasoningText: &reasoningText{Text: block.Thinking, Signature: block.Signature}, + }}) + case "redacted_thinking": + result = append(result, bedrockMessageContent{ReasoningContent: &reasoningContent{ + RedactedContent: block.Data, + }}) + } + } + return result +} + +func bedrockThinkingFromClaudeConfig(thinking *claudeThinkingConfig) map[string]interface{} { + if thinking == nil || thinking.Type == "" || thinking.Type == "disabled" { + return nil + } + result := map[string]interface{}{"type": thinking.Type} + if thinking.Display != "" { + result["display"] = thinking.Display + } + if thinking.Type == "enabled" && thinking.BudgetTokens > 0 { + result["budget_tokens"] = thinking.BudgetTokens + } + return result +} + +func bedrockSupportsAdaptiveEffort(effort string) bool { + switch effort { + case "low", "medium", "high": + return true + default: + return false + } +} + +func claudeToolResultBlockToBedrock(block claudeChatMessageContent) *toolResultBlock { + result := &toolResultBlock{ToolUseId: block.ToolUseId} + if block.IsError { + result.Status = "error" + } + if block.Content == nil { + result.Content = []toolResultContentBlock{{Text: util.Ptr("")}} + return result + } + if block.Content.IsString { + result.Content = append(result.Content, toolResultContentBlock{Text: util.Ptr(block.Content.StringValue)}) + return result + } + for _, item := range block.Content.ArrayValue { + switch item.Type { + case "text": + result.Content = append(result.Content, toolResultContentBlock{Text: util.Ptr(item.Text)}) + case "image": + if item.Source != nil && item.Source.Type == "base64" { + result.Content = append(result.Content, toolResultContentBlock{Image: &imageBlock{ + Format: strings.TrimPrefix(item.Source.MediaType, "image/"), + Source: imageSource{Bytes: item.Source.Data}, + }}) + } + } + } + if len(result.Content) == 0 { + result.Content = []toolResultContentBlock{{Text: util.Ptr("")}} + } + return result +} + func (b *bedrockProvider) setAuthHeaders(body []byte, headers http.Header) { // Bearer token authentication is already set in TransformRequestHeaders // This function only handles AWS SigV4 authentication which requires the request body diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/bedrock_thinking_test.go b/plugins/wasm-go/extensions/ai-proxy/provider/bedrock_thinking_test.go new file mode 100644 index 000000000..1028e222f --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/provider/bedrock_thinking_test.go @@ -0,0 +1,540 @@ +package provider + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBedrockResponsePreservesClaudeNativeThinkingSignature(t *testing.T) { + provider := &bedrockProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + + response := provider.buildChatCompletionResponse(ctx, &bedrockConverseResponse{ + Output: converseOutputMemberMessage{Message: message{ + Role: roleAssistant, + Content: []contentBlock{ + {ReasoningContent: &reasoningContent{ReasoningText: &reasoningText{Text: "reasoning", Signature: "sig"}}}, + {Text: "answer"}, + }, + }}, + StopReason: "end_turn", + }) + body, err := json.Marshal(response) + require.NoError(t, err) + + converted, err := (&ClaudeToOpenAIConverter{}).ConvertOpenAIResponseToClaude(ctx, body) + require.NoError(t, err) + + var claudeResponse claudeTextGenResponse + require.NoError(t, json.Unmarshal(converted, &claudeResponse)) + require.Len(t, claudeResponse.Content, 2) + assert.Equal(t, "thinking", claudeResponse.Content[0].Type) + require.NotNil(t, claudeResponse.Content[0].Thinking) + require.NotNil(t, claudeResponse.Content[0].Signature) + assert.Equal(t, "reasoning", *claudeResponse.Content[0].Thinking) + assert.Equal(t, "sig", *claudeResponse.Content[0].Signature) + assert.Equal(t, "text", claudeResponse.Content[1].Type) + require.NotNil(t, claudeResponse.Content[1].Text) + assert.Equal(t, "answer", *claudeResponse.Content[1].Text) +} + +func TestBedrockStreamPreservesClaudeNativeThinkingSignature(t *testing.T) { + provider := &bedrockProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + converter := &ClaudeToOpenAIConverter{} + + textChunk, err := provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{ + ContentBlockIndex: 0, + Delta: &converseStreamEventContentBlockDelta{ + ReasoningContent: &reasoningContentDelta{Text: "reasoning"}, + }, + }) + require.NoError(t, err) + _, err = converter.ConvertOpenAIStreamResponseToClaude(ctx, textChunk) + require.NoError(t, err) + + signatureChunk, err := provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{ + ContentBlockIndex: 0, + Delta: &converseStreamEventContentBlockDelta{ + ReasoningContent: &reasoningContentDelta{Signature: "sig"}, + }, + }) + require.NoError(t, err) + converted, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, signatureChunk) + require.NoError(t, err) + + events := parseClaudeSSEEvents(t, converted) + require.Len(t, events, 1) + assert.Equal(t, "content_block_delta", events[0].Name) + require.NotNil(t, events[0].Payload.Delta) + assert.Equal(t, "signature_delta", events[0].Payload.Delta.Type) + assert.Equal(t, "sig", events[0].Payload.Delta.Signature) +} + +func TestBedrockStreamPreservesClaudeNativeIndexesAndStops(t *testing.T) { + provider := &bedrockProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + converter := &ClaudeToOpenAIConverter{} + + chunk, err := provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{ + ContentBlockIndex: 2, + Delta: &converseStreamEventContentBlockDelta{ + ReasoningContent: &reasoningContentDelta{Text: "reasoning"}, + }, + }) + require.NoError(t, err) + converted, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, chunk) + require.NoError(t, err) + events := parseClaudeSSEEvents(t, converted) + require.Len(t, events, 2) + assert.Equal(t, "content_block_start", events[0].Name) + require.NotNil(t, events[0].Payload.Index) + assert.Equal(t, 2, *events[0].Payload.Index) + assert.Equal(t, "content_block_delta", events[1].Name) + require.NotNil(t, events[1].Payload.Index) + assert.Equal(t, 2, *events[1].Payload.Index) + + chunk, err = provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{ + ContentBlockIndex: 2, + ContentBlockStop: &contentBlockStop{ContentBlockIndex: 2}, + }) + require.NoError(t, err) + converted, err = converter.ConvertOpenAIStreamResponseToClaude(ctx, chunk) + require.NoError(t, err) + events = parseClaudeSSEEvents(t, converted) + require.Len(t, events, 1) + assert.Equal(t, "content_block_stop", events[0].Name) + require.NotNil(t, events[0].Payload.Index) + assert.Equal(t, 2, *events[0].Payload.Index) +} + +func TestBedrockResponsePreservesClaudeNativeRedactedThinking(t *testing.T) { + provider := &bedrockProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + + response := provider.buildChatCompletionResponse(ctx, &bedrockConverseResponse{ + Output: converseOutputMemberMessage{Message: message{ + Role: roleAssistant, + Content: []contentBlock{ + {ReasoningContent: &reasoningContent{RedactedContent: "opaque-base64"}}, + {Text: "answer"}, + }, + }}, + StopReason: "end_turn", + }) + body, err := json.Marshal(response) + require.NoError(t, err) + + converted, err := (&ClaudeToOpenAIConverter{}).ConvertOpenAIResponseToClaude(ctx, body) + require.NoError(t, err) + + var claudeResponse claudeTextGenResponse + require.NoError(t, json.Unmarshal(converted, &claudeResponse)) + require.Len(t, claudeResponse.Content, 2) + assert.Equal(t, "redacted_thinking", claudeResponse.Content[0].Type) + assert.Equal(t, "opaque-base64", claudeResponse.Content[0].Data) +} + +func TestBedrockResponsePreservesClaudeNativeToolUseWithThinking(t *testing.T) { + provider := &bedrockProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + + response := provider.buildChatCompletionResponse(ctx, &bedrockConverseResponse{ + Output: converseOutputMemberMessage{Message: message{ + Role: roleAssistant, + Content: []contentBlock{ + {ReasoningContent: &reasoningContent{ReasoningText: &reasoningText{Text: "reasoning", Signature: "sig"}}}, + {ToolUse: &bedrockToolUse{ToolUseId: "toolu_1", Name: "lookup", Input: map[string]interface{}{"query": "q"}}}, + }, + }}, + StopReason: "tool_use", + }) + body, err := json.Marshal(response) + require.NoError(t, err) + + converted, err := (&ClaudeToOpenAIConverter{}).ConvertOpenAIResponseToClaude(ctx, body) + require.NoError(t, err) + + var claudeResponse claudeTextGenResponse + require.NoError(t, json.Unmarshal(converted, &claudeResponse)) + require.Len(t, claudeResponse.Content, 2) + assert.Equal(t, "thinking", claudeResponse.Content[0].Type) + assert.Equal(t, "tool_use", claudeResponse.Content[1].Type) + assert.Equal(t, "toolu_1", claudeResponse.Content[1].Id) + assert.Equal(t, "lookup", claudeResponse.Content[1].Name) +} + +func TestBedrockStreamRedactedThinkingStopsOnce(t *testing.T) { + provider := &bedrockProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + converter := &ClaudeToOpenAIConverter{} + + chunk, err := provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{ + ContentBlockIndex: 1, + Delta: &converseStreamEventContentBlockDelta{ + ReasoningContent: &reasoningContentDelta{RedactedContent: "opaque-base64"}, + }, + }) + require.NoError(t, err) + converted, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, chunk) + require.NoError(t, err) + events := parseClaudeSSEEvents(t, converted) + require.Len(t, events, 1) + assert.Equal(t, "content_block_start", events[0].Name) + assert.Equal(t, "redacted_thinking", events[0].Payload.ContentBlock.Type) + + chunk, err = provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{ + ContentBlockIndex: 1, + ContentBlockStop: &contentBlockStop{ContentBlockIndex: 1}, + }) + require.NoError(t, err) + converted, err = converter.ConvertOpenAIStreamResponseToClaude(ctx, chunk) + require.NoError(t, err) + events = parseClaudeSSEEvents(t, converted) + require.Len(t, events, 1) + assert.Equal(t, "content_block_stop", events[0].Name) +} + +func TestBedrockRequestPreservesClaudeNativeThinkingAndToolResult(t *testing.T) { + provider := &bedrockProvider{} + openaiBody, err := (&ClaudeToOpenAIConverter{}).ConvertClaudeRequestToOpenAI([]byte(`{ + "model":"claude", + "system":"system prompt", + "messages":[{ + "role":"assistant", + "content":[ + {"type":"thinking","thinking":"reasoning","signature":"sig"}, + {"type":"tool_use","id":"toolu_1","name":"lookup","input":{"query":"q"}} + ] + },{ + "role":"user", + "content":[{ + "type":"tool_result", + "tool_use_id":"toolu_1", + "is_error":true, + "content":[{"type":"text","text":"failed"}] + }] + }] + }`)) + require.NoError(t, err) + var openaiRequest chatCompletionRequest + require.NoError(t, json.Unmarshal(openaiBody, &openaiRequest)) + + body, err := provider.buildBedrockTextGenerationRequest(&openaiRequest, nil) + require.NoError(t, err) + + var request bedrockTextGenRequest + require.NoError(t, json.Unmarshal(body, &request)) + require.Len(t, request.System, 1) + assert.Equal(t, "system prompt", request.System[0].Text) + require.Len(t, request.Messages, 2) + require.Len(t, request.Messages[0].Content, 2) + require.NotNil(t, request.Messages[0].Content[0].ReasoningContent) + require.NotNil(t, request.Messages[0].Content[0].ReasoningContent.ReasoningText) + assert.Equal(t, "reasoning", request.Messages[0].Content[0].ReasoningContent.ReasoningText.Text) + assert.Equal(t, "sig", request.Messages[0].Content[0].ReasoningContent.ReasoningText.Signature) + require.NotNil(t, request.Messages[0].Content[1].ToolUse) + assert.Equal(t, "toolu_1", request.Messages[0].Content[1].ToolUse.ToolUseId) + require.NotNil(t, request.Messages[1].Content[0].ToolResult) + assert.Equal(t, "error", request.Messages[1].Content[0].ToolResult.Status) + require.NotNil(t, request.Messages[1].Content[0].ToolResult.Content[0].Text) + assert.Equal(t, "failed", *request.Messages[1].Content[0].ToolResult.Content[0].Text) +} + +func TestBedrockRequestToolResultWithTrailingTextDoesNotDuplicateToolResult(t *testing.T) { + provider := &bedrockProvider{} + openaiBody, err := (&ClaudeToOpenAIConverter{}).ConvertClaudeRequestToOpenAI([]byte(`{ + "model":"claude", + "messages":[{ + "role":"user", + "content":[ + {"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}, + {"type":"text","text":"continue"} + ] + }] + }`)) + require.NoError(t, err) + var openaiRequest chatCompletionRequest + require.NoError(t, json.Unmarshal(openaiBody, &openaiRequest)) + require.Len(t, openaiRequest.Messages, 2) + assert.Empty(t, openaiRequest.Messages[1].ClaudeContentBlocks) + + body, err := provider.buildBedrockTextGenerationRequest(&openaiRequest, nil) + require.NoError(t, err) + + var request bedrockTextGenRequest + require.NoError(t, json.Unmarshal(body, &request)) + require.Len(t, request.Messages, 2) + require.Len(t, request.Messages[0].Content, 1) + require.NotNil(t, request.Messages[0].Content[0].ToolResult) + require.Len(t, request.Messages[1].Content, 1) + assert.Nil(t, request.Messages[1].Content[0].ToolResult) + assert.Equal(t, "continue", request.Messages[1].Content[0].Text) +} + +func TestBedrockRequestRedactedThinkingUsesSingleUnionArm(t *testing.T) { + contents := claudeContentBlocksToBedrockContents([]claudeChatMessageContent{ + {Type: "redacted_thinking", Data: "opaque-base64"}, + }) + + body, err := json.Marshal(contents[0]) + require.NoError(t, err) + assert.JSONEq(t, `{"reasoningContent":{"redactedContent":"opaque-base64"}}`, string(body)) + assert.NotContains(t, string(body), "reasoningText") +} + +func TestBedrockRequestToolResultDefaultsEmptyContent(t *testing.T) { + result := claudeToolResultBlockToBedrock(claudeChatMessageContent{ + Type: "tool_result", + ToolUseId: "toolu_1", + }) + + require.Len(t, result.Content, 1) + require.NotNil(t, result.Content[0].Text) + assert.Equal(t, "", *result.Content[0].Text) + body, err := json.Marshal(result) + require.NoError(t, err) + assert.Contains(t, string(body), `"text":""`) + assert.NotContains(t, string(body), `[{}]`) +} + +func TestBedrockRequestPreservesEmptyThinkingTextWithSignature(t *testing.T) { + contents := claudeContentBlocksToBedrockContents([]claudeChatMessageContent{ + {Type: "thinking", Thinking: "", Signature: "sig"}, + }) + + body, err := json.Marshal(contents[0]) + require.NoError(t, err) + assert.JSONEq(t, `{"reasoningContent":{"reasoningText":{"text":"","signature":"sig"}}}`, string(body)) +} + +func TestBedrockRequestPreservesClaudeNativeThinkingBudget(t *testing.T) { + provider := &bedrockProvider{} + openaiBody, err := (&ClaudeToOpenAIConverter{}).ConvertClaudeRequestToOpenAI([]byte(`{ + "model":"claude", + "max_tokens":32000, + "thinking":{"type":"enabled","budget_tokens":8192}, + "messages":[{"role":"user","content":"hello"}] + }`)) + require.NoError(t, err) + var openaiRequest chatCompletionRequest + require.NoError(t, json.Unmarshal(openaiBody, &openaiRequest)) + + body, err := provider.buildBedrockTextGenerationRequest(&openaiRequest, nil) + require.NoError(t, err) + + var request bedrockTextGenRequest + require.NoError(t, json.Unmarshal(body, &request)) + assert.Equal(t, float64(8192), request.AdditionalModelRequestFields["thinking"].(map[string]interface{})["budget_tokens"]) +} + +func TestBedrockRequestMapsAdaptiveOutputEffortIntoThinking(t *testing.T) { + provider := &bedrockProvider{} + openaiBody, err := (&ClaudeToOpenAIConverter{}).ConvertClaudeRequestToOpenAI([]byte(`{ + "model":"claude", + "thinking":{"type":"adaptive"}, + "output_config":{"effort":"high"}, + "messages":[{"role":"user","content":"hello"}] + }`)) + require.NoError(t, err) + var openaiRequest chatCompletionRequest + require.NoError(t, json.Unmarshal(openaiBody, &openaiRequest)) + + body, err := provider.buildBedrockTextGenerationRequest(&openaiRequest, nil) + require.NoError(t, err) + + var request bedrockTextGenRequest + require.NoError(t, json.Unmarshal(body, &request)) + thinking := request.AdditionalModelRequestFields["thinking"].(map[string]interface{}) + assert.Equal(t, "adaptive", thinking["type"]) + assert.Equal(t, "high", thinking["effort"]) + assert.NotContains(t, request.AdditionalModelRequestFields, "output_config") + assert.NotContains(t, request.AdditionalModelRequestFields, "anthropic_beta") +} + +func TestBedrockRequestDropsOutputEffortWithoutAdaptiveThinking(t *testing.T) { + provider := &bedrockProvider{} + openaiBody, err := (&ClaudeToOpenAIConverter{}).ConvertClaudeRequestToOpenAI([]byte(`{ + "model":"claude", + "output_config":{"effort":"high"}, + "messages":[{"role":"user","content":"hello"}] + }`)) + require.NoError(t, err) + var openaiRequest chatCompletionRequest + require.NoError(t, json.Unmarshal(openaiBody, &openaiRequest)) + + body, err := provider.buildBedrockTextGenerationRequest(&openaiRequest, nil) + require.NoError(t, err) + + var request bedrockTextGenRequest + require.NoError(t, json.Unmarshal(body, &request)) + assert.NotContains(t, request.AdditionalModelRequestFields, "output_config") + assert.NotContains(t, request.AdditionalModelRequestFields, "thinking") +} + +func TestBedrockRequestDropsUnsupportedAdaptiveOutputEffort(t *testing.T) { + provider := &bedrockProvider{} + openaiBody, err := (&ClaudeToOpenAIConverter{}).ConvertClaudeRequestToOpenAI([]byte(`{ + "model":"claude", + "thinking":{"type":"adaptive"}, + "output_config":{"effort":"xhigh"}, + "messages":[{"role":"user","content":"hello"}] + }`)) + require.NoError(t, err) + var openaiRequest chatCompletionRequest + require.NoError(t, json.Unmarshal(openaiBody, &openaiRequest)) + + body, err := provider.buildBedrockTextGenerationRequest(&openaiRequest, nil) + require.NoError(t, err) + + var request bedrockTextGenRequest + require.NoError(t, json.Unmarshal(body, &request)) + thinking := request.AdditionalModelRequestFields["thinking"].(map[string]interface{}) + assert.Equal(t, "adaptive", thinking["type"]) + assert.NotContains(t, thinking, "effort") + assert.NotContains(t, request.AdditionalModelRequestFields, "output_config") +} + +func TestBedrockRequestMapsClaudeOutputFormatToTextFormat(t *testing.T) { + provider := &bedrockProvider{} + openaiBody, err := (&ClaudeToOpenAIConverter{}).ConvertClaudeRequestToOpenAI([]byte(`{ + "model":"claude", + "output_config":{ + "format":{ + "type":"json_schema", + "schema":{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"]} + } + }, + "messages":[{"role":"user","content":"hello"}] + }`)) + require.NoError(t, err) + var openaiRequest chatCompletionRequest + require.NoError(t, json.Unmarshal(openaiBody, &openaiRequest)) + + body, err := provider.buildBedrockTextGenerationRequest(&openaiRequest, nil) + require.NoError(t, err) + + var request map[string]interface{} + require.NoError(t, json.Unmarshal(body, &request)) + outputConfig := request["outputConfig"].(map[string]interface{}) + textFormat := outputConfig["textFormat"].(map[string]interface{}) + assert.Equal(t, "json_schema", textFormat["type"]) +} + +func TestBedrockRequestDowngradesForcedToolChoiceWhenThinkingEnabled(t *testing.T) { + provider := &bedrockProvider{} + openaiBody, err := (&ClaudeToOpenAIConverter{}).ConvertClaudeRequestToOpenAI([]byte(`{ + "model":"claude", + "thinking":{"type":"enabled","budget_tokens":8192}, + "tools":[{"name":"lookup","input_schema":{"type":"object"}}], + "tool_choice":{"type":"any"}, + "messages":[{"role":"user","content":"hello"}] + }`)) + require.NoError(t, err) + var openaiRequest chatCompletionRequest + require.NoError(t, json.Unmarshal(openaiBody, &openaiRequest)) + + body, err := provider.buildBedrockTextGenerationRequest(&openaiRequest, nil) + require.NoError(t, err) + + var request bedrockTextGenRequest + require.NoError(t, json.Unmarshal(body, &request)) + require.NotNil(t, request.ToolConfig) + assert.NotNil(t, request.ToolConfig.ToolChoice.Auto) + assert.Nil(t, request.ToolConfig.ToolChoice.Any) + assert.Nil(t, request.ToolConfig.ToolChoice.Tool) +} + +func TestBedrockStreamSkipsOrphanClaudeContentBlockStop(t *testing.T) { + provider := &bedrockProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + converter := &ClaudeToOpenAIConverter{} + + chunk, err := provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{ + ContentBlockIndex: 3, + ContentBlockStop: &contentBlockStop{ContentBlockIndex: 3}, + }) + require.NoError(t, err) + converted, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, chunk) + require.NoError(t, err) + assert.Empty(t, parseClaudeSSEEvents(t, converted)) +} + +func TestBedrockStreamBatchedEventsKeepClaudeMessageStartFirst(t *testing.T) { + provider := &bedrockProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + converter := &ClaudeToOpenAIConverter{} + role := roleAssistant + + roleChunk, err := provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{Role: &role}) + require.NoError(t, err) + reasoningChunk, err := provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{ + ContentBlockIndex: 0, + Delta: &converseStreamEventContentBlockDelta{ + ReasoningContent: &reasoningContentDelta{Text: "reasoning"}, + }, + }) + require.NoError(t, err) + + converted, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, append(roleChunk, reasoningChunk...)) + require.NoError(t, err) + events := parseClaudeSSEEvents(t, converted) + require.Len(t, events, 3) + assert.Equal(t, "message_start", events[0].Name) + assert.Equal(t, "content_block_start", events[1].Name) + assert.Equal(t, "content_block_delta", events[2].Name) +} + +func TestBedrockResponseUsesReasoningContentInsteadOfThinkTags(t *testing.T) { + provider := &bedrockProvider{} + + response := provider.buildChatCompletionResponse(newMockMultipartHttpContext(), &bedrockConverseResponse{ + Output: converseOutputMemberMessage{Message: message{ + Role: roleAssistant, + Content: []contentBlock{ + {ReasoningContent: &reasoningContent{ReasoningText: &reasoningText{Text: "reasoning"}}}, + {Text: "answer"}, + }, + }}, + StopReason: "end_turn", + }) + + require.Len(t, response.Choices, 1) + require.NotNil(t, response.Choices[0].Message) + assert.Equal(t, "reasoning", response.Choices[0].Message.ReasoningContent) + assert.Equal(t, "answer", response.Choices[0].Message.Content) + assert.NotContains(t, response.Choices[0].Message.StringContent(), "") +} + +func TestBedrockStreamUsesReasoningContentInsteadOfThinkTags(t *testing.T) { + provider := &bedrockProvider{} + ctx := newMockMultipartHttpContext() + + chunk, err := provider.convertEventFromBedrockToOpenAI(ctx, ConverseStreamEvent{ + ContentBlockIndex: 0, + Delta: &converseStreamEventContentBlockDelta{ + ReasoningContent: &reasoningContentDelta{Text: "reasoning"}, + }, + }) + require.NoError(t, err) + + body := strings.TrimPrefix(strings.TrimSpace(string(chunk)), ssePrefix) + var response chatCompletionResponse + require.NoError(t, json.Unmarshal([]byte(body), &response)) + require.Len(t, response.Choices, 1) + require.NotNil(t, response.Choices[0].Delta) + assert.Equal(t, "reasoning", response.Choices[0].Delta.ReasoningContent) + assert.Nil(t, response.Choices[0].Delta.Content) +} diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go index 05fd05349..6f00a1a4b 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go @@ -66,6 +66,7 @@ type claudeChatMessageContentSource struct { type claudeChatMessageContent struct { Type string `json:"type"` Text string `json:"text,omitempty"` + Data string `json:"data,omitempty"` // For redacted_thinking Source *claudeChatMessageContentSource `json:"source,omitempty"` CacheControl map[string]interface{} `json:"cache_control,omitempty"` // Tool use fields @@ -75,6 +76,9 @@ type claudeChatMessageContent struct { // Tool result fields ToolUseId string `json:"tool_use_id,omitempty"` // For tool_result Content *claudeChatMessageContentWr `json:"content,omitempty"` // For tool_result - can be string or array + IsError bool `json:"is_error,omitempty"` // For tool_result + Signature string `json:"signature,omitempty"` // For thinking + Thinking string `json:"thinking,omitempty"` // For thinking } // UnmarshalJSON implements custom JSON unmarshaling for claudeChatMessageContentWr @@ -205,6 +209,16 @@ func (csp claudeSystemPrompt) String() string { type claudeThinkingConfig struct { Type string `json:"type"` BudgetTokens int `json:"budget_tokens,omitempty"` + Display string `json:"display,omitempty"` +} + +func hasActiveClaudeThinking(thinking *claudeThinkingConfig) bool { + return thinking != nil && thinking.Type != "" && thinking.Type != "disabled" +} + +type claudeOutputConfig struct { + Effort string `json:"effort,omitempty"` + Format json.RawMessage `json:"format,omitempty"` } type claudeTextGenRequest struct { @@ -221,6 +235,8 @@ type claudeTextGenRequest struct { Tools []claudeTool `json:"tools,omitempty"` ServiceTier string `json:"service_tier,omitempty"` Thinking *claudeThinkingConfig `json:"thinking,omitempty"` + OutputConfig *claudeOutputConfig `json:"output_config,omitempty"` + AnthropicBeta []string `json:"anthropic_beta,omitempty"` AnthropicVersion string `json:"anthropic_version,omitempty"` } @@ -239,6 +255,7 @@ type claudeTextGenResponse struct { type claudeTextGenContent struct { Type string `json:"type,omitempty"` Text *string `json:"text,omitempty"` // Use pointer: empty string outputs "text":"", nil omits field + Data string `json:"data,omitempty"` // For redacted_thinking Id string `json:"id,omitempty"` // For tool_use Name string `json:"name,omitempty"` // For tool_use Input *map[string]interface{} `json:"input,omitempty"` // Use pointer: empty map outputs "input":{}, nil omits field @@ -272,6 +289,7 @@ type claudeTextGenDelta struct { Type string `json:"type,omitempty"` Text string `json:"text,omitempty"` Thinking string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` PartialJson string `json:"partial_json,omitempty"` StopReason *string `json:"stop_reason,omitempty"` StopSequence json.RawMessage `json:"stop_sequence,omitempty"` // Use RawMessage to output explicit null @@ -442,8 +460,15 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe claudeRequest.MaxTokens = claudeDefaultMaxTokens } + if origRequest.ClaudeOutputConfig != nil { + claudeRequest.OutputConfig = origRequest.ClaudeOutputConfig + } + if origRequest.ClaudeThinking != nil { + claudeRequest.Thinking = origRequest.ClaudeThinking + } + // Convert OpenAI reasoning parameters to Claude thinking configuration - if origRequest.ReasoningEffort != "" || origRequest.ReasoningMaxTokens > 0 { + if claudeRequest.Thinking == nil && (origRequest.ReasoningEffort != "" || origRequest.ReasoningMaxTokens > 0) { var budgetTokens int if origRequest.ReasoningMaxTokens > 0 { budgetTokens = origRequest.ReasoningMaxTokens @@ -498,6 +523,18 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe continue } + if len(message.ClaudeContentBlocks) > 0 { + role := message.Role + if role == roleTool { + role = roleUser + } + claudeRequest.Messages = append(claudeRequest.Messages, claudeChatMessage{ + Role: role, + Content: NewArrayContent(message.ClaudeContentBlocks), + }) + continue + } + // Handle OpenAI "tool" role messages - convert to Claude "user" role with tool_result content if message.Role == roleTool { toolResultContent := claudeChatMessageContent{ @@ -677,9 +714,10 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe if origRequest.ParallelToolCalls != nil { parallelToolCalls = *origRequest.ParallelToolCalls } + hasThinking := hasActiveClaudeThinking(claudeRequest.Thinking) choiceType := origRequest.getToolChoiceType() - if tc := origRequest.getToolChoiceObject(); tc != nil && tc.Type == "function" && tc.Function.Name != "" { + if tc := origRequest.getToolChoiceObject(); !hasThinking && tc != nil && tc.Type == "function" && tc.Function.Name != "" { claudeRequest.ToolChoice = &claudeToolChoice{ Name: tc.Function.Name, Type: "tool", @@ -689,6 +727,11 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe switch choiceType { case "required": choiceType = "any" + case "function": + choiceType = "auto" + } + if hasThinking && (choiceType == "any" || choiceType == "tool") { + choiceType = "auto" } claudeRequest.ToolChoice = &claudeToolChoice{ Type: choiceType, @@ -706,6 +749,9 @@ func (c *claudeProvider) responseClaude2OpenAI(ctx wrapper.HttpContext, origResp // Extract text content, thinking content, and tool calls from Claude response var textContent string var reasoningContent string + var reasoningSignature string + var reasoningRedactedContent string + var nativeContent []claudeTextGenContent var toolCalls []toolCall for _, content := range origResponse.Content { switch content.Type { @@ -713,11 +759,20 @@ func (c *claudeProvider) responseClaude2OpenAI(ctx wrapper.HttpContext, origResp if content.Text != nil { textContent = *content.Text } + nativeContent = append(nativeContent, content) case "thinking": if content.Thinking != nil { reasoningContent = *content.Thinking } + if content.Signature != nil { + reasoningSignature = *content.Signature + } + nativeContent = append(nativeContent, content) + case "redacted_thinking": + reasoningRedactedContent = content.Data + nativeContent = append(nativeContent, content) case "tool_use": + nativeContent = append(nativeContent, content) var args []byte if content.Input != nil { args, _ = json.Marshal(*content.Input) @@ -734,10 +789,23 @@ func (c *claudeProvider) responseClaude2OpenAI(ctx wrapper.HttpContext, origResp }) } } + if ctx != nil { + needClaudeResponseConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if needClaudeResponseConversion && len(nativeContent) > 0 { + ctx.SetContext(ctxKeyClaudeNativeResponseContent, nativeContent) + } + } choice := chatCompletionChoice{ - Index: 0, - Message: &chatMessage{Role: roleAssistant, Content: textContent, ReasoningContent: reasoningContent, ToolCalls: toolCalls}, + Index: 0, + Message: &chatMessage{ + Role: roleAssistant, + Content: textContent, + ReasoningContent: reasoningContent, + ReasoningSignature: reasoningSignature, + ReasoningRedactedContent: reasoningRedactedContent, + ToolCalls: toolCalls, + }, FinishReason: util.Ptr(stopReasonClaude2OpenAI(origResponse.StopReason)), } @@ -802,6 +870,21 @@ func (c *claudeProvider) streamResponseClaude2OpenAI(ctx wrapper.HttpContext, or return c.createChatCompletionResponse(ctx, origResponse, choice) case "content_block_start": + needClaudeResponseConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if needClaudeResponseConversion && origResponse.ContentBlock != nil && origResponse.ContentBlock.Type == "redacted_thinking" { + var index int + if origResponse.Index != nil { + index = *origResponse.Index + } + choice := chatCompletionChoice{ + Index: index, + Delta: &chatMessage{ + ReasoningRedactedContent: origResponse.ContentBlock.Data, + ClaudeContentBlockIndex: &index, + }, + } + return c.createChatCompletionResponse(ctx, origResponse, choice) + } // Handle tool_use content block start if origResponse.ContentBlock != nil && origResponse.ContentBlock.Type == "tool_use" { var index int @@ -852,9 +935,28 @@ func (c *claudeProvider) streamResponseClaude2OpenAI(ctx wrapper.HttpContext, or } // Handle thinking_delta if origResponse.Delta != nil && origResponse.Delta.Type == "thinking_delta" { + delta := &chatMessage{Reasoning: origResponse.Delta.Thinking} + needClaudeResponseConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if needClaudeResponseConversion { + delta.ClaudeContentBlockIndex = &index + } choice := chatCompletionChoice{ Index: index, - Delta: &chatMessage{Reasoning: origResponse.Delta.Thinking}, + Delta: delta, + } + return c.createChatCompletionResponse(ctx, origResponse, choice) + } + if origResponse.Delta != nil && origResponse.Delta.Type == "signature_delta" { + needClaudeResponseConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if !needClaudeResponseConversion { + return nil + } + choice := chatCompletionChoice{ + Index: index, + Delta: &chatMessage{ + ReasoningSignature: origResponse.Delta.Signature, + ClaudeContentBlockIndex: &index, + }, } return c.createChatCompletionResponse(ctx, origResponse, choice) } @@ -895,7 +997,23 @@ func (c *claudeProvider) streamResponseClaude2OpenAI(ctx wrapper.HttpContext, or TotalTokens: c.usage.TotalTokens, }, } - case "content_block_stop", "ping": + case "content_block_stop": + needClaudeResponseConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if !needClaudeResponseConversion { + return nil + } + var index int + if origResponse.Index != nil { + index = *origResponse.Index + } + choice := chatCompletionChoice{ + Index: index, + Delta: &chatMessage{ + ClaudeContentBlockStop: &index, + }, + } + return c.createChatCompletionResponse(ctx, origResponse, choice) + case "ping": log.Debugf("skip processing response type: %s", origResponse.Type) return nil default: 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 4ad27162b..2133bcdf9 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go @@ -184,6 +184,48 @@ func TestClaudeProvider_BuildClaudeTextGenRequest_StandardMode(t *testing.T) { assert.Equal(t, "You are a helpful assistant.", claudeReq.System.StringValue) }) + t.Run("preserves_bridge_thinking_blocks_and_output_config", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + ClaudeThinking: &claudeThinkingConfig{Type: "adaptive", Display: "omitted"}, + ClaudeOutputConfig: &claudeOutputConfig{ + Effort: "high", + Format: json.RawMessage(`{ + "type":"json_schema", + "schema":{"type":"object","properties":{"answer":{"type":"string"}}} + }`), + }, + Messages: []chatMessage{{ + Role: roleAssistant, + ClaudeContentBlocks: []claudeChatMessageContent{ + {Type: "thinking", Thinking: "", Signature: "sig"}, + {Type: "redacted_thinking", Data: "opaque-base64"}, + {Type: "text", Text: "answer"}, + }, + }}, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + require.NotNil(t, claudeReq.Thinking) + assert.Equal(t, "adaptive", claudeReq.Thinking.Type) + assert.Equal(t, "omitted", claudeReq.Thinking.Display) + require.NotNil(t, claudeReq.OutputConfig) + assert.Equal(t, "high", claudeReq.OutputConfig.Effort) + require.NotEmpty(t, claudeReq.OutputConfig.Format) + assert.Contains(t, string(claudeReq.OutputConfig.Format), "json_schema") + require.Len(t, claudeReq.Messages, 1) + blocks := claudeReq.Messages[0].Content.GetArrayValue() + require.Len(t, blocks, 3) + assert.Equal(t, "thinking", blocks[0].Type) + assert.Equal(t, "sig", blocks[0].Signature) + assert.Equal(t, "redacted_thinking", blocks[1].Type) + assert.Equal(t, "opaque-base64", blocks[1].Data) + assert.Equal(t, "text", blocks[2].Type) + assert.Equal(t, "answer", blocks[2].Text) + }) + t.Run("maps_openai_function_tool_choice_to_claude_tool_choice", func(t *testing.T) { request := &chatCompletionRequest{ Model: "claude-sonnet-4-5-20250929", @@ -241,6 +283,30 @@ func TestClaudeProvider_BuildClaudeTextGenRequest_StandardMode(t *testing.T) { assert.True(t, claudeReq.ToolChoice.DisableParallelToolUse) }) + t.Run("downgrades_forced_tool_choice_to_auto_when_thinking_enabled", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + ClaudeThinking: &claudeThinkingConfig{Type: "enabled", BudgetTokens: 8192}, + Messages: []chatMessage{ + {Role: roleUser, Content: "Search."}, + }, + Tools: []tool{{ + Type: "function", + Function: function{ + Name: "web_search", + Parameters: map[string]interface{}{"type": "object"}, + }, + }}, + ToolChoice: "required", + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + require.NotNil(t, claudeReq.ToolChoice) + assert.Equal(t, "auto", claudeReq.ToolChoice.Type) + }) + t.Run("maps_openai_string_none_tool_choice_to_claude_none", func(t *testing.T) { request := &chatCompletionRequest{ Model: "claude-sonnet-4-5-20250929", @@ -264,6 +330,117 @@ func TestClaudeProvider_BuildClaudeTextGenRequest_StandardMode(t *testing.T) { assert.Equal(t, "none", claudeReq.ToolChoice.Type) assert.Empty(t, claudeReq.ToolChoice.Name) }) + + t.Run("preserves_bridge_tool_result_blocks_before_role_tool_fallback", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + Messages: []chatMessage{{ + Role: roleTool, + ClaudeContentBlocks: []claudeChatMessageContent{{ + Type: "tool_result", + ToolUseId: "toolu_1", + IsError: true, + Content: &claudeChatMessageContentWr{ + ArrayValue: []claudeChatMessageContent{{ + Type: "image", + Source: &claudeChatMessageContentSource{ + Type: "base64", + MediaType: "image/png", + Data: "AAAA", + }, + }}, + }, + }}, + }}, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + require.Len(t, claudeReq.Messages, 1) + assert.Equal(t, roleUser, claudeReq.Messages[0].Role) + blocks := claudeReq.Messages[0].Content.GetArrayValue() + require.Len(t, blocks, 1) + assert.Equal(t, "tool_result", blocks[0].Type) + assert.True(t, blocks[0].IsError) + require.NotNil(t, blocks[0].Content) + require.Len(t, blocks[0].Content.ArrayValue, 1) + assert.Equal(t, "image", blocks[0].Content.ArrayValue[0].Type) + }) +} + +func TestClaudeProvider_ResponsePreservesNativeThinkingBlocksForClaudeConversion(t *testing.T) { + provider := &claudeProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + thinking := "reasoning" + signature := "sig" + text := "answer" + + response := provider.responseClaude2OpenAI(ctx, &claudeTextGenResponse{ + Id: "msg_1", + Model: "claude-sonnet-4-5-20250929", + Type: "message", + Role: roleAssistant, + Content: []claudeTextGenContent{ + {Type: "thinking", Thinking: &thinking, Signature: &signature}, + {Type: "redacted_thinking", Data: "opaque-base64"}, + {Type: "text", Text: &text}, + }, + }) + body, err := json.Marshal(response) + require.NoError(t, err) + + converted, err := (&ClaudeToOpenAIConverter{}).ConvertOpenAIResponseToClaude(ctx, body) + require.NoError(t, err) + + var claudeResponse claudeTextGenResponse + require.NoError(t, json.Unmarshal(converted, &claudeResponse)) + require.Len(t, claudeResponse.Content, 3) + assert.Equal(t, "thinking", claudeResponse.Content[0].Type) + require.NotNil(t, claudeResponse.Content[0].Signature) + assert.Equal(t, "sig", *claudeResponse.Content[0].Signature) + assert.Equal(t, "redacted_thinking", claudeResponse.Content[1].Type) + assert.Equal(t, "opaque-base64", claudeResponse.Content[1].Data) + assert.Equal(t, "text", claudeResponse.Content[2].Type) +} + +func TestClaudeProvider_StreamPreservesNativeSignatureAndStopsForClaudeConversion(t *testing.T) { + provider := &claudeProvider{} + ctx := newMockMultipartHttpContext() + ctx.SetContext("needClaudeResponseConversion", true) + converter := &ClaudeToOpenAIConverter{} + index := 1 + + signatureResponse := provider.streamResponseClaude2OpenAI(ctx, &claudeTextGenStreamResponse{ + Type: "content_block_delta", + Index: &index, + Delta: &claudeTextGenDelta{ + Type: "signature_delta", + Signature: "sig", + }, + }) + signatureBody, err := json.Marshal(signatureResponse) + require.NoError(t, err) + converted, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, []byte("data: "+string(signatureBody)+"\n\n")) + require.NoError(t, err) + events := parseClaudeSSEEvents(t, converted) + require.Len(t, events, 2) + assert.Equal(t, "content_block_start", events[0].Name) + assert.Equal(t, "content_block_delta", events[1].Name) + assert.Equal(t, "signature_delta", events[1].Payload.Delta.Type) + + stopResponse := provider.streamResponseClaude2OpenAI(ctx, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &index, + }) + stopBody, err := json.Marshal(stopResponse) + require.NoError(t, err) + converted, err = converter.ConvertOpenAIStreamResponseToClaude(ctx, []byte("data: "+string(stopBody)+"\n\n")) + require.NoError(t, err) + events = parseClaudeSSEEvents(t, converted) + require.Len(t, events, 1) + assert.Equal(t, "content_block_stop", events[0].Name) } func TestClaudeProvider_BuildClaudeTextGenRequest_ClaudeCodeMode(t *testing.T) { 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 3fc1e2e66..d588c4807 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 @@ -28,16 +28,91 @@ type ClaudeToOpenAIConverter struct { toolBlockIndex int toolBlockStarted bool toolBlockStopped bool + redactedBlockIndexes map[int]bool // Tool call state tracking toolCallStates map[int]*toolCallInfo // Map of OpenAI index to tool call state activeToolIndex *int // Currently active tool call index (for Claude serialization) } +const ( + ctxKeyClaudeNativeResponseContent = "claudeNativeResponseContent" +) + +func getClaudeNativeResponseContent(ctx wrapper.HttpContext) ([]claudeTextGenContent, bool) { + if ctx == nil { + return nil, false + } + content, ok := ctx.GetContext(ctxKeyClaudeNativeResponseContent).([]claudeTextGenContent) + return content, ok && len(content) > 0 +} + +func writeClaudeStreamEvents(result *strings.Builder, events []*claudeTextGenStreamResponse) { + for _, event := range events { + data, err := json.Marshal(event) + if err != nil { + log.Errorf("unable to marshal claude stream response: %v", err) + continue + } + result.WriteString(fmt.Sprintf("event: %s\ndata: %s\n\n", event.Type, data)) + } +} + +func claudeContentFromOpenAIMessage(ctx wrapper.HttpContext, message *chatMessage) []claudeTextGenContent { + if nativeContent, ok := getClaudeNativeResponseContent(ctx); ok { + return nativeContent + } + + var contents []claudeTextGenContent + var reasoningText string + if message.Reasoning != "" { + reasoningText = message.Reasoning + } else if message.ReasoningContent != "" { + reasoningText = message.ReasoningContent + } + if reasoningText != "" { + emptySignature := "" + contents = append(contents, claudeTextGenContent{ + Type: "thinking", + Signature: &emptySignature, + Thinking: &reasoningText, + }) + log.Debugf("[OpenAI->Claude] Added thinking content: %s", reasoningText) + } + + if message.StringContent() != "" { + textContent := message.StringContent() + contents = append(contents, claudeTextGenContent{Type: "text", Text: &textContent}) + } + + for _, toolCall := range message.ToolCalls { + if toolCall.Function.IsEmpty() { + continue + } + 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, arguments: %s", err, toolCall.Function.Arguments) + input = map[string]interface{}{} + } + } else { + input = map[string]interface{}{} + } + contents = append(contents, claudeTextGenContent{ + Type: "tool_use", + Id: toolCall.Id, + Name: toolCall.Function.Name, + Input: &input, + }) + } + return contents +} + // toolCallInfo tracks tool call state type toolCallInfo struct { id string // Tool call ID name string // Tool call name claudeContentIndex int // Claude content block index + hasClaudeIndex bool contentBlockStarted bool // Whether content_block_start has been sent contentBlockStopped bool // Whether content_block_stop has been sent cachedArguments string // Cache arguments for this tool call @@ -45,11 +120,22 @@ type toolCallInfo struct { // contentConversionResult represents the result of converting Claude content to OpenAI format type contentConversionResult struct { - textParts []string - toolCalls []toolCall - toolResults []claudeChatMessageContent - openaiContents []chatMessageContent - hasNonTextContent bool + textParts []string + reasoningContent string + reasoningSignature string + reasoningRedactedContent string + claudeContentBlocks []claudeChatMessageContent + toolCalls []toolCall + toolResults []claudeChatMessageContent + openaiContents []chatMessageContent + hasNonTextContent bool +} + +func applyReasoningFields(message *chatMessage, conversionResult *contentConversionResult) { + message.ReasoningContent = conversionResult.reasoningContent + message.ReasoningSignature = conversionResult.reasoningSignature + message.ReasoningRedactedContent = conversionResult.reasoningRedactedContent + message.ClaudeContentBlocks = conversionResult.claudeContentBlocks } // ConvertClaudeRequestToOpenAI converts a Claude chat completion request to OpenAI format @@ -98,6 +184,7 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b Role: claudeMsg.Role, ToolCalls: conversionResult.toolCalls, } + applyReasoningFields(&openaiMsg, conversionResult) // Add text content if present, otherwise set to null if len(conversionResult.textParts) > 0 { @@ -113,9 +200,10 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b if len(conversionResult.toolResults) > 0 { for _, toolResult := range conversionResult.toolResults { toolMsg := chatMessage{ - Role: "tool", - Content: toolResult.Content.GetStringValue(), - ToolCallId: toolResult.ToolUseId, + Role: "tool", + Content: toolResult.Content.GetStringValue(), + ToolCallId: toolResult.ToolUseId, + ClaudeContentBlocks: []claudeChatMessageContent{toolResult}, } openaiRequest.Messages = append(openaiRequest.Messages, toolMsg) } @@ -136,6 +224,7 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b Role: claudeMsg.Role, Content: conversionResult.openaiContents, } + applyReasoningFields(&openaiMsg, conversionResult) openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg) } } @@ -202,6 +291,7 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b if claudeRequest.Thinking != nil { log.Debugf("[Claude->OpenAI] Found thinking config: type=%s, budget_tokens=%d", claudeRequest.Thinking.Type, claudeRequest.Thinking.BudgetTokens) + openaiRequest.ClaudeThinking = claudeRequest.Thinking if claudeRequest.Thinking.Type == "enabled" { // Set ReasoningEffort based on budget_tokens @@ -218,6 +308,10 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b claudeRequest.Thinking.BudgetTokens, openaiRequest.ReasoningEffort) } } + if claudeRequest.OutputConfig != nil { + openaiRequest.ClaudeOutputConfig = claudeRequest.OutputConfig + } + openaiRequest.ClaudeAnthropicBeta = claudeRequest.AnthropicBeta result, err := json.Marshal(openaiRequest) if err != nil { @@ -260,61 +354,7 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.Http if len(openaiResponse.Choices) > 0 { choice := openaiResponse.Choices[0] if choice.Message != nil { - var contents []claudeTextGenContent - - // Add reasoning content (thinking) if present - check both reasoning and reasoning_content fields - var reasoningText string - if choice.Message.Reasoning != "" { - reasoningText = choice.Message.Reasoning - } else if choice.Message.ReasoningContent != "" { - reasoningText = choice.Message.ReasoningContent - } - - if reasoningText != "" { - emptySignature := "" - contents = append(contents, claudeTextGenContent{ - Type: "thinking", - Signature: &emptySignature, // Use pointer for empty string - Thinking: &reasoningText, - }) - log.Debugf("[OpenAI->Claude] Added thinking content: %s", reasoningText) - } - - // Add text content if present - if choice.Message.StringContent() != "" { - textContent := choice.Message.StringContent() - contents = append(contents, claudeTextGenContent{ - Type: "text", - Text: &textContent, - }) - } - - // Add tool calls if present - if len(choice.Message.ToolCalls) > 0 { - for _, toolCall := range choice.Message.ToolCalls { - if !toolCall.Function.IsEmpty() { - // Parse arguments from JSON string to map - 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, arguments: %s", err, toolCall.Function.Arguments) - input = map[string]interface{}{} - } - } else { - input = map[string]interface{}{} - } - - contents = append(contents, claudeTextGenContent{ - Type: "tool_use", - Id: toolCall.Id, - Name: toolCall.Function.Name, - Input: &input, - }) - } - } - } - - claudeResponse.Content = contents + claudeResponse.Content = claudeContentFromOpenAIMessage(ctx, choice.Message) } // Convert finish reason @@ -444,19 +484,10 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIStreamResponseToClaude(ctx wrappe continue } - // Convert to Claude streaming format claudeStreamResponses := c.buildClaudeStreamResponse(ctx, &openaiStreamResponse) log.Debugf("[OpenAI->Claude] Generated %d Claude stream events from OpenAI chunk", len(claudeStreamResponses)) - for i, claudeStreamResponse := range claudeStreamResponses { - responseData, err := json.Marshal(claudeStreamResponse) - if err != nil { - log.Errorf("unable to marshal claude stream response: %v", err) - continue - } - log.Debugf("[OpenAI->Claude] Stream event [%d/%d]: %s", i+1, len(claudeStreamResponses), string(responseData)) - result.WriteString(fmt.Sprintf("event: %s\ndata: %s\n\n", claudeStreamResponse.Type, responseData)) - } + writeClaudeStreamEvents(&result, claudeStreamResponses) } } @@ -536,6 +567,75 @@ func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpCont log.Debugf("[OpenAI->Claude] Skipping duplicate role message for id: %s", openaiResponse.Id) } + if choice.Delta != nil && choice.Delta.ClaudeContentBlockStop != nil { + stopIndex := *choice.Delta.ClaudeContentBlockStop + shouldStop := false + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &stopIndex, + }) + if c.thinkingBlockStarted && !c.thinkingBlockStopped && c.thinkingBlockIndex == stopIndex { + c.thinkingBlockStopped = true + shouldStop = true + } + if c.textBlockStarted && !c.textBlockStopped && c.textBlockIndex == stopIndex { + c.textBlockStopped = true + shouldStop = true + } + for _, toolCall := range c.toolCallStates { + if toolCall.hasClaudeIndex && !toolCall.contentBlockStopped && toolCall.claudeContentIndex == stopIndex { + toolCall.contentBlockStopped = true + shouldStop = true + } + } + if c.redactedBlockIndexes != nil && c.redactedBlockIndexes[stopIndex] { + delete(c.redactedBlockIndexes, stopIndex) + shouldStop = true + } + if !shouldStop { + return nil + } + return responses + } + + if choice.Delta != nil && choice.Delta.ReasoningRedactedContent != "" { + if c.thinkingBlockStarted && !c.thinkingBlockStopped { + c.thinkingBlockStopped = true + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.thinkingBlockIndex, + }) + } + if c.textBlockStarted && !c.textBlockStopped { + c.textBlockStopped = true + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_stop", + Index: &c.textBlockIndex, + }) + } + redactedIndex := c.nextContentIndex + if choice.Delta.ClaudeContentBlockIndex != nil { + redactedIndex = *choice.Delta.ClaudeContentBlockIndex + } + if redactedIndex >= c.nextContentIndex { + c.nextContentIndex = redactedIndex + 1 + } + data := choice.Delta.ReasoningRedactedContent + if c.redactedBlockIndexes == nil { + c.redactedBlockIndexes = make(map[int]bool) + } + c.redactedBlockIndexes[redactedIndex] = true + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_start", + Index: &redactedIndex, + ContentBlock: &claudeTextGenContent{ + Type: "redacted_thinking", + Data: data, + }, + }) + return responses + } + // Handle reasoning content (thinking) first - check both reasoning and reasoning_content fields var reasoningText string if choice.Delta != nil { @@ -550,10 +650,18 @@ func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpCont log.Debugf("[OpenAI->Claude] Processing reasoning content delta: %s", reasoningText) // Send content_block_start for thinking only once with dynamic index - if !c.thinkingBlockStarted { - c.thinkingBlockIndex = c.nextContentIndex - c.nextContentIndex++ + if !c.thinkingBlockStarted || c.thinkingBlockStopped { + if choice.Delta.ClaudeContentBlockIndex != nil { + c.thinkingBlockIndex = *choice.Delta.ClaudeContentBlockIndex + if c.thinkingBlockIndex >= c.nextContentIndex { + c.nextContentIndex = c.thinkingBlockIndex + 1 + } + } else { + c.thinkingBlockIndex = c.nextContentIndex + c.nextContentIndex++ + } c.thinkingBlockStarted = true + c.thinkingBlockStopped = false log.Debugf("[OpenAI->Claude] Generated content_block_start event for thinking at index %d", c.thinkingBlockIndex) emptyStr := "" responses = append(responses, &claudeTextGenStreamResponse{ @@ -579,6 +687,44 @@ func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpCont }) } + signature := "" + if choice.Delta != nil { + signature = choice.Delta.ReasoningSignature + } + if signature != "" { + if !c.thinkingBlockStarted || c.thinkingBlockStopped { + if choice.Delta != nil && choice.Delta.ClaudeContentBlockIndex != nil { + c.thinkingBlockIndex = *choice.Delta.ClaudeContentBlockIndex + if c.thinkingBlockIndex >= c.nextContentIndex { + c.nextContentIndex = c.thinkingBlockIndex + 1 + } + } else { + c.thinkingBlockIndex = c.nextContentIndex + c.nextContentIndex++ + } + c.thinkingBlockStarted = true + c.thinkingBlockStopped = false + emptyStr := "" + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_start", + Index: &c.thinkingBlockIndex, + ContentBlock: &claudeTextGenContent{ + Type: "thinking", + Signature: &emptyStr, + Thinking: &emptyStr, + }, + }) + } + responses = append(responses, &claudeTextGenStreamResponse{ + Type: "content_block_delta", + Index: &c.thinkingBlockIndex, + Delta: &claudeTextGenDelta{ + Type: "signature_delta", + Signature: signature, + }, + }) + } + // Handle content if choice.Delta != nil && choice.Delta.Content != nil && choice.Delta.Content != "" { deltaContent, ok := choice.Delta.Content.(string) @@ -600,10 +746,18 @@ func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpCont } // Send content_block_start only once for text content with dynamic index - if !c.textBlockStarted { - c.textBlockIndex = c.nextContentIndex - c.nextContentIndex++ + if !c.textBlockStarted || c.textBlockStopped { + if choice.Delta.ClaudeContentBlockIndex != nil { + c.textBlockIndex = *choice.Delta.ClaudeContentBlockIndex + if c.textBlockIndex >= c.nextContentIndex { + c.nextContentIndex = c.textBlockIndex + 1 + } + } else { + c.textBlockIndex = c.nextContentIndex + c.nextContentIndex++ + } c.textBlockStarted = true + c.textBlockStopped = false log.Debugf("[OpenAI->Claude] Generated content_block_start event for text at index %d", c.textBlockIndex) emptyText := "" responses = append(responses, &claudeTextGenStreamResponse{ @@ -674,6 +828,10 @@ func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpCont contentBlockStopped: false, cachedArguments: "", } + if choice.Delta.ClaudeContentBlockIndex != nil { + c.toolCallStates[toolCall.Index].claudeContentIndex = *choice.Delta.ClaudeContentBlockIndex + c.toolCallStates[toolCall.Index].hasClaudeIndex = true + } } toolState := c.toolCallStates[toolCall.Index] @@ -860,13 +1018,17 @@ func (c *ClaudeToOpenAIConverter) convertContentArray(claudeContents []claudeCha openaiContents: []chatMessageContent{}, hasNonTextContent: false, } + claudeContentBlocks := make([]claudeChatMessageContent, 0, len(claudeContents)) + preserveClaudeContentBlocks := false for _, claudeContent := range claudeContents { + preservedClaudeContent := claudeContent switch claudeContent.Type { case "text": if claudeContent.Text != "" { // Strip dynamic cch field from billing header to enable caching processedText := stripCchFromBillingHeader(claudeContent.Text) + preservedClaudeContent.Text = processedText result.textParts = append(result.textParts, processedText) result.openaiContents = append(result.openaiContents, chatMessageContent{ Type: contentTypeText, @@ -874,6 +1036,17 @@ func (c *ClaudeToOpenAIConverter) convertContentArray(claudeContents []claudeCha CacheControl: claudeContent.CacheControl, }) } + case "thinking": + result.hasNonTextContent = true + preserveClaudeContentBlocks = true + result.reasoningContent += claudeContent.Thinking + if claudeContent.Signature != "" { + result.reasoningSignature = claudeContent.Signature + } + case "redacted_thinking": + result.hasNonTextContent = true + preserveClaudeContentBlocks = true + result.reasoningRedactedContent += claudeContent.Data case "image": result.hasNonTextContent = true if claudeContent.Source != nil { @@ -897,6 +1070,7 @@ func (c *ClaudeToOpenAIConverter) convertContentArray(claudeContents []claudeCha } case "tool_use": result.hasNonTextContent = true + preserveClaudeContentBlocks = true // Convert Claude tool_use to OpenAI tool_calls format if claudeContent.Id != "" && claudeContent.Name != "" { // Convert input to JSON string for OpenAI format @@ -920,10 +1094,15 @@ func (c *ClaudeToOpenAIConverter) convertContentArray(claudeContents []claudeCha } case "tool_result": result.hasNonTextContent = true + preserveClaudeContentBlocks = true // Store tool results for processing result.toolResults = append(result.toolResults, claudeContent) log.Debugf("[Claude->OpenAI] Found tool_result for tool_use_id: %s", claudeContent.ToolUseId) } + claudeContentBlocks = append(claudeContentBlocks, preservedClaudeContent) + } + if preserveClaudeContentBlocks { + result.claudeContentBlocks = claudeContentBlocks } return result @@ -953,9 +1132,13 @@ func (c *ClaudeToOpenAIConverter) startToolCall(toolState *toolCallInfo) []*clau }) } - // Assign Claude content index - toolState.claudeContentIndex = c.nextContentIndex - c.nextContentIndex++ + if !toolState.hasClaudeIndex { + toolState.claudeContentIndex = c.nextContentIndex + c.nextContentIndex++ + toolState.hasClaudeIndex = true + } else if toolState.claudeContentIndex >= c.nextContentIndex { + c.nextContentIndex = toolState.claudeContentIndex + 1 + } toolState.contentBlockStarted = true log.Debugf("[OpenAI->Claude] Started tool call: Claude index=%d, id=%s, name=%s", diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/model.go b/plugins/wasm-go/extensions/ai-proxy/provider/model.go index c83314c74..2e072e442 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/model.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/model.go @@ -46,6 +46,9 @@ type chatCompletionRequest struct { Model string `json:"model"` Store bool `json:"store,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` + ClaudeThinking *claudeThinkingConfig `json:"claude_thinking,omitempty"` + ClaudeOutputConfig *claudeOutputConfig `json:"claude_output_config,omitempty"` + ClaudeAnthropicBeta []string `json:"claude_anthropic_beta,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` LogitBias map[string]int `json:"logit_bias,omitempty"` @@ -202,17 +205,22 @@ type completionTokensDetails struct { } type chatMessage struct { - Id string `json:"id,omitempty"` - Audio map[string]interface{} `json:"audio,omitempty"` - Name string `json:"name,omitempty"` - Role string `json:"role,omitempty"` - Content any `json:"content,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` - Reasoning string `json:"reasoning,omitempty"` // For streaming responses - ToolCalls []toolCall `json:"tool_calls,omitempty"` - FunctionCall *functionCall `json:"function_call,omitempty"` // For legacy OpenAI format - Refusal string `json:"refusal,omitempty"` - ToolCallId string `json:"tool_call_id,omitempty"` + Id string `json:"id,omitempty"` + Audio map[string]interface{} `json:"audio,omitempty"` + Name string `json:"name,omitempty"` + Role string `json:"role,omitempty"` + Content any `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` // For streaming responses + ReasoningSignature string `json:"reasoning_signature,omitempty"` + ReasoningRedactedContent string `json:"reasoning_redacted_content,omitempty"` + ClaudeContentBlocks []claudeChatMessageContent `json:"claude_content_blocks,omitempty"` + ClaudeContentBlockIndex *int `json:"claude_content_block_index,omitempty"` + ClaudeContentBlockStop *int `json:"claude_content_block_stop,omitempty"` + ToolCalls []toolCall `json:"tool_calls,omitempty"` + FunctionCall *functionCall `json:"function_call,omitempty"` // For legacy OpenAI format + Refusal string `json:"refusal,omitempty"` + ToolCallId string `json:"tool_call_id,omitempty"` } func (m *chatMessage) handleNonStreamingReasoningContent(reasoningContentMode string) { diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go index ba3645b76..adae9f5d8 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go @@ -1221,6 +1221,10 @@ func (c *ProviderConfig) handleRequestBody( } } + if needClaudeConversion && provider.GetProviderType() != providerTypeBedrock && provider.GetProviderType() != providerTypeClaude { + body = stripClaudeInternalMessageFields(body) + } + // use openai protocol (either original openai or converted from claude) if handler, ok := provider.(TransformRequestBodyHandler); ok { body, err = handler.TransformRequestBody(ctx, apiName, body) @@ -1254,6 +1258,38 @@ func (c *ProviderConfig) handleRequestBody( return types.ActionContinue, replaceRequestBody(body) } +func stripClaudeInternalMessageFields(body []byte) []byte { + result := body + for _, field := range []string{"claude_thinking", "claude_output_config", "claude_anthropic_beta"} { + if updated, err := sjson.DeleteBytes(result, field); err == nil { + result = updated + } + } + + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return result + } + + for _, field := range []string{ + "reasoning", + "reasoning_content", + "reasoning_signature", + "reasoning_redacted_content", + "claude_content_blocks", + "claude_content_block_index", + "claude_content_block_stop", + } { + messages.ForEach(func(key, _ gjson.Result) bool { + if updated, err := sjson.DeleteBytes(result, fmt.Sprintf("messages.%d.%s", key.Int(), field)); err == nil { + result = updated + } + return true + }) + } + return result +} + func (c *ProviderConfig) handleRequestHeaders(provider Provider, ctx wrapper.HttpContext, apiName ApiName) { headers := util.GetRequestHeaders() originPath := headers.Get(":path") diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider_test.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider_test.go index d2359a53e..9dccd76b5 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider_test.go @@ -678,3 +678,35 @@ func TestProviderConfig_SetDefaultCapabilities(t *testing.T) { assert.Equal(t, "/v1/chat/completions", config.capabilities[string(ApiNameChatCompletion)]) }) } + +func TestStripClaudeInternalMessageFields(t *testing.T) { + body := []byte(`{ + "model":"claude", + "claude_thinking":{"type":"adaptive"}, + "claude_output_config":{"effort":"high"}, + "claude_anthropic_beta":["effort-2025-11-24"], + "messages":[{ + "role":"assistant", + "content":"answer", + "reasoning_content":"reasoning", + "reasoning_signature":"sig", + "reasoning_redacted_content":"opaque", + "claude_content_blocks":[{"type":"thinking","thinking":"","signature":"sig"}], + "claude_content_block_index":1, + "claude_content_block_stop":1 + }] + }`) + + result := stripClaudeInternalMessageFields(body) + + assert.False(t, gjson.GetBytes(result, "claude_thinking").Exists()) + assert.False(t, gjson.GetBytes(result, "claude_output_config").Exists()) + assert.False(t, gjson.GetBytes(result, "claude_anthropic_beta").Exists()) + assert.False(t, gjson.GetBytes(result, "messages.0.reasoning_content").Exists()) + assert.False(t, gjson.GetBytes(result, "messages.0.reasoning_signature").Exists()) + assert.False(t, gjson.GetBytes(result, "messages.0.reasoning_redacted_content").Exists()) + assert.False(t, gjson.GetBytes(result, "messages.0.claude_content_blocks").Exists()) + assert.False(t, gjson.GetBytes(result, "messages.0.claude_content_block_index").Exists()) + assert.False(t, gjson.GetBytes(result, "messages.0.claude_content_block_stop").Exists()) + assert.Equal(t, "answer", gjson.GetBytes(result, "messages.0.content").String()) +}