Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
- fix: strip the encrypted reasoning signature when the upstream reports the field as unsupported (e.g. Bedrock Converse replaying a Claude signature onto a non-Anthropic model after a mid-conversation model switch), extending the existing unverifiable-signature fail-soft
- fix: clear Anthropic raw-body passthrough based on the resolved provider and model pair, so non-Claude models on multi-family providers (Vertex, Azure, Bedrock Mantle) convert the request instead of passing the Anthropic payload through
- feat: optionally synthesize prompt-cache breakpoints for requests that carry none, so agentic clients that emit no cache markers (Codex and similar) stop paying the cache-write rate on every turn; off by default per provider via `prompt_cache.auto_inject`, with `cache_control_injection_points` for role/index targeting, and extended to translate the marker into `prompt_cache_breakpoint` plus explicit cache mode on the gpt-5.6 family
- fix: drop unsigned reasoning blocks from Bedrock Converse replays to Claude, which verifies the thinking signature and rejects a block without one in every serialisation; Nova and MiniMax keep receiving unsigned blocks with the field omitted. Gated on a new datasheet capability `bedrock_requires_signed_reasoning` with an Anthropic-family fallback (#6624)
- fix: render reasoning the upstream actually returned on Bedrock Converse-shaped responses (`/bedrock` converse and the framework drop-ins that reuse it): a native Grok or OpenAI reasoning summary with no encrypted content was resolved through the request-direction redacted shape and silently dropped, so clients saw text only; the response direction now emits `reasoningText` for exposed text and keeps `redactedContent` for opaque blocks, while replays to Bedrock are unchanged [@akshaydeo](https://github.com/akshaydeo)
5 changes: 3 additions & 2 deletions core/providers/bedrock/bedrock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4159,7 +4159,8 @@ func TestNovaReasoningEffortClamped(t *testing.T) {

// TestReasoningSignatureEchoedOnlyWhenNonEmpty verifies that an empty reasoning
// signature is dropped before sending to Bedrock (MiniMax emits ""), while a real
// signature is preserved (Anthropic requires it). Keyed on the value, not the model.
// signature is preserved. Runs on a Nova id: on Claude an unsigned block is not
// sent at all (#6624), which TestUnsignedReasoningReplay_Chat pins.
func TestReasoningSignatureEchoedOnlyWhenNonEmpty(t *testing.T) {
cases := map[string]struct {
in *string
Expand All @@ -4172,7 +4173,7 @@ func TestReasoningSignatureEchoedOnlyWhenNonEmpty(t *testing.T) {
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
bifrostReq := &schemas.BifrostChatRequest{
Model: "anthropic.claude-sonnet-4-5",
Model: "amazon.nova-pro-v1:0",
Comment thread
akshaydeo marked this conversation as resolved.
Input: []schemas.ChatMessage{
{Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("hi")}},
{
Expand Down
157 changes: 157 additions & 0 deletions core/providers/bedrock/converseresponsereasoning_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package bedrock

import (
"context"
"testing"

"github.com/maximhq/bifrost/core/schemas"
"github.com/stretchr/testify/require"
)

// The Converse-shaped ingress routes (/bedrock converse and the framework drop-ins
// that reuse it) render a Bifrost response back through the request-direction
// message converter. That converter picks a reasoning shape per model family so
// that Bedrock never receives a block it did not sign, which is right for a
// replay and wrong for a response: a native xai Grok reasoning item carries a
// summary and no encrypted content, and the redacted shape silently dropped it
// (harness 47.5.G, 48.1.B, 48.2.B, 48.3.B). The response direction must render
// whatever the upstream exposed.
func TestConverseResponseRendersUpstreamReasoning(t *testing.T) {
answer := "44"

t.Run("summary without encrypted content becomes reasoningText", func(t *testing.T) {
resp, err := ToBedrockConverseResponse(&schemas.BifrostResponsesResponse{
Model: "xai/grok-4-0709",
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
ResponsesReasoning: &schemas.ResponsesReasoning{
Summary: []schemas.ResponsesReasoningSummary{{Type: "summary_text", Text: "simulate night by night"}},
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{Type: schemas.ResponsesOutputMessageContentTypeText, Text: &answer},
},
},
},
},
})
require.NoError(t, err)
require.NotNil(t, resp.Output)
require.NotNil(t, resp.Output.Message)
content := resp.Output.Message.Content
require.Len(t, content, 2, "reasoning block must precede the text block, got %+v", content)
require.NotNil(t, content[0].ReasoningContent, "first block must be the reasoning block")
require.NotNil(t, content[0].ReasoningContent.ReasoningText, "an exposed summary renders as reasoningText")
require.NotNil(t, content[0].ReasoningContent.ReasoningText.Text)
require.Equal(t, "simulate night by night", *content[0].ReasoningContent.ReasoningText.Text)
require.Nil(t, content[0].ReasoningContent.ReasoningText.Signature, "no signature exists to echo")
require.Nil(t, content[0].ReasoningContent.RedactedContent)
require.NotNil(t, content[1].Text)
require.Equal(t, answer, *content[1].Text)
})

t.Run("encrypted content without text stays redactedContent", func(t *testing.T) {
blob := "cnNuXzVaVnJpZjRKMGJYSXFtV2RsZWRqN1FJRmVGZWdz"
resp, err := ToBedrockConverseResponse(&schemas.BifrostResponsesResponse{
Model: "us.xai.grok-4.6",
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
ResponsesReasoning: &schemas.ResponsesReasoning{
Summary: []schemas.ResponsesReasoningSummary{},
EncryptedContent: &blob,
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{Type: schemas.ResponsesOutputMessageContentTypeText, Text: &answer},
},
},
},
},
})
require.NoError(t, err)
content := resp.Output.Message.Content
require.Len(t, content, 2)
require.NotNil(t, content[0].ReasoningContent)
require.NotNil(t, content[0].ReasoningContent.RedactedContent, "an opaque block must stay redacted")
require.Equal(t, blob, *content[0].ReasoningContent.RedactedContent)
require.Nil(t, content[0].ReasoningContent.ReasoningText, "reasoningText must never accompany a redacted block")
})

t.Run("request direction still drops unsigned Grok text", func(t *testing.T) {
blocks := convertBifrostReasoningToBedrockReasoning(&schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
ResponsesReasoning: &schemas.ResponsesReasoning{
Summary: []schemas.ResponsesReasoningSummary{{Type: "summary_text", Text: "simulate night by night"}},
},
}, converseReasoningShape("xai/grok-4-0709"), converseRequiresSignedReasoning("xai/grok-4-0709"))
require.Empty(t, blocks, "a replay to Bedrock must not send a block Bedrock cannot verify")
})
}

func TestConverseResponseRendersEmbeddedReasoning(t *testing.T) {
for _, model := range []string{unsignedReasoningClaude, unsignedReasoningNova, "xai/grok-4-0709"} {
for name, signature := range map[string]*string{"absent": nil, "empty": schemas.Ptr(""), "signed": schemas.Ptr("signed-fixture")} {
t.Run(model+"/"+name, func(t *testing.T) {
resp, err := ToBedrockConverseResponse(&schemas.BifrostResponsesResponse{
Model: model,
Output: []schemas.ResponsesMessage{{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{
{Type: schemas.ResponsesOutputMessageContentTypeReasoning, Text: schemas.Ptr("thinking"), Signature: signature},
{Type: schemas.ResponsesOutputMessageContentTypeText, Text: schemas.Ptr("answer")},
}},
}},
})
require.NoError(t, err)
require.Len(t, resp.Output.Message.Content, 2)
reasoning := resp.Output.Message.Content[0].ReasoningContent
require.NotNil(t, reasoning)
require.NotNil(t, reasoning.ReasoningText)
require.Equal(t, "thinking", *reasoning.ReasoningText.Text)
require.Equal(t, reasoningSignatureForBedrock(signature), reasoning.ReasoningText.Signature)
require.Equal(t, "answer", *resp.Output.Message.Content[1].Text)
})
}
}
}

func TestConverseResponseMixedReasoningRoundTrip(t *testing.T) {
for name, text := range map[string]string{"text": "visible thinking", "empty text": ""} {
t.Run(name, func(t *testing.T) {
content := []BedrockContentBlock{
{ReasoningContent: &BedrockReasoningContent{ReasoningText: &BedrockReasoningContentText{Text: &text, Signature: schemas.Ptr("text-signature")}}},
{ReasoningContent: &BedrockReasoningContent{RedactedContent: schemas.Ptr("b3BhcXVl")}},
{Text: schemas.Ptr("answer")},
}
upstream := &BedrockConverseResponse{Output: &BedrockConverseOutput{Message: &BedrockMessage{Role: BedrockMessageRoleAssistant, Content: content}}}
bifrost, err := upstream.ToBifrostResponsesResponse(schemas.NewBifrostContext(context.Background(), schemas.NoDeadline))
require.NoError(t, err)
bifrost.Model = unsignedReasoningClaude
rendered, err := ToBedrockConverseResponse(bifrost)
require.NoError(t, err)
require.Equal(t, content, rendered.Output.Message.Content, "both reasoning variants and their signatures must survive in order")
})
}
}

func TestConverseResponseSummarySignatureIsNotRedactedContent(t *testing.T) {
blocks := convertBifrostReasoningToConverseResponseReasoning(&schemas.ResponsesMessage{
ResponsesReasoning: &schemas.ResponsesReasoning{
Summary: []schemas.ResponsesReasoningSummary{{Text: "summary"}},
EncryptedContent: schemas.Ptr("summary-signature"),
},
})
require.Len(t, blocks, 1, "summary encrypted_content is its signature, not a second opaque block")
require.Equal(t, "summary-signature", *blocks[0].ReasoningContent.ReasoningText.Signature)
require.Nil(t, blocks[0].ReasoningContent.RedactedContent)
}
49 changes: 43 additions & 6 deletions core/providers/bedrock/reasoning_replay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func TestConvertBifrostReasoningToBedrockReasoning(t *testing.T) {

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
blocks := convertBifrostReasoningToBedrockReasoning(tc.msg, schemas.BedrockReasoningShapeText)
blocks := convertBifrostReasoningToBedrockReasoning(tc.msg, schemas.BedrockReasoningShapeText, false)

require.Len(t, blocks, tc.wantBlocks)
reasoningTextInvariant(t, blocks)
Expand Down Expand Up @@ -239,7 +239,7 @@ func TestConvertBifrostReasoningToBedrockReasoningEncryptedContent(t *testing.T)
},
}

blocks := convertBifrostReasoningToBedrockReasoning(message, schemas.BedrockReasoningShapeText)
blocks := convertBifrostReasoningToBedrockReasoning(message, schemas.BedrockReasoningShapeText, false)
require.Len(t, blocks, 1)
require.NotNil(t, blocks[0].ReasoningContent)
require.NotNil(t, blocks[0].ReasoningContent.ReasoningText)
Expand All @@ -261,7 +261,7 @@ func TestConvertBifrostReasoningToBedrockReasoningTextAlwaysSerialized(t *testin
Summary: []schemas.ResponsesReasoningSummary{},
EncryptedContent: &signature,
},
}, schemas.BedrockReasoningShapeText)
}, schemas.BedrockReasoningShapeText, false)
require.Len(t, blocks, 1)

raw, err := sonic.Marshal(blocks[0])
Expand Down Expand Up @@ -403,6 +403,43 @@ func TestConverseReasoningShapeFollowsCanonicalModel(t *testing.T) {
"an unresolved opaque id carries no family and must not be guessed as redacted")
}

func TestConverseRequiresSignedReasoningFamilyFallback(t *testing.T) {
tests := []struct {
model string
want bool
}{
{"global.anthropic.claude-sonnet-4-6", true},
{"us.anthropic.claude-opus-4-8", true},
{"anthropic.claude-3-5-sonnet-20240620-v1:0", true},
{"amazon.nova-pro-v1:0", false},
{"minimax.minimax-m2", false},
{"us.deepseek.r1-v1:0", false},
}
for _, tc := range tests {
t.Run(tc.model, func(t *testing.T) {
require.Equal(t, tc.want, converseRequiresSignedReasoning(tc.model))
})
}
}

func TestConverseRequiresSignedReasoningDatasheetWinsOverFamily(t *testing.T) {
t.Run("row can turn it off for a claude id", func(t *testing.T) {
const model = "global.anthropic.claude-sonnet-4-6"
installBedrockCapabilityRecord(t, model, &schemas.ModelCapabilities{BedrockRequiresSignedReasoning: schemas.Ptr(false)})
require.False(t, converseRequiresSignedReasoning(model))
})
t.Run("row can turn it on for a nova id", func(t *testing.T) {
const model = "amazon.nova-pro-v1:0"
installBedrockCapabilityRecord(t, model, &schemas.ModelCapabilities{BedrockRequiresSignedReasoning: schemas.Ptr(true)})
require.True(t, converseRequiresSignedReasoning(model))
})
t.Run("empty row falls back to the family", func(t *testing.T) {
const model = "global.anthropic.claude-sonnet-4-6"
installBedrockCapabilityRecord(t, model, &schemas.ModelCapabilities{})
require.True(t, converseRequiresSignedReasoning(model))
})
}

func TestConvertBifrostReasoningToBedrockReasoningRedactedShape(t *testing.T) {
blob := "cnNuXzVaVnJpZjRKMGJYSXFtV2RsZWRqN1FJRmVGZWdz"

Expand All @@ -413,7 +450,7 @@ func TestConvertBifrostReasoningToBedrockReasoningRedactedShape(t *testing.T) {
Summary: []schemas.ResponsesReasoningSummary{},
EncryptedContent: &blob,
},
}, schemas.BedrockReasoningShapeRedacted)
}, schemas.BedrockReasoningShapeRedacted, false)

require.Len(t, blocks, 1)
require.NotNil(t, blocks[0].ReasoningContent)
Expand Down Expand Up @@ -441,7 +478,7 @@ func TestConvertBifrostReasoningToBedrockReasoningRedactedShape(t *testing.T) {
ResponsesReasoning: &schemas.ResponsesReasoning{
Summary: []schemas.ResponsesReasoningSummary{},
},
}, schemas.BedrockReasoningShapeRedacted)
}, schemas.BedrockReasoningShapeRedacted, false)

require.Empty(t, blocks, "an unreplayable block must be dropped, not reshaped")
})
Expand All @@ -452,7 +489,7 @@ func TestConvertBifrostReasoningToBedrockReasoningRedactedShape(t *testing.T) {
ResponsesReasoning: &schemas.ResponsesReasoning{
Summary: []schemas.ResponsesReasoningSummary{{Text: "step by step"}},
},
}, schemas.BedrockReasoningShapeRedacted)
}, schemas.BedrockReasoningShapeRedacted, false)

require.Empty(t, blocks)
})
Expand Down
Loading
Loading