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:
Betula-L
2026-05-07 19:27:48 -07:00
committed by GitHub
parent 6199fe414d
commit b77a074831
8 changed files with 1420 additions and 142 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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())
}