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
24 changes: 24 additions & 0 deletions core/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -5386,6 +5386,30 @@ func executeRequestWithRetries[T any](
}
tracer.SetAttribute(handle, schemas.AttrNumberOfRetries, attempts)

// Surface caller-supplied extra headers (from x-bf-eh-* and direct-allowlist
// header forwarding) as span attributes so observability backends see the
// same set Bifrost forwards to the upstream provider.
if extraHeaders, ok := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string); ok {
for name, values := range extraHeaders {
if name == "" || len(values) == 0 {
continue
}
// Never export credential-bearing headers verbatim. The transport
// layer denylists most sensitive headers, but plain authorization /
// set-cookie can still reach here, and core SDK callers bypass that
// guard entirely. Keep the key (presence is useful) but redact the value.
if schemas.IsSensitiveHeader(name) {
tracer.SetAttribute(handle, schemas.AttrExtraHeaderPrefix+name, schemas.RedactedAttrValue)
continue
}
if len(values) == 1 {
tracer.SetAttribute(handle, schemas.AttrExtraHeaderPrefix+name, values[0])
} else {
tracer.SetAttribute(handle, schemas.AttrExtraHeaderPrefix+name, values)
}
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
roroghost17 marked this conversation as resolved.

// Populate LLM request attributes (messages, parameters, etc.)
if req != nil {
tracer.PopulateLLMRequestAttributes(handle, req)
Expand Down
285 changes: 285 additions & 0 deletions core/providers/bedrock/bedrock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5233,3 +5233,288 @@ func TestBedrockToBifrostResponsesResponse_StructuredOutput_MixedWithRealTools(t
assert.Equal(t, "tool_calls", *result.StopReason,
"expected stop_reason=tool_calls when real tool calls are also present")
}

// TestBedrockSearchResultToolResultRoundTrip is the regression gate for
// https://github.com/maximhq/bifrost/issues/3537 — a Bedrock-native passthrough
// request containing toolResult.content[].searchResult must survive
// ToBifrostResponsesRequest → ToBedrockResponsesRequest with all fields intact.
// Pre-fix, the SearchResult field is dropped during JSON unmarshal and the
// outbound request shows toolResult.content = [{"text": ""}].
func TestBedrockSearchResultToolResultRoundTrip(t *testing.T) {
original := &bedrock.BedrockConverseRequest{
ModelID: "anthropic.claude-sonnet-4-5",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{Text: schemas.Ptr("What is Apptio?")},
},
},
{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: "tooluse_a4rBqeZNRTKj2lTskvaO4H",
Name: "RAGRequest",
Input: json.RawMessage(`{"query":"What is Apptio?"}`),
},
},
},
},
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "tooluse_a4rBqeZNRTKj2lTskvaO4H",
Status: schemas.Ptr("success"),
Content: []bedrock.BedrockContentBlock{
{
SearchResult: &bedrock.BedrockSearchResultBlock{
Source: "Great Source of Information About Apptio",
Title: "12adbd74-46bd-4a88-88b2-0048755f6eb5",
Content: []bedrock.BedrockSearchResultContent{
{Text: "Apptio is a company that makes calls to Bedrock using passthrough APIs via Bifrost"},
},
Citations: &bedrock.BedrockCitationsConfig{Enabled: true},
},
},
},
},
},
},
},
},
System: []bedrock.BedrockSystemMessage{
{Text: schemas.Ptr("Do not rely on your knowledge to answer. Use only the tool results.")},
},
}

ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)

// First leg: Bedrock → Bifrost intermediate.
bifrostReq, err := original.ToBifrostResponsesRequest(ctx)
require.NoError(t, err)
require.NotNil(t, bifrostReq)

// Second leg: Bifrost intermediate → Bedrock.
rebuilt, err := bedrock.ToBedrockResponsesRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, rebuilt)

// Locate the rebuilt toolResult content block (its position may shift
// because the converter groups assistant tool calls and user tool results
// by state-machine emission, but a toolResult with our toolUseId must exist).
var got *bedrock.BedrockSearchResultBlock
for _, msg := range rebuilt.Messages {
for _, block := range msg.Content {
if block.ToolResult == nil {
continue
}
if block.ToolResult.ToolUseID != "tooluse_a4rBqeZNRTKj2lTskvaO4H" {
continue
}
for _, c := range block.ToolResult.Content {
if c.SearchResult != nil {
got = c.SearchResult
break
}
}
}
}

require.NotNil(t, got, "expected toolResult.content[].searchResult to round-trip; got nil (regression of #3537)")
assert.Equal(t, "Great Source of Information About Apptio", got.Source)
assert.Equal(t, "12adbd74-46bd-4a88-88b2-0048755f6eb5", got.Title)
require.Len(t, got.Content, 1)
assert.Equal(t, "Apptio is a company that makes calls to Bedrock using passthrough APIs via Bifrost", got.Content[0].Text)
require.NotNil(t, got.Citations)
assert.True(t, got.Citations.Enabled)
}

// TestBedrockVideoToolResultRoundTrip verifies that a video block inside
// toolResult.content survives ToBifrostResponsesRequest → ToBedrockResponsesRequest
// via the same sentinel-envelope mechanism that preserves searchResult. Without
// the schema fix + envelope trigger extension, Video is silently dropped at JSON
// unmarshal and the outbound request carries an empty text block instead.
func TestBedrockVideoToolResultRoundTrip(t *testing.T) {
// Smallest plausible base64 payload — content doesn't matter for the round-trip,
// only that the Video struct is preserved verbatim.
videoBytes := "AAAA"

original := &bedrock.BedrockConverseRequest{
ModelID: "anthropic.claude-sonnet-4-5",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{Text: schemas.Ptr("Describe the attached clip.")},
},
},
{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: "tooluse_video_xyz",
Name: "FetchClip",
Input: json.RawMessage(`{"id":"abc"}`),
},
},
},
},
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "tooluse_video_xyz",
Status: schemas.Ptr("success"),
Content: []bedrock.BedrockContentBlock{
{
Video: &bedrock.BedrockVideoBlock{
Format: "mp4",
Source: bedrock.BedrockVideoSource{
Bytes: &videoBytes,
},
},
},
},
},
},
},
},
},
}

ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)

bifrostReq, err := original.ToBifrostResponsesRequest(ctx)
require.NoError(t, err)
require.NotNil(t, bifrostReq)

rebuilt, err := bedrock.ToBedrockResponsesRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, rebuilt)

var got *bedrock.BedrockVideoBlock
for _, msg := range rebuilt.Messages {
for _, block := range msg.Content {
if block.ToolResult == nil || block.ToolResult.ToolUseID != "tooluse_video_xyz" {
continue
}
for _, c := range block.ToolResult.Content {
if c.Video != nil {
got = c.Video
break
}
}
}
}

require.NotNil(t, got, "expected toolResult.content[].video to round-trip; got nil")
assert.Equal(t, "mp4", got.Format)
require.NotNil(t, got.Source.Bytes)
assert.Equal(t, videoBytes, *got.Source.Bytes)
assert.Nil(t, got.Source.S3Location, "expected union member s3Location to be nil when bytes is set")
}

// TestBedrockMixedBlockToolResultRoundTrip covers a toolResult.content array that
// mixes a representable block (text) with an unrepresentable one (searchResult).
// Because the envelope path triggers on *any* unrepresentable block and serializes
// the entire content array, the whole array is bundled and must be decoded back
// intact — both blocks, in order. This guards against the decode leg dropping the
// representable block (or vice-versa) when the two are interleaved.
func TestBedrockMixedBlockToolResultRoundTrip(t *testing.T) {
original := &bedrock.BedrockConverseRequest{
ModelID: "anthropic.claude-sonnet-4-5",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{Text: schemas.Ptr("What is Apptio?")},
},
},
{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: "tooluse_mixed_blocks",
Name: "RAGRequest",
Input: json.RawMessage(`{"query":"What is Apptio?"}`),
},
},
},
},
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "tooluse_mixed_blocks",
Status: schemas.Ptr("success"),
Content: []bedrock.BedrockContentBlock{
{Text: schemas.Ptr("Summary: Apptio is a Bedrock passthrough customer.")},
{
SearchResult: &bedrock.BedrockSearchResultBlock{
Source: "Great Source of Information About Apptio",
Title: "12adbd74-46bd-4a88-88b2-0048755f6eb5",
Content: []bedrock.BedrockSearchResultContent{
{Text: "Apptio is a company that makes calls to Bedrock using passthrough APIs via Bifrost"},
},
Citations: &bedrock.BedrockCitationsConfig{Enabled: true},
},
},
},
},
},
},
},
},
System: []bedrock.BedrockSystemMessage{
{Text: schemas.Ptr("Do not rely on your knowledge to answer. Use only the tool results.")},
},
}

ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)

bifrostReq, err := original.ToBifrostResponsesRequest(ctx)
require.NoError(t, err)
require.NotNil(t, bifrostReq)

rebuilt, err := bedrock.ToBedrockResponsesRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, rebuilt)

// Locate the rebuilt toolResult content array for our toolUseId.
var gotContent []bedrock.BedrockContentBlock
for _, msg := range rebuilt.Messages {
for _, block := range msg.Content {
if block.ToolResult == nil || block.ToolResult.ToolUseID != "tooluse_mixed_blocks" {
continue
}
gotContent = block.ToolResult.Content
}
}

require.NotNil(t, gotContent, "expected toolResult with our toolUseId to round-trip; got nil")
require.Len(t, gotContent, 2, "expected both the text and searchResult blocks to survive the round-trip")

// Order is preserved: the envelope is a JSON array, so block[0] is the text
// block and block[1] is the searchResult block.
require.NotNil(t, gotContent[0].Text, "expected first block to remain a text block")
assert.Equal(t, "Summary: Apptio is a Bedrock passthrough customer.", *gotContent[0].Text)
assert.Nil(t, gotContent[0].SearchResult, "text block must not gain a searchResult")

got := gotContent[1].SearchResult
require.NotNil(t, got, "expected second block to remain a searchResult block")
assert.Nil(t, gotContent[1].Text, "searchResult block must not gain a text field")
assert.Equal(t, "Great Source of Information About Apptio", got.Source)
assert.Equal(t, "12adbd74-46bd-4a88-88b2-0048755f6eb5", got.Title)
require.Len(t, got.Content, 1)
assert.Equal(t, "Apptio is a company that makes calls to Bedrock using passthrough APIs via Bifrost", got.Content[0].Text)
require.NotNil(t, got.Citations)
assert.True(t, got.Citations.Enabled)
}
26 changes: 23 additions & 3 deletions core/providers/bedrock/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -3163,7 +3163,12 @@ func ConvertBifrostMessagesToBedrockMessages(ctx context.Context, bifrostMessage
// Convert result content to Bedrock format
if msg.ResponsesToolMessage.Output != nil {
if msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil {
resultContent = append(resultContent, tryParseJSONIntoContentBlock(*msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr))
outputStr := *msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr
if blocks, ok := decodeBedrockToolResultEnvelope(outputStr); ok {
resultContent = append(resultContent, blocks...)
} else {
resultContent = append(resultContent, tryParseJSONIntoContentBlock(outputStr))
}
} else if msg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks != nil {
// Handle structured output blocks
for _, block := range msg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks {
Expand Down Expand Up @@ -3989,9 +3994,24 @@ func convertSingleBedrockMessageToBifrostMessages(ctx *schemas.BifrostContext, m

} else if block.ToolResult != nil {
// Tool result content - typically not in assistant output but handled for completeness
// Prefer JSON payloads without unmarshalling; fallback to text
// Prefer JSON payloads without unmarshalling; fallback to text.
// If the content contains a searchResult (or any other block Bifrost's intermediate
// can't model natively), serialize the full content array into a sentinel envelope
// so it round-trips losslessly via ToBedrockResponsesRequest.
var resultContent string
if len(block.ToolResult.Content) > 0 {
hasUnrepresentableBlock := false
for _, c := range block.ToolResult.Content {
if c.SearchResult != nil || c.Video != nil {
hasUnrepresentableBlock = true
break
}
}
if hasUnrepresentableBlock {
if envelope, err := encodeBedrockToolResultEnvelope(block.ToolResult.Content); err == nil {
resultContent = envelope
}
}
if resultContent == "" && len(block.ToolResult.Content) > 0 {
// JSON first (no unmarshal; just one marshal to string when present)
for _, c := range block.ToolResult.Content {
if c.JSON != nil {
Expand Down
Loading
Loading