mirror of
https://github.com/alibaba/higress.git
synced 2026-05-11 14:27:27 +08:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(), "<think>")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user