Files
bifrost/core/providers/bedrock/bedrock_test.go
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

4327 lines
139 KiB
Go

package bedrock_test
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"github.com/maximhq/bifrost/core/internal/llmtests"
"github.com/maximhq/bifrost/core/providers/bedrock"
"github.com/maximhq/bifrost/core/schemas"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mustMarshalJSON(v interface{}) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
panic("mustMarshalJSON: " + err.Error())
}
return json.RawMessage(b)
}
// jsonEqual compares two json.RawMessage values semantically (ignoring key order).
func jsonEqual(t *testing.T, expected, actual json.RawMessage, msgAndArgs ...interface{}) {
t.Helper()
if expected == nil && actual == nil {
return
}
var e, a interface{}
if err := json.Unmarshal(expected, &e); err != nil {
t.Errorf("failed to unmarshal expected JSON: %v", err)
return
}
if err := json.Unmarshal(actual, &a); err != nil {
t.Errorf("failed to unmarshal actual JSON: %v", err)
return
}
assert.Equal(t, e, a, msgAndArgs...)
}
// mustMarshalToolParams marshals ToolFunctionParameters to json.RawMessage,
// matching the conversion code path for deterministic output.
func mustMarshalToolParams(params *schemas.ToolFunctionParameters) json.RawMessage {
b, err := json.Marshal(params)
if err != nil {
panic("mustMarshalToolParams: " + err.Error())
}
return json.RawMessage(b)
}
// Common test variables
var (
testMaxTokens = 100
testTemp = 0.7
testTopP = 0.9
testStop = []string{"STOP"}
testTrace = "enabled"
testLatency = "optimized"
testProps = *schemas.NewOrderedMapFromPairs(
schemas.KV("location", map[string]interface{}{
"type": "string",
"description": "The city name",
}),
)
// testPropsFromJSON is the same as testProps but with nested values as *OrderedMap
// (as produced by json.Unmarshal -> OrderedMap.UnmarshalJSON)
testPropsFromJSON = *schemas.NewOrderedMapFromPairs(
schemas.KV("location", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
schemas.KV("description", "The city name"),
)),
)
)
// assertBedrockRequestEqual compares two BedrockConverseRequest objects
// but ignores the order of tools in ToolConfig
func assertBedrockRequestEqual(t *testing.T, expected, actual *bedrock.BedrockConverseRequest) {
t.Helper()
assert.Equal(t, expected.ModelID, actual.ModelID)
assert.Equal(t, expected.Messages, actual.Messages)
assert.Equal(t, expected.System, actual.System)
assert.Equal(t, expected.InferenceConfig, actual.InferenceConfig)
assert.Equal(t, expected.GuardrailConfig, actual.GuardrailConfig)
assert.Equal(t, expected.AdditionalModelRequestFields, actual.AdditionalModelRequestFields)
assert.Equal(t, expected.AdditionalModelResponseFieldPaths, actual.AdditionalModelResponseFieldPaths)
assert.Equal(t, expected.PerformanceConfig, actual.PerformanceConfig)
assert.Equal(t, expected.PromptVariables, actual.PromptVariables)
assert.Equal(t, expected.RequestMetadata, actual.RequestMetadata)
assert.Equal(t, expected.ServiceTier, actual.ServiceTier)
assert.Equal(t, expected.Stream, actual.Stream)
assert.Equal(t, expected.ExtraParams, actual.ExtraParams)
assert.Equal(t, expected.Fallbacks, actual.Fallbacks)
if expected.ToolConfig == nil {
assert.Nil(t, actual.ToolConfig)
return
}
require.NotNil(t, actual.ToolConfig)
assert.Equal(t, expected.ToolConfig.ToolChoice, actual.ToolConfig.ToolChoice)
expectedTools := expected.ToolConfig.Tools
actualTools := actual.ToolConfig.Tools
assert.Equal(t, len(expectedTools), len(actualTools), "Tool count mismatch")
expectedToolMap := make(map[string]bedrock.BedrockTool)
for _, tool := range expectedTools {
if tool.ToolSpec != nil {
expectedToolMap[tool.ToolSpec.Name] = tool
}
}
actualToolMap := make(map[string]bedrock.BedrockTool)
for _, tool := range actualTools {
if tool.ToolSpec != nil {
actualToolMap[tool.ToolSpec.Name] = tool
}
}
for name, expectedTool := range expectedToolMap {
actualTool, exists := actualToolMap[name]
assert.True(t, exists, "Tool %s not found in actual tools", name)
if exists {
// Compare tool specs field-by-field, using JSON-semantic comparison
// for InputSchema to handle key ordering differences from sorted marshaling
if expectedTool.ToolSpec != nil && actualTool.ToolSpec != nil {
assert.Equal(t, expectedTool.ToolSpec.Name, actualTool.ToolSpec.Name, "Tool %s name differs", name)
assert.Equal(t, expectedTool.ToolSpec.Description, actualTool.ToolSpec.Description, "Tool %s description differs", name)
jsonEqual(t, expectedTool.ToolSpec.InputSchema.JSON, actualTool.ToolSpec.InputSchema.JSON, "Tool %s input schema differs", name)
} else {
assert.Equal(t, expectedTool, actualTool, "Tool %s differs", name)
}
}
}
}
func TestBedrock(t *testing.T) {
t.Parallel()
if strings.TrimSpace(os.Getenv("AWS_ACCESS_KEY_ID")) == "" || strings.TrimSpace(os.Getenv("AWS_SECRET_ACCESS_KEY")) == "" {
t.Skip("Skipping Bedrock tests because AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set")
}
client, ctx, cancel, err := llmtests.SetupTest()
if err != nil {
t.Fatalf("Error initializing test setup: %v", err)
}
defer cancel()
defer client.Shutdown()
// Get Bedrock-specific configuration from environment
s3Bucket := os.Getenv("AWS_S3_BUCKET")
roleArn := os.Getenv("AWS_BEDROCK_ROLE_ARN")
rerankModelARN := strings.TrimSpace(os.Getenv("AWS_BEDROCK_RERANK_MODEL_ARN"))
// Build extra params for batch and file operations
var batchExtraParams map[string]interface{}
var fileExtraParams map[string]interface{}
if s3Bucket != "" {
fileExtraParams = map[string]interface{}{
"s3_bucket": s3Bucket,
}
batchExtraParams = map[string]interface{}{
"output_s3_uri": "s3://" + s3Bucket + "/batch-output/",
}
if roleArn != "" {
batchExtraParams["role_arn"] = roleArn
}
}
testConfig := llmtests.ComprehensiveTestConfig{
Provider: schemas.Bedrock,
ChatModel: "claude-4-sonnet",
VisionModel: "claude-4-sonnet",
Fallbacks: []schemas.Fallback{
{Provider: schemas.Bedrock, Model: "claude-4-sonnet"},
{Provider: schemas.Bedrock, Model: "claude-4.5-sonnet"},
},
EmbeddingModel: "cohere.embed-v4:0",
RerankModel: rerankModelARN,
ReasoningModel: "claude-4.5-sonnet",
PromptCachingModel: "claude-4.5-sonnet",
ImageEditModel: "amazon.nova-canvas-v1:0",
ImageVariationModel: "amazon.nova-canvas-v1:0",
InterleavedThinkingModel: "global.anthropic.claude-opus-4-5-20251101-v1:0",
BatchExtraParams: batchExtraParams,
FileExtraParams: fileExtraParams,
Scenarios: llmtests.TestScenarios{
TextCompletion: false, // Not supported
SimpleChat: true,
CompletionStream: true,
MultiTurnConversation: true,
ToolCalls: true,
ToolCallsStreaming: true,
MultipleToolCalls: true,
MultipleToolCallsStreaming: true,
End2EndToolCalling: true,
AutomaticFunctionCall: true,
ImageURL: false, // Bedrock doesn't support image URL
ImageBase64: true,
MultipleImages: false, // Since one of the image is URL
FileBase64: true,
FileURL: false, // S3 urls supported for nova models
CompleteEnd2End: true,
Embedding: true,
Rerank: rerankModelARN != "",
ListModels: true,
Reasoning: true,
PromptCaching: true,
BatchCreate: true,
BatchList: true,
BatchRetrieve: true,
BatchCancel: true,
BatchResults: true,
FileUpload: true,
FileList: true,
FileRetrieve: true,
FileDelete: true,
FileContent: true,
FileBatchInput: true,
CountTokens: true,
ImageEdit: true,
ImageVariation: true,
StructuredOutputs: true,
InterleavedThinking: true,
EagerInputStreaming: true, // fine-grained-tool-streaming-2025-05-14 (per B-header)
// ServerToolsViaOpenAIEndpoint: Bedrock does not support web_search / web_fetch /
// code_execution server tools per Table 20, so no cases would run. Left off.
},
}
t.Run("BedrockTests", func(t *testing.T) {
llmtests.RunAllComprehensiveTests(t, client, ctx, testConfig)
})
}
// TestBifrostToBedrockRequestConversion tests the conversion from Bifrost request to Bedrock request
func TestBifrostToBedrockRequestConversion(t *testing.T) {
maxTokens := testMaxTokens
temp := testTemp
topP := testTopP
stop := testStop
trace := testTrace
latency := testLatency
serviceTier := "priority"
props := testProps
tests := []struct {
name string
input *schemas.BifrostChatRequest
expected *bedrock.BedrockConverseRequest
wantErr bool
}{
{
name: "BasicTextMessage",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello, world!"),
},
},
},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello, world!"),
},
},
},
},
},
},
{
name: "SystemMessage",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleSystem,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("System message 1"),
},
},
{
Role: schemas.ChatMessageRoleSystem,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("System message 2"),
},
},
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello!"),
},
},
},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
System: []bedrock.BedrockSystemMessage{
{
Text: schemas.Ptr("System message 1"),
},
{
Text: schemas.Ptr("System message 2"),
},
},
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
},
},
{
name: "InferenceParameters",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello!"),
},
},
},
Params: &schemas.ChatParameters{
MaxCompletionTokens: &maxTokens,
Temperature: &temp,
TopP: &topP,
Stop: stop,
},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
InferenceConfig: &bedrock.BedrockInferenceConfig{
MaxTokens: &maxTokens,
Temperature: &temp,
TopP: &topP,
StopSequences: stop,
},
},
},
{
name: "ServiceTierProvided",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello!"),
},
},
},
Params: &schemas.ChatParameters{
ServiceTier: &serviceTier,
},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
InferenceConfig: &bedrock.BedrockInferenceConfig{},
ServiceTier: &bedrock.BedrockServiceTier{
Type: serviceTier,
},
},
},
{
name: "ServiceTierNotProvided",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello!"),
},
},
},
Params: &schemas.ChatParameters{
Temperature: &temp,
},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
InferenceConfig: &bedrock.BedrockInferenceConfig{
Temperature: &temp,
},
},
},
{
name: "Tools",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("What's the weather?"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "get_weather",
Description: schemas.Ptr("Get weather information"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("location", map[string]interface{}{
"type": "string",
"description": "The city name",
}),
),
Required: []string{"location"},
},
},
},
},
},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("What's the weather?"),
},
},
},
},
InferenceConfig: &bedrock.BedrockInferenceConfig{},
ToolConfig: &bedrock.BedrockToolConfig{
Tools: []bedrock.BedrockTool{
{
ToolSpec: &bedrock.BedrockToolSpec{
Name: "get_weather",
Description: schemas.Ptr("Get weather information"),
InputSchema: bedrock.BedrockToolInputSchema{
JSON: mustMarshalToolParams(&schemas.ToolFunctionParameters{
Type: "object",
Properties: &props,
Required: []string{"location"},
}),
},
},
},
},
},
},
},
{
name: "AllExtraParams",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello!"),
},
},
},
Params: &schemas.ChatParameters{
ExtraParams: map[string]interface{}{
"guardrailConfig": map[string]interface{}{
"guardrailIdentifier": "test-guardrail",
"guardrailVersion": "1",
"trace": trace,
},
"performanceConfig": map[string]interface{}{
"latency": "optimized",
},
"promptVariables": map[string]interface{}{
"username": map[string]interface{}{
"text": "John",
},
},
"requestMetadata": map[string]string{
"user": "test-user",
},
"additionalModelRequestFieldPaths": map[string]interface{}{
"customField": "customValue",
},
"additionalModelResponseFieldPaths": []interface{}{"field1", "field2"},
},
},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
InferenceConfig: &bedrock.BedrockInferenceConfig{},
GuardrailConfig: &bedrock.BedrockGuardrailConfig{
GuardrailIdentifier: "test-guardrail",
GuardrailVersion: "1",
Trace: &trace,
},
PerformanceConfig: &bedrock.BedrockPerformanceConfig{
Latency: &latency,
},
PromptVariables: map[string]bedrock.BedrockPromptVariable{
"username": {
Text: schemas.Ptr("John"),
},
},
RequestMetadata: map[string]string{
"user": "test-user",
},
AdditionalModelRequestFields: schemas.NewOrderedMapFromPairs(
schemas.KV("customField", "customValue"),
),
AdditionalModelResponseFieldPaths: []string{"field1", "field2"},
},
},
{
name: "ParallelToolCalls",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Invoke all tools in parallel that are available to you"),
},
},
{
Role: schemas.ChatMessageRoleAssistant,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("I'll invoke both available tools in parallel for you."),
},
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{
Index: 0,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("tooluse_Yl388l8ES0G_3TQtDcKq_g"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("hello"),
Arguments: "{}",
},
},
{
Index: 1,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("tooluse_eARDw2iqRXak8uyRC2KxXw"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("world"),
Arguments: "{}",
},
},
},
},
},
{
Role: schemas.ChatMessageRoleTool,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello"),
},
ChatToolMessage: &schemas.ChatToolMessage{
ToolCallID: schemas.Ptr("tooluse_Yl388l8ES0G_3TQtDcKq_g"),
},
},
{
Role: schemas.ChatMessageRoleTool,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("World"),
},
ChatToolMessage: &schemas.ChatToolMessage{
ToolCallID: schemas.Ptr("tooluse_eARDw2iqRXak8uyRC2KxXw"),
},
},
},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Invoke all tools in parallel that are available to you"),
},
},
},
{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("I'll invoke both available tools in parallel for you."),
},
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: "tooluse_Yl388l8ES0G_3TQtDcKq_g",
Name: "hello",
Input: json.RawMessage("{}"),
},
},
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: "tooluse_eARDw2iqRXak8uyRC2KxXw",
Name: "world",
Input: json.RawMessage("{}"),
},
},
},
},
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "tooluse_Yl388l8ES0G_3TQtDcKq_g",
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello"),
},
},
Status: schemas.Ptr("success"),
},
},
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "tooluse_eARDw2iqRXak8uyRC2KxXw",
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("World"),
},
},
Status: schemas.Ptr("success"),
},
},
},
},
},
ToolConfig: &bedrock.BedrockToolConfig{
Tools: []bedrock.BedrockTool{
{
ToolSpec: &bedrock.BedrockToolSpec{
Name: "hello",
Description: schemas.Ptr("Tool extracted from conversation history"),
InputSchema: bedrock.BedrockToolInputSchema{
JSON: mustMarshalJSON(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}),
},
},
},
{
ToolSpec: &bedrock.BedrockToolSpec{
Name: "world",
Description: schemas.Ptr("Tool extracted from conversation history"),
InputSchema: bedrock.BedrockToolInputSchema{
JSON: mustMarshalJSON(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}),
},
},
},
},
},
},
},
{
name: "NilRequest",
input: nil,
wantErr: true,
},
{
name: "EmptyMessages",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
Messages: nil,
},
},
{
name: "ArrayToolMessage",
input: &schemas.BifrostChatRequest{
Model: "claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("What's the weather like in New York?"),
},
},
{
Role: schemas.ChatMessageRoleAssistant,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("I'll invoke get_weather tool to know the weather in New York."),
},
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{
Index: 0,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("tooluse_Yl388l8ES0G_3TQtDcKq_g"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("get_weather"),
Arguments: `{"location":"New York"}`,
},
},
},
},
},
{
Role: schemas.ChatMessageRoleTool,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr(`[{"period":"now","weather":"sunny"},{"period":"next_1_hour","weather":"cloudy"}]`),
},
ChatToolMessage: &schemas.ChatToolMessage{
ToolCallID: schemas.Ptr("tooluse_Yl388l8ES0G_3TQtDcKq_g"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "get_weather",
Description: schemas.Ptr("Get weather information"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("location", map[string]interface{}{
"type": "string",
"description": "The city name",
}),
),
Required: []string{"location"},
},
},
},
},
},
},
expected: &bedrock.BedrockConverseRequest{
ModelID: "claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("What's the weather like in New York?"),
},
},
},
{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("I'll invoke get_weather tool to know the weather in New York."),
},
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: "tooluse_Yl388l8ES0G_3TQtDcKq_g",
Name: "get_weather",
Input: json.RawMessage(`{"location":"New York"}`),
},
},
},
},
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "tooluse_Yl388l8ES0G_3TQtDcKq_g",
Content: []bedrock.BedrockContentBlock{
{
JSON: mustMarshalJSON(map[string]any{
"results": []any{
any(map[string]any{"period": "now", "weather": "sunny"}),
any(map[string]any{"period": "next_1_hour", "weather": "cloudy"}),
},
}),
},
},
Status: schemas.Ptr("success"),
},
},
},
},
},
InferenceConfig: &bedrock.BedrockInferenceConfig{},
ToolConfig: &bedrock.BedrockToolConfig{
Tools: []bedrock.BedrockTool{
{
ToolSpec: &bedrock.BedrockToolSpec{
Name: "get_weather",
Description: schemas.Ptr("Get weather information"),
InputSchema: bedrock.BedrockToolInputSchema{
JSON: mustMarshalToolParams(&schemas.ToolFunctionParameters{
Type: "object",
Properties: &props,
Required: []string{"location"},
}),
},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
actual, err := bedrock.ToBedrockChatCompletionRequest(ctx, tt.input)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, actual)
if tt.input == nil {
assert.Contains(t, err.Error(), "nil")
}
} else {
require.NoError(t, err)
assertBedrockRequestEqual(t, tt.expected, actual)
}
})
}
}
// TestBedrockToBifrostRequestConversion tests the conversion from Bedrock request to Bifrost request
func TestBedrockToBifrostRequestConversion(t *testing.T) {
maxTokens := testMaxTokens
temp := testTemp
topP := testTopP
trace := testTrace
latency := testLatency
props := testProps
_ = props // used in input construction
// Build expected params via JSON round-trip so keyOrder and nested OrderedMap match
expectedParamsJSON := mustMarshalJSON(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{
"type": "string",
"description": "The city name",
},
},
"required": []string{"location"},
})
var expectedParams schemas.ToolFunctionParameters
_ = json.Unmarshal(expectedParamsJSON, &expectedParams)
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
tests := []struct {
name string
input *bedrock.BedrockConverseRequest
expected *schemas.BifrostResponsesRequest
wantErr bool
}{
{
name: "BasicTextMessage",
input: &bedrock.BedrockConverseRequest{
ModelID: "bedrock/claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello, world!"),
},
},
},
},
},
expected: &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "claude-3-sonnet",
Input: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Status: schemas.Ptr("completed"),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: schemas.Ptr("Hello, world!"),
},
},
},
},
},
Params: &schemas.ResponsesParameters{},
},
},
{
name: "SystemMessage",
input: &bedrock.BedrockConverseRequest{
ModelID: "bedrock/claude-3-sonnet",
System: []bedrock.BedrockSystemMessage{
{
Text: schemas.Ptr("You are a helpful assistant."),
},
},
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
},
expected: &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "claude-3-sonnet",
Input: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleSystem),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: schemas.Ptr("You are a helpful assistant."),
},
},
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Status: schemas.Ptr("completed"),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: schemas.Ptr("Hello!"),
},
},
},
},
},
Params: &schemas.ResponsesParameters{},
},
},
{
name: "InferenceParameters",
input: &bedrock.BedrockConverseRequest{
ModelID: "bedrock/claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
InferenceConfig: &bedrock.BedrockInferenceConfig{
MaxTokens: &maxTokens,
Temperature: &temp,
TopP: &topP,
},
},
expected: &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "claude-3-sonnet",
Input: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Status: schemas.Ptr("completed"),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: schemas.Ptr("Hello!"),
},
},
},
},
},
Params: &schemas.ResponsesParameters{
MaxOutputTokens: &maxTokens,
Temperature: &temp,
TopP: &topP,
},
},
},
{
name: "InferenceParametersWithStopSequences",
input: &bedrock.BedrockConverseRequest{
ModelID: "bedrock/claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
InferenceConfig: &bedrock.BedrockInferenceConfig{
MaxTokens: &maxTokens,
Temperature: &temp,
TopP: &topP,
StopSequences: testStop,
},
},
expected: &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "claude-3-sonnet",
Input: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Status: schemas.Ptr("completed"),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: schemas.Ptr("Hello!"),
},
},
},
},
},
Params: &schemas.ResponsesParameters{
MaxOutputTokens: &maxTokens,
Temperature: &temp,
TopP: &topP,
ExtraParams: map[string]interface{}{
"stop": testStop,
},
},
},
},
{
name: "Tools",
input: &bedrock.BedrockConverseRequest{
ModelID: "bedrock/claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("What's the weather?"),
},
},
},
},
ToolConfig: &bedrock.BedrockToolConfig{
Tools: []bedrock.BedrockTool{
{
ToolSpec: &bedrock.BedrockToolSpec{
Name: "get_weather",
Description: schemas.Ptr("Get weather information"),
InputSchema: bedrock.BedrockToolInputSchema{
JSON: mustMarshalJSON(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{
"type": "string",
"description": "The city name",
},
},
"required": []string{"location"},
}),
},
},
},
},
},
},
expected: &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "claude-3-sonnet",
Input: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Status: schemas.Ptr("completed"),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: schemas.Ptr("What's the weather?"),
},
},
},
},
},
Params: &schemas.ResponsesParameters{
Tools: []schemas.ResponsesTool{
{
Type: schemas.ResponsesToolTypeFunction,
Name: schemas.Ptr("get_weather"),
Description: schemas.Ptr("Get weather information"),
ResponsesToolFunction: &schemas.ResponsesToolFunction{
Parameters: &expectedParams,
},
},
},
},
},
},
{
name: "AllExtraParams",
input: &bedrock.BedrockConverseRequest{
ModelID: "bedrock/claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
GuardrailConfig: &bedrock.BedrockGuardrailConfig{
GuardrailIdentifier: "test-guardrail",
GuardrailVersion: "1",
Trace: &trace,
},
PerformanceConfig: &bedrock.BedrockPerformanceConfig{
Latency: &latency,
},
PromptVariables: map[string]bedrock.BedrockPromptVariable{
"username": {
Text: schemas.Ptr("John"),
},
},
RequestMetadata: map[string]string{
"user": "test-user",
},
AdditionalModelRequestFields: schemas.NewOrderedMapFromPairs(
schemas.KV("customField", "customValue"),
),
AdditionalModelResponseFieldPaths: []string{"field1", "field2"},
},
expected: &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "claude-3-sonnet",
Input: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Status: schemas.Ptr("completed"),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: schemas.Ptr("Hello!"),
},
},
},
},
},
Params: &schemas.ResponsesParameters{
ExtraParams: map[string]interface{}{
"guardrailConfig": map[string]interface{}{
"guardrailIdentifier": "test-guardrail",
"guardrailVersion": "1",
"trace": trace,
},
"performanceConfig": map[string]interface{}{
"latency": latency,
},
"promptVariables": map[string]interface{}{
"username": map[string]interface{}{
"text": "John",
},
},
"requestMetadata": map[string]string{
"user": "test-user",
},
"additionalModelRequestFieldPaths": schemas.NewOrderedMapFromPairs(
schemas.KV("customField", "customValue"),
),
"additionalModelResponseFieldPaths": []string{"field1", "field2"},
},
},
},
},
{
name: "MessageWithToolUse",
input: &bedrock.BedrockConverseRequest{
ModelID: "bedrock/claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: "tool-use-123",
Name: "get_weather",
Input: json.RawMessage(`{"location":"NYC"}`),
},
},
},
},
},
},
expected: &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "claude-3-sonnet",
Input: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
Status: schemas.Ptr("completed"),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("tool-use-123"),
Name: schemas.Ptr("get_weather"),
Arguments: schemas.Ptr(`{"location":"NYC"}`),
},
},
},
Params: &schemas.ResponsesParameters{},
},
},
{
name: "MessageWithToolResult",
input: &bedrock.BedrockConverseRequest{
ModelID: "bedrock/claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "tool-use-123",
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("The weather in NYC is sunny, 72°F"),
},
},
},
},
},
},
},
},
expected: &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "claude-3-sonnet",
Input: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("tool-use-123"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr("The weather in NYC is sunny, 72°F"),
},
},
},
},
Params: &schemas.ResponsesParameters{},
},
},
{
name: "MessageWithBothToolUseAndToolResult",
input: &bedrock.BedrockConverseRequest{
ModelID: "bedrock/claude-3-sonnet",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: "tool-use-456",
Name: "calculate",
Input: json.RawMessage(`{"expression":"2+2"}`),
},
},
},
},
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "tool-use-456",
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("4"),
},
},
},
},
},
},
},
},
expected: &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "claude-3-sonnet",
Input: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
Status: schemas.Ptr("completed"),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("tool-use-456"),
Name: schemas.Ptr("calculate"),
Arguments: schemas.Ptr(`{"expression":"2+2"}`),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("tool-use-456"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr("4"),
},
},
},
},
Params: &schemas.ResponsesParameters{},
},
},
{
name: "NilRequest",
input: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var actual *schemas.BifrostResponsesRequest
var err error
if tt.input == nil {
var bedrockReq *bedrock.BedrockConverseRequest
actual, err = bedrockReq.ToBifrostResponsesRequest(ctx)
} else {
actual, err = tt.input.ToBifrostResponsesRequest(ctx)
}
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, actual)
if tt.input == nil {
assert.Contains(t, err.Error(), "nil")
}
} else {
require.NoError(t, err)
assert.Equal(t, tt.expected, actual)
}
})
}
}
// TestBifrostToBedrockResponseConversion tests the conversion from Bifrost Responses response to Bedrock response
func TestBifrostToBedrockResponseConversion(t *testing.T) {
inputTokens := 10
outputTokens := 20
totalTokens := 30
latency := int64(100)
callID := "call-123"
toolName := "get_weather"
arguments := `{"location":"NYC"}`
reason := "max_tokens"
tests := []struct {
name string
input *schemas.BifrostResponsesResponse
expected *bedrock.BedrockConverseResponse
wantErr bool
}{
{
name: "BasicTextResponse",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Hello, world!"),
},
},
},
},
},
// IncompleteDetails is nil, so should default to "end_turn"
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "end_turn", // Default stop reason when IncompleteDetails is nil
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello, world!"),
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithUsage",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Hello!"),
},
},
},
},
},
Usage: &schemas.ResponsesResponseUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
TotalTokens: totalTokens,
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "end_turn",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
TotalTokens: totalTokens,
},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithToolUse",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: &callID,
Name: &toolName,
Arguments: &arguments,
},
},
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "tool_use",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: callID,
Name: toolName,
Input: json.RawMessage(`{"location":"NYC"}`),
},
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithToolUseInvalidJSON",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: &callID,
Name: &toolName,
Arguments: schemas.Ptr("invalid json {"),
},
},
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "tool_use",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: callID,
Name: toolName,
Input: json.RawMessage("invalid json {"), // Should fallback to raw string
},
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithToolUseNilArguments",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: &callID,
Name: &toolName,
Arguments: nil,
},
},
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "tool_use",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: callID,
Name: toolName,
Input: json.RawMessage("{}"), // Should default to empty map
},
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithMetrics",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Hello!"),
},
},
},
},
},
ExtraFields: schemas.BifrostResponseExtraFields{
Latency: latency,
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "end_turn",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{
LatencyMs: latency,
},
},
},
{
name: "ResponseWithIncompleteDetails",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Hello!"),
},
},
},
},
},
IncompleteDetails: &schemas.ResponsesResponseIncompleteDetails{
Reason: reason, // This should be used as stop reason instead of default "end_turn"
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: reason, // Should use IncompleteDetails.Reason when present
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithToolResultString",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call-123"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr("Tool result text"),
},
},
},
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "end_turn",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "call-123",
Status: schemas.Ptr("success"),
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Tool result text"),
},
},
},
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithToolResultJSON",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call-456"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr(`{"temperature": 72, "location": "NYC"}`),
},
},
},
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "end_turn",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "call-456",
Status: schemas.Ptr("success"),
Content: []bedrock.BedrockContentBlock{
{
JSON: mustMarshalJSON(map[string]interface{}{
"temperature": float64(72),
"location": "NYC",
}),
},
},
},
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithToolResultContentBlocks",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call-789"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesFunctionToolCallOutputBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Result from tool"),
},
},
},
},
},
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "end_turn",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "call-789",
Status: schemas.Ptr("success"),
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Result from tool"),
},
},
},
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithToolUseAndToolResult",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call-111"),
Name: schemas.Ptr("get_weather"),
Arguments: schemas.Ptr(`{"location": "NYC"}`),
},
},
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call-111"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr(`{"temperature": 72}`),
},
},
},
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: "tool_use",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: "call-111",
Name: "get_weather",
Input: json.RawMessage(`{"location":"NYC"}`),
},
},
{
ToolResult: &bedrock.BedrockToolResult{
ToolUseID: "call-111",
Status: schemas.Ptr("success"),
Content: []bedrock.BedrockContentBlock{
{
JSON: mustMarshalJSON(map[string]interface{}{
"temperature": float64(72),
}),
},
},
},
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "ResponseWithToolUseAndIncompleteDetails",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: &callID,
Name: &toolName,
Arguments: &arguments,
},
},
},
IncompleteDetails: &schemas.ResponsesResponseIncompleteDetails{
Reason: reason, // IncompleteDetails should take priority over tool_use
},
},
expected: &bedrock.BedrockConverseResponse{
StopReason: reason, // Should use IncompleteDetails.Reason even when tool use is present
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: callID,
Name: toolName,
Input: json.RawMessage(`{"location":"NYC"}`),
},
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{},
Metrics: &bedrock.BedrockConverseMetrics{},
},
},
{
name: "NilResponse",
input: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual, err := bedrock.ToBedrockConverseResponse(tt.input)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, actual)
if tt.input == nil {
assert.Contains(t, err.Error(), "nil")
}
} else {
require.NoError(t, err)
// Compare structure instead of exact equality since IDs may be generated
if tt.expected != nil && actual != nil {
assert.Equal(t, tt.expected.StopReason, actual.StopReason)
assert.Equal(t, tt.expected.Output.Message.Role, actual.Output.Message.Role)
assert.Equal(t, len(tt.expected.Output.Message.Content), len(actual.Output.Message.Content))
if tt.expected.Usage != nil {
assert.Equal(t, tt.expected.Usage.InputTokens, actual.Usage.InputTokens)
assert.Equal(t, tt.expected.Usage.OutputTokens, actual.Usage.OutputTokens)
assert.Equal(t, tt.expected.Usage.TotalTokens, actual.Usage.TotalTokens)
}
if tt.expected.Metrics != nil {
assert.Equal(t, tt.expected.Metrics.LatencyMs, actual.Metrics.LatencyMs)
}
} else {
assert.Equal(t, tt.expected, actual)
}
}
})
}
}
// TestBedrockToBifrostResponseConversion tests the conversion from Bedrock response to Bifrost Responses response
func TestBedrockToBifrostResponseConversion(t *testing.T) {
inputTokens := 10
outputTokens := 20
totalTokens := 30
toolUseID := "call-123"
toolName := "get_weather"
toolInput := json.RawMessage(`{"location":"NYC"}`)
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
tests := []struct {
name string
input *bedrock.BedrockConverseResponse
expected *schemas.BifrostResponsesResponse
wantErr bool
}{
{
name: "BasicTextResponse",
input: &bedrock.BedrockConverseResponse{
StopReason: "end_turn",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello, world!"),
},
},
},
},
},
expected: &schemas.BifrostResponsesResponse{
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Status: schemas.Ptr("completed"),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Hello, world!"),
ResponsesOutputMessageContentText: &schemas.ResponsesOutputMessageContentText{
Annotations: []schemas.ResponsesOutputMessageContentTextAnnotation{},
LogProbs: []schemas.ResponsesOutputMessageContentTextLogProb{},
},
},
},
},
},
},
},
},
{
name: "ResponseWithUsage",
input: &bedrock.BedrockConverseResponse{
StopReason: "end_turn",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello!"),
},
},
},
},
Usage: &bedrock.BedrockTokenUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
TotalTokens: totalTokens,
},
},
expected: &schemas.BifrostResponsesResponse{
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Status: schemas.Ptr("completed"),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Hello!"),
ResponsesOutputMessageContentText: &schemas.ResponsesOutputMessageContentText{
Annotations: []schemas.ResponsesOutputMessageContentTextAnnotation{},
LogProbs: []schemas.ResponsesOutputMessageContentTextLogProb{},
},
},
},
},
},
},
Usage: &schemas.ResponsesResponseUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
TotalTokens: totalTokens,
},
},
},
{
name: "ResponseWithToolUse",
input: &bedrock.BedrockConverseResponse{
StopReason: "end_turn",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
ToolUse: &bedrock.BedrockToolUse{
ToolUseID: toolUseID,
Name: toolName,
Input: toolInput,
},
},
},
},
},
},
expected: &schemas.BifrostResponsesResponse{
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Status: schemas.Ptr("completed"),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: &toolUseID,
Name: &toolName,
Arguments: schemas.Ptr(string(toolInput)),
},
},
},
},
},
{
name: "NilResponse",
input: nil,
wantErr: true,
},
{
name: "EmptyOutput",
input: &bedrock.BedrockConverseResponse{
StopReason: "end_turn",
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{},
},
},
},
expected: &schemas.BifrostResponsesResponse{
Output: nil, // Empty content blocks result in nil output
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var actual *schemas.BifrostResponsesResponse
var err error
if tt.input == nil {
var bedrockResp *bedrock.BedrockConverseResponse
actual, err = bedrockResp.ToBifrostResponsesResponse(ctx)
} else {
actual, err = tt.input.ToBifrostResponsesResponse(ctx)
}
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, actual)
if tt.input == nil {
assert.Contains(t, err.Error(), "nil")
}
} else {
require.NoError(t, err)
// Note: CreatedAt and IDs are set at runtime, so compare structure instead
if actual != nil {
assert.Greater(t, actual.CreatedAt, 0)
actual.CreatedAt = tt.expected.CreatedAt
// For output messages, IDs are generated, so we need to compare by value not identity
if len(actual.Output) > 0 && len(tt.expected.Output) > 0 {
assert.Equal(t, len(tt.expected.Output), len(actual.Output))
for i := range actual.Output {
assert.Equal(t, tt.expected.Output[i].Type, actual.Output[i].Type)
assert.Equal(t, tt.expected.Output[i].Role, actual.Output[i].Role)
assert.Equal(t, tt.expected.Output[i].Status, actual.Output[i].Status)
if tt.expected.Output[i].ResponsesToolMessage != nil {
assert.NotNil(t, actual.Output[i].ResponsesToolMessage)
require.NotNil(t, actual.Output[i].ResponsesToolMessage.Name)
require.NotNil(t, actual.Output[i].ResponsesToolMessage.CallID)
require.NotNil(t, actual.Output[i].ResponsesToolMessage.Arguments)
assert.Equal(t, *tt.expected.Output[i].ResponsesToolMessage.Name, *actual.Output[i].ResponsesToolMessage.Name)
assert.Equal(t, *tt.expected.Output[i].ResponsesToolMessage.CallID, *actual.Output[i].ResponsesToolMessage.CallID)
assert.Equal(t, *tt.expected.Output[i].ResponsesToolMessage.Arguments, *actual.Output[i].ResponsesToolMessage.Arguments)
}
if tt.expected.Output[i].Content != nil {
assert.Equal(t, tt.expected.Output[i].Content, actual.Output[i].Content)
}
}
}
// Compare usage if present
if tt.expected.Usage != nil {
assert.NotNil(t, actual.Usage)
assert.Equal(t, tt.expected.Usage.InputTokens, actual.Usage.InputTokens)
assert.Equal(t, tt.expected.Usage.OutputTokens, actual.Usage.OutputTokens)
assert.Equal(t, tt.expected.Usage.TotalTokens, actual.Usage.TotalTokens)
}
}
}
})
}
}
func TestToBedrockResponsesRequest_AdditionalFields(t *testing.T) {
req := &schemas.BifrostResponsesRequest{
Model: "bedrock/anthropic.claude-3-sonnet-20240229-v1:0",
Params: &schemas.ResponsesParameters{
ExtraParams: map[string]interface{}{
"additionalModelRequestFieldPaths": map[string]interface{}{
"top_k": 200,
},
"additionalModelResponseFieldPaths": []string{
"/amazon-bedrock-invocationMetrics/inputTokenCount",
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
bedrockReq, err := bedrock.ToBedrockResponsesRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, bedrockReq)
// Convert OrderedMap to map[string]interface{} for comparison
expectedFields := map[string]interface{}{"top_k": 200}
actualFields := bedrockReq.AdditionalModelRequestFields.ToMap()
assert.Equal(t, expectedFields, actualFields)
assert.Equal(t, []string{"/amazon-bedrock-invocationMetrics/inputTokenCount"}, bedrockReq.AdditionalModelResponseFieldPaths)
}
func TestToBedrockResponsesRequest_AdditionalFields_InterfaceSlice(t *testing.T) {
req := &schemas.BifrostResponsesRequest{
Model: "bedrock/anthropic.claude-3-sonnet-20240229-v1:0",
Params: &schemas.ResponsesParameters{
ExtraParams: map[string]interface{}{
"additionalModelResponseFieldPaths": []interface{}{
"/amazon-bedrock-invocationMetrics/inputTokenCount",
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
bedrockReq, err := bedrock.ToBedrockResponsesRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, bedrockReq)
assert.Equal(t, []string{"/amazon-bedrock-invocationMetrics/inputTokenCount"}, bedrockReq.AdditionalModelResponseFieldPaths)
}
func TestToBedrockResponsesRequest_AnthropicTextFormatUsesOutputConfig(t *testing.T) {
schemaObj := any(schemas.NewOrderedMapFromPairs(
schemas.KV("type", "object"),
schemas.KV("properties", schemas.NewOrderedMapFromPairs(
schemas.KV("topic", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
)),
)),
schemas.KV("required", []string{"topic"}),
))
req := &schemas.BifrostResponsesRequest{
Model: "bedrock/anthropic.claude-3-sonnet-20240229-v1:0",
Params: &schemas.ResponsesParameters{
Text: &schemas.ResponsesTextConfig{
Format: &schemas.ResponsesTextConfigFormat{
Type: "json_schema",
Name: schemas.Ptr("classification"),
JSONSchema: &schemas.ResponsesTextConfigFormatJSONSchema{
Schema: &schemaObj,
},
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
bedrockReq, err := bedrock.ToBedrockResponsesRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, bedrockReq)
require.NotNil(t, bedrockReq.AdditionalModelRequestFields, "expected additional model request fields for anthropic responses structured output")
outputConfigRaw, hasOutputConfig := bedrockReq.AdditionalModelRequestFields.Get("output_config")
require.True(t, hasOutputConfig, "expected output_config for anthropic responses structured output")
outputConfig, ok := schemas.SafeExtractOrderedMap(outputConfigRaw)
require.True(t, ok, "expected output_config to be an ordered map")
formatRaw, hasFormat := outputConfig.Get("format")
require.True(t, hasFormat, "expected output_config.format")
formatMap, ok := schemas.SafeExtractOrderedMap(formatRaw)
require.True(t, ok, "expected output_config.format to be an ordered map")
formatType, ok := formatMap.Get("type")
require.True(t, ok, "expected output_config.format.type")
assert.Equal(t, "json_schema", formatType)
schemaRaw, ok := formatMap.Get("schema")
require.True(t, ok, "expected output_config.format.schema")
schemaMap, ok := schemas.SafeExtractOrderedMap(schemaRaw)
require.True(t, ok, "expected output_config.format.schema to remain ordered")
require.NotNil(t, schemaMap)
if bedrockReq.ToolConfig != nil {
assert.Nil(t, bedrockReq.ToolConfig.ToolChoice, "expected no forced tool choice for anthropic responses structured output")
assert.Empty(t, bedrockReq.ToolConfig.Tools, "expected no synthetic structured output tool for anthropic responses structured output")
}
}
func TestToBedrockResponsesRequest_NonAnthropicTextFormatStillUsesToolConversion(t *testing.T) {
schemaObj := any(schemas.NewOrderedMapFromPairs(
schemas.KV("type", "object"),
schemas.KV("properties", schemas.NewOrderedMapFromPairs(
schemas.KV("topic", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
)),
)),
schemas.KV("required", []string{"topic"}),
))
req := &schemas.BifrostResponsesRequest{
Model: "bedrock/amazon.nova-pro-v1:0",
Params: &schemas.ResponsesParameters{
Text: &schemas.ResponsesTextConfig{
Format: &schemas.ResponsesTextConfigFormat{
Type: "json_schema",
Name: schemas.Ptr("classification"),
JSONSchema: &schemas.ResponsesTextConfigFormatJSONSchema{
Schema: &schemaObj,
},
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
bedrockReq, err := bedrock.ToBedrockResponsesRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, bedrockReq)
if bedrockReq.AdditionalModelRequestFields != nil {
_, hasOutputConfig := bedrockReq.AdditionalModelRequestFields.Get("output_config")
assert.False(t, hasOutputConfig, "expected no output_config for non-anthropic responses structured output")
}
require.NotNil(t, bedrockReq.ToolConfig, "expected tool_config for non-anthropic responses structured output")
require.NotEmpty(t, bedrockReq.ToolConfig.Tools, "expected synthetic structured output tool to be added")
require.NotNil(t, bedrockReq.ToolConfig.ToolChoice, "expected structured output tool choice to be forced")
require.NotNil(t, bedrockReq.ToolConfig.ToolChoice.Tool, "expected structured output tool choice to target the synthetic tool")
assert.Contains(t, bedrockReq.ToolConfig.ToolChoice.Tool.Name, "bf_so_", "expected forced tool choice to target the synthetic structured output tool")
}
func TestToBedrockResponsesRequest_NonAnthropicTextFormatPreservedWithUserTools(t *testing.T) {
schemaObj := any(schemas.NewOrderedMapFromPairs(
schemas.KV("type", "object"),
schemas.KV("properties", schemas.NewOrderedMapFromPairs(
schemas.KV("topic", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
)),
)),
schemas.KV("required", []string{"topic"}),
))
toolParams := schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("city", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
)),
),
}
req := &schemas.BifrostResponsesRequest{
Model: "bedrock/amazon.nova-pro-v1:0",
Params: &schemas.ResponsesParameters{
Text: &schemas.ResponsesTextConfig{
Format: &schemas.ResponsesTextConfigFormat{
Type: "json_schema",
Name: schemas.Ptr("classification"),
JSONSchema: &schemas.ResponsesTextConfigFormatJSONSchema{
Schema: &schemaObj,
},
},
},
Tools: []schemas.ResponsesTool{
{
Type: schemas.ResponsesToolTypeFunction,
Name: schemas.Ptr("get_weather"),
Description: schemas.Ptr("Get weather information"),
ResponsesToolFunction: &schemas.ResponsesToolFunction{
Parameters: &toolParams,
},
},
},
ToolChoice: &schemas.ResponsesToolChoice{
ResponsesToolChoiceStruct: &schemas.ResponsesToolChoiceStruct{
Type: schemas.ResponsesToolChoiceTypeFunction,
Name: schemas.Ptr("get_weather"),
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
bedrockReq, err := bedrock.ToBedrockResponsesRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, bedrockReq)
require.NotNil(t, bedrockReq.ToolConfig, "expected tool_config to be initialized")
require.Len(t, bedrockReq.ToolConfig.Tools, 2, "expected synthetic structured output tool plus user tool")
require.NotNil(t, bedrockReq.ToolConfig.ToolChoice, "expected structured output tool choice to be forced")
require.NotNil(t, bedrockReq.ToolConfig.ToolChoice.Tool, "expected structured output tool choice to target the synthetic tool")
assert.Equal(t, "bf_so_classification", bedrockReq.ToolConfig.ToolChoice.Tool.Name)
assert.Equal(t, "bf_so_classification", bedrockReq.ToolConfig.Tools[0].ToolSpec.Name)
assert.Equal(t, "get_weather", bedrockReq.ToolConfig.Tools[1].ToolSpec.Name)
}
// TestToolResultJSONParsingResponsesAPI tests that tool results are correctly parsed and wrapped based on JSON type
// Tests only Responses API.
func TestToolResultJSONParsingResponsesAPI(t *testing.T) {
tests := []struct {
name string
toolResultContent string
expectedContentType string // "text" or "json"
expectedJSON json.RawMessage
expectedText *string
}{
{
name: "PlainTextResult",
toolResultContent: "Hello there! This is plain text, not JSON.",
expectedContentType: "text",
expectedText: schemas.Ptr("Hello there! This is plain text, not JSON."),
},
{
name: "InvalidJSONResult",
toolResultContent: "{invalid json syntax",
expectedContentType: "text",
expectedText: schemas.Ptr("{invalid json syntax"),
},
{
name: "JSONObjectResult",
toolResultContent: `{"location":"NYC","temperature":72}`,
expectedContentType: "json",
expectedJSON: mustMarshalJSON(map[string]any{"location": "NYC", "temperature": float64(72)}),
},
{
name: "JSONArrayResult",
toolResultContent: `[{"period":"now","weather":"sunny"},{"period":"next_1_hour","weather":"cloudy"}]`,
expectedContentType: "json",
expectedJSON: mustMarshalJSON(map[string]any{
"results": []any{
map[string]any{"period": "now", "weather": "sunny"},
map[string]any{"period": "next_1_hour", "weather": "cloudy"},
},
}),
},
{
name: "JSONPrimitiveNumberResult",
toolResultContent: `42`,
expectedContentType: "json",
expectedJSON: mustMarshalJSON(map[string]any{"value": float64(42)}),
},
{
name: "JSONPrimitiveStringResult",
toolResultContent: `"hello world"`,
expectedContentType: "json",
expectedJSON: mustMarshalJSON(map[string]any{"value": "hello world"}),
},
{
name: "JSONPrimitiveBooleanResult",
toolResultContent: `true`,
expectedContentType: "json",
expectedJSON: mustMarshalJSON(map[string]any{"value": true}),
},
{
name: "JSONPrimitiveNullResult",
toolResultContent: `null`,
expectedContentType: "json",
expectedJSON: mustMarshalJSON(map[string]any{"value": nil}),
},
{
name: "EmptyJSONObjectResult",
toolResultContent: `{}`,
expectedContentType: "json",
expectedJSON: mustMarshalJSON(map[string]any{}),
},
{
name: "EmptyJSONArrayResult",
toolResultContent: `[]`,
expectedContentType: "json",
expectedJSON: mustMarshalJSON(map[string]any{"results": []any{}}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a Responses API message with function call output (tool result)
input := []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("tooluse_test_123"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: schemas.Ptr(tt.toolResultContent),
},
},
},
}
messages, _, err := bedrock.ConvertBifrostMessagesToBedrockMessages(input)
require.NoError(t, err)
require.Len(t, messages, 1)
// The tool result should be in a user message
toolResultMsg := messages[0]
assert.Equal(t, bedrock.BedrockMessageRoleUser, toolResultMsg.Role)
require.Len(t, toolResultMsg.Content, 1)
toolResult := toolResultMsg.Content[0].ToolResult
require.NotNil(t, toolResult)
assert.Equal(t, "tooluse_test_123", toolResult.ToolUseID)
require.Len(t, toolResult.Content, 1)
resultContent := toolResult.Content[0]
if tt.expectedContentType == "text" {
assert.NotNil(t, resultContent.Text, "Expected text content")
assert.Nil(t, resultContent.JSON, "Expected no JSON content")
assert.Equal(t, tt.expectedText, resultContent.Text)
} else {
assert.Nil(t, resultContent.Text, "Expected no text content")
assert.Equal(t, tt.expectedJSON, resultContent.JSON)
}
})
}
}
// TestConvertBifrostResponsesMessageContentBlocksToBedrockContentBlocks_EmptyBlocks tests that
// empty ContentBlocks are not created when required fields are missing, preventing the Bedrock API error:
// "ContentBlock object at messages.1.content.0 must set one of the following keys: text, image, toolUse, toolResult, document, video, cachePoint, reasoningContent, citationsContent, searchResult."
func TestConvertBifrostResponsesMessageContentBlocksToBedrockContentBlocks_EmptyBlocks(t *testing.T) {
tests := []struct {
name string
input *schemas.BifrostResponsesResponse
expectedBlocks int // Expected number of ContentBlocks in the output
description string
}{
{
name: "ImageBlockWithNilImageURL_ShouldNotCreateEmptyBlock",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeImage,
ResponsesInputMessageContentBlockImage: &schemas.ResponsesInputMessageContentBlockImage{
ImageURL: nil, // Missing ImageURL - should not create empty block
},
},
},
},
},
},
},
expectedBlocks: 0,
description: "Image block with nil ImageURL should not create an empty ContentBlock",
},
{
name: "ImageBlockWithNilImageBlock_ShouldNotCreateEmptyBlock",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeImage,
ResponsesInputMessageContentBlockImage: nil, // Missing image block - should not create empty block
},
},
},
},
},
},
expectedBlocks: 0,
description: "Image block with nil ResponsesInputMessageContentBlockImage should not create an empty ContentBlock",
},
{
name: "ReasoningBlockWithNilText_ShouldNotCreateEmptyBlock",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeReasoning,
Text: nil, // Missing Text - should not create empty block
},
},
},
},
},
},
expectedBlocks: 0,
description: "Reasoning block with nil Text should not create an empty ContentBlock",
},
{
name: "FileBlockWithNilFileData_ShouldNotCreateEmptyBlock",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeFile,
ResponsesInputMessageContentBlockFile: &schemas.ResponsesInputMessageContentBlockFile{
FileData: nil, // Missing FileData - should not create empty block
Filename: schemas.Ptr("test.pdf"),
FileType: schemas.Ptr("application/pdf"),
},
},
},
},
},
},
},
expectedBlocks: 0,
description: "File block with nil FileData should not create an empty ContentBlock",
},
{
name: "FileBlockWithNilFileBlock_ShouldNotCreateEmptyBlock",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeFile,
ResponsesInputMessageContentBlockFile: nil, // Missing file block - should not create empty block
},
},
},
},
},
},
expectedBlocks: 0,
description: "File block with nil ResponsesInputMessageContentBlockFile should not create an empty ContentBlock",
},
{
name: "ValidTextBlock_ShouldCreateBlock",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Valid text content"),
},
},
},
},
},
},
expectedBlocks: 1,
description: "Valid text block should create a ContentBlock",
},
{
name: "ValidReasoningBlock_ShouldCreateBlock",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
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("Valid reasoning content"),
},
},
},
},
},
},
expectedBlocks: 1,
description: "Valid reasoning block should create a ContentBlock",
},
{
name: "ValidFileBlock_ShouldCreateBlock",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeFile,
ResponsesInputMessageContentBlockFile: &schemas.ResponsesInputMessageContentBlockFile{
FileData: schemas.Ptr("dGVzdCBmaWxlIGRhdGE="), // base64 encoded "test file data"
Filename: schemas.Ptr("test.pdf"),
FileType: schemas.Ptr("application/pdf"),
},
},
},
},
},
},
},
expectedBlocks: 1,
description: "Valid file block should create a ContentBlock",
},
{
name: "MixedValidAndInvalidBlocks_ShouldOnlyCreateValidBlocks",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Valid text"),
},
{
Type: schemas.ResponsesInputMessageContentBlockTypeImage,
ResponsesInputMessageContentBlockImage: nil, // Invalid - should be skipped
},
{
Type: schemas.ResponsesOutputMessageContentTypeReasoning,
Text: schemas.Ptr("Valid reasoning"),
},
{
Type: schemas.ResponsesInputMessageContentBlockTypeFile,
ResponsesInputMessageContentBlockFile: &schemas.ResponsesInputMessageContentBlockFile{
FileData: nil, // Invalid - should be skipped
},
},
},
},
},
},
},
expectedBlocks: 2, // Only valid text and reasoning blocks
description: "Mixed valid and invalid blocks should only create valid ContentBlocks",
},
{
name: "CacheControlBlock_ShouldCreateCachePointBlock",
input: &schemas.BifrostResponsesResponse{
CreatedAt: 1234567890,
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Text with cache control"),
CacheControl: &schemas.CacheControl{
Type: schemas.CacheControlTypeEphemeral,
},
},
},
},
},
},
},
expectedBlocks: 2, // Text block + CachePoint block
description: "ContentBlock with CacheControl should create both content and CachePoint blocks",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual, err := bedrock.ToBedrockConverseResponse(tt.input)
require.NoError(t, err, "Conversion should not error")
require.NotNil(t, actual, "Response should not be nil")
require.NotNil(t, actual.Output, "Output should not be nil")
require.NotNil(t, actual.Output.Message, "Message should not be nil")
actualBlocks := len(actual.Output.Message.Content)
assert.Equal(t, tt.expectedBlocks, actualBlocks, tt.description)
// Verify that all created blocks have at least one required field set
for i, block := range actual.Output.Message.Content {
hasRequiredField := block.Text != nil ||
block.Image != nil ||
block.Document != nil ||
block.ToolUse != nil ||
block.ToolResult != nil ||
block.ReasoningContent != nil ||
block.CachePoint != nil ||
block.JSON != nil ||
block.GuardContent != nil
assert.True(t, hasRequiredField,
"ContentBlock at index %d must have at least one required field set (text, image, toolUse, toolResult, document, video, cachePoint, reasoningContent, citationsContent, searchResult)",
i)
}
})
}
}
// TestToolResultDeduplication tests that duplicate tool results are properly handled
func TestToolResultDeduplication(t *testing.T) {
t.Run("DuplicateResultInPendingResults", func(t *testing.T) {
manager := bedrock.NewToolCallStateManager()
// tool call and result
manager.RegisterToolCall("call-123", "get_weather", `{"location":"NYC"}`, nil)
content1 := []bedrock.BedrockContentBlock{{Text: schemas.Ptr("First result")}}
manager.RegisterToolResult("call-123", content1, "success", nil)
// duplicate result with different content
content2 := []bedrock.BedrockContentBlock{{Text: schemas.Ptr("Duplicate result")}}
manager.RegisterToolResult("call-123", content2, "success", nil)
// Deduplicated regardless of content. Practically same ID should not ever has diff content.
results := manager.GetPendingResults()
require.Len(t, results, 1)
require.NotNil(t, results["call-123"])
assert.Equal(t, "First result", *results["call-123"].Content[0].Text)
})
t.Run("DuplicateResultAfterEmission", func(t *testing.T) {
manager := bedrock.NewToolCallStateManager()
// Register and emit a tool call
manager.RegisterToolCall("call-456", "calculate", `{"x":1,"y":2}`, nil)
callIDs := manager.EmitPendingToolCalls()
require.Len(t, callIDs, 1)
manager.MarkToolCallsEmitted(callIDs, 0)
// register and emit the result
content1 := []bedrock.BedrockContentBlock{{Text: schemas.Ptr("3")}}
manager.RegisterToolResult("call-456", content1, "success", nil)
manager.MarkResultsEmitted([]string{"call-456"})
// Register a duplicate
content2 := []bedrock.BedrockContentBlock{{Text: schemas.Ptr("Duplicate")}}
manager.RegisterToolResult("call-456", content2, "success", nil)
// Not added due to it being duplicated with the emitted result
results := manager.GetPendingResults()
assert.Empty(t, results)
})
t.Run("MultipleToolCallsWithDuplicateResults", func(t *testing.T) {
manager := bedrock.NewToolCallStateManager()
// Register multiple tool calls
manager.RegisterToolCall("call-a", "tool_a", `{}`, nil)
manager.RegisterToolCall("call-b", "tool_b", `{}`, nil)
// Register results for both
contentA := []bedrock.BedrockContentBlock{{Text: schemas.Ptr("Result A")}}
contentB := []bedrock.BedrockContentBlock{{Text: schemas.Ptr("Result B")}}
manager.RegisterToolResult("call-a", contentA, "success", nil)
manager.RegisterToolResult("call-b", contentB, "success", nil)
// Try to register duplicates
contentADup := []bedrock.BedrockContentBlock{{Text: schemas.Ptr("Result A")}}
contentBDup := []bedrock.BedrockContentBlock{{Text: schemas.Ptr("Result B")}}
manager.RegisterToolResult("call-a", contentADup, "success", nil)
manager.RegisterToolResult("call-b", contentBDup, "success", nil)
// Verify original results are preserved
results := manager.GetPendingResults()
require.Len(t, results, 2)
assert.Equal(t, "Result A", *results["call-a"].Content[0].Text)
assert.Equal(t, "Result B", *results["call-b"].Content[0].Text)
})
}
// TestToolCallDeduplication tests that duplicate tool calls are properly handled
func TestToolCallDeduplication(t *testing.T) {
t.Run("DuplicateToolCallIgnored", func(t *testing.T) {
manager := bedrock.NewToolCallStateManager()
manager.RegisterToolCall("call-123", "get_weather", `{"location":"NYC"}`, nil)
manager.RegisterToolCall("call-123", "get_weather", `{"location":"NYC"}`, nil)
// Deduplicated regardless of content.
callIDs := manager.EmitPendingToolCalls()
require.Len(t, callIDs, 1)
assert.Equal(t, "call-123", callIDs[0])
})
t.Run("MultipleDistinctToolCalls", func(t *testing.T) {
manager := bedrock.NewToolCallStateManager()
// initial registration
manager.RegisterToolCall("call-a", "tool_a", `{"x":1}`, nil)
manager.RegisterToolCall("call-b", "tool_b", `{"y":2}`, nil)
manager.RegisterToolCall("call-c", "tool_c", `{"z":3}`, nil)
// duplications
manager.RegisterToolCall("call-a", "tool_a", `{"x":1}`, nil)
manager.RegisterToolCall("call-b", "tool_b", `{"y":2}`, nil)
manager.RegisterToolCall("call-c", "tool_c", `{"z":3}`, nil)
// no duplicates
callIDs := manager.EmitPendingToolCalls()
require.Len(t, callIDs, 3)
assert.Contains(t, callIDs, "call-a")
assert.Contains(t, callIDs, "call-b")
assert.Contains(t, callIDs, "call-c")
})
t.Run("DuplicateToolCallAfterEmission", func(t *testing.T) {
manager := bedrock.NewToolCallStateManager()
// register and emit a tool call
manager.RegisterToolCall("call-789", "calculator", `{"expr":"1+1"}`, nil)
callIDs := manager.EmitPendingToolCalls()
require.Len(t, callIDs, 1)
manager.MarkToolCallsEmitted(callIDs, 0)
// register the same tool call again after emission
manager.RegisterToolCall("call-789", "calculator", `{"expr":"1+1"}`, nil)
// duplicate was rejected
newCallIDs := manager.EmitPendingToolCalls()
assert.Empty(t, newCallIDs)
})
}
// TestAnthropicReasoningConfigUsesThinkinField verifies that Anthropic models use
// the "thinking" field (not "reasoning_config") in additionalModelRequestFields
// for the Bedrock Converse API.
func TestAnthropicReasoningConfigUsesThinkingField(t *testing.T) {
tests := []struct {
name string
model string
effort *string
maxTokens *int
expectedFieldName string
expectedType string
expectBudgetTokens bool
expectNoOutputConfig bool
expectOutputConfigEffort string // expected effort value in output_config (empty string means no output_config expected)
}{
{
name: "Opus4.6_AdaptiveThinking_UsesThinkingField",
model: "anthropic.claude-opus-4-6-v1",
effort: schemas.Ptr("high"),
expectedFieldName: "thinking",
expectedType: "adaptive",
expectBudgetTokens: false,
expectNoOutputConfig: false,
expectOutputConfigEffort: "high",
},
{
name: "Opus4.5_NativeEffort_UsesThinkingField",
model: "anthropic.claude-opus-4-5-v1",
effort: schemas.Ptr("high"),
expectedFieldName: "thinking",
expectedType: "enabled",
expectBudgetTokens: true,
expectNoOutputConfig: true,
},
{
name: "Sonnet3.7_OlderModel_UsesThinkingField",
model: "anthropic.claude-3-7-sonnet-v1",
effort: schemas.Ptr("medium"),
expectedFieldName: "thinking",
expectedType: "enabled",
expectBudgetTokens: true,
expectNoOutputConfig: true,
},
{
name: "Anthropic_MaxTokens_UsesThinkingField",
model: "anthropic.claude-3-7-sonnet-v1",
maxTokens: schemas.Ptr(2048),
expectedFieldName: "thinking",
expectedType: "enabled",
expectBudgetTokens: true,
expectNoOutputConfig: true,
},
{
name: "Anthropic_DisabledReasoning_UsesThinkingField",
model: "anthropic.claude-3-7-sonnet-v1",
effort: schemas.Ptr("none"),
expectedFieldName: "thinking",
expectedType: "disabled",
expectBudgetTokens: false,
expectNoOutputConfig: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reasoning := &schemas.ChatReasoning{}
if tt.effort != nil {
reasoning.Effort = tt.effort
}
if tt.maxTokens != nil {
reasoning.MaxTokens = tt.maxTokens
}
bifrostReq := &schemas.BifrostChatRequest{
Model: tt.model,
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello"),
},
},
},
Params: &schemas.ChatParameters{
Reasoning: reasoning,
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.AdditionalModelRequestFields)
// Verify the correct field name is used
thinkingConfig, hasThinking := result.AdditionalModelRequestFields.Get(tt.expectedFieldName)
assert.True(t, hasThinking, "expected field %q in AdditionalModelRequestFields", tt.expectedFieldName)
// Verify reasoning_config is NOT used for Anthropic models
_, hasReasoningConfig := result.AdditionalModelRequestFields.Get("reasoning_config")
assert.False(t, hasReasoningConfig, "reasoning_config should NOT be set for Anthropic models")
// Verify output_config handling
if tt.expectNoOutputConfig {
_, hasOutputConfig := result.AdditionalModelRequestFields.Get("output_config")
assert.False(t, hasOutputConfig, "output_config should NOT be set for this model")
} else if tt.expectOutputConfigEffort != "" {
// Opus 4.6+ should have output_config.effort set
outputConfig, hasOutputConfig := result.AdditionalModelRequestFields.Get("output_config")
assert.True(t, hasOutputConfig, "output_config should be set for Opus 4.6+")
if outputConfigMap, ok := outputConfig.(map[string]any); ok {
effortStr, _ := outputConfigMap["effort"].(string)
assert.Equal(t, tt.expectOutputConfigEffort, effortStr, "output_config.effort should match expected value")
}
}
// Verify the type
if configMap, ok := thinkingConfig.(map[string]any); ok {
typeStr, _ := configMap["type"].(string)
assert.Equal(t, tt.expectedType, typeStr)
if tt.expectBudgetTokens {
_, hasBudget := configMap["budget_tokens"]
assert.True(t, hasBudget, "expected budget_tokens in thinking config")
}
} else if configMap, ok := thinkingConfig.(map[string]string); ok {
assert.Equal(t, tt.expectedType, configMap["type"])
}
})
}
}
func TestAnthropicOrderedOutputConfigRoundTripsReasoning(t *testing.T) {
request := &bedrock.BedrockConverseRequest{
ModelID: "anthropic.claude-opus-4-6-v1",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello"),
},
},
},
},
AdditionalModelRequestFields: schemas.NewOrderedMapFromPairs(
schemas.KV("thinking", map[string]any{
"type": "adaptive",
"budget_tokens": 2048,
}),
schemas.KV("output_config", schemas.NewOrderedMapFromPairs(
schemas.KV("effort", "medium"),
)),
),
ExtraParams: map[string]any{
"reasoning_summary": "auto",
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := request.ToBifrostResponsesRequest(ctx)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Params)
require.NotNil(t, result.Params.Reasoning)
require.NotNil(t, result.Params.Reasoning.Effort)
assert.Equal(t, "medium", *result.Params.Reasoning.Effort)
require.NotNil(t, result.Params.Reasoning.MaxTokens)
assert.Equal(t, 2048, *result.Params.Reasoning.MaxTokens)
require.NotNil(t, result.Params.Reasoning.Summary)
assert.Equal(t, "auto", *result.Params.Reasoning.Summary)
}
func TestAnthropicOutputConfigFormatStillFallsBackToBudgetTokensForReasoning(t *testing.T) {
request := &bedrock.BedrockConverseRequest{
ModelID: "anthropic.claude-opus-4-6-v1",
Messages: []bedrock.BedrockMessage{
{
Role: bedrock.BedrockMessageRoleUser,
Content: []bedrock.BedrockContentBlock{
{
Text: schemas.Ptr("Hello"),
},
},
},
},
AdditionalModelRequestFields: schemas.NewOrderedMapFromPairs(
schemas.KV("thinking", map[string]any{
"type": "adaptive",
"budget_tokens": 2048,
}),
schemas.KV("output_config", schemas.NewOrderedMapFromPairs(
schemas.KV("format", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "json_schema"),
schemas.KV("schema", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "object"),
)),
)),
)),
),
ExtraParams: map[string]any{
"reasoning_summary": "auto",
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := request.ToBifrostResponsesRequest(ctx)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Params)
require.NotNil(t, result.Params.Reasoning)
require.NotNil(t, result.Params.Reasoning.Effort)
// Effort is inferred from budget_tokens (2048) against the model-specific max output tokens
// (128K for claude-opus-4-6) minus Anthropic's minimum reasoning budget (1024). That ratio
// (~0.008) falls in the "low" bucket — see providerUtils.GetReasoningEffortFromBudgetTokens.
assert.Equal(t, "low", *result.Params.Reasoning.Effort)
require.NotNil(t, result.Params.Reasoning.MaxTokens)
assert.Equal(t, 2048, *result.Params.Reasoning.MaxTokens)
require.NotNil(t, result.Params.Reasoning.Summary)
assert.Equal(t, "auto", *result.Params.Reasoning.Summary)
}
// TestAnthropicStructuredOutputUsesOutputConfigWithoutForcedToolChoice ensures
// Anthropic Bedrock structured output uses native output_config.format and does
// not synthesize a forced tool choice, while keeping reasoning (thinking) active.
func TestAnthropicStructuredOutputUsesOutputConfigWithoutForcedToolChoice(t *testing.T) {
responseFormat := any(map[string]any{
"type": "json_schema",
"json_schema": map[string]any{
"name": "classification",
"schema": map[string]any{
"type": "object",
"properties": map[string]any{
"topic": map[string]any{
"type": "string",
},
},
"required": []any{"topic"},
},
},
})
bifrostReq := &schemas.BifrostChatRequest{
Model: "anthropic.claude-3-7-sonnet-v1",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Classify this"),
},
},
},
Params: &schemas.ChatParameters{
ResponseFormat: &responseFormat,
Reasoning: &schemas.ChatReasoning{
MaxTokens: schemas.Ptr(2048),
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.AdditionalModelRequestFields)
outputConfigRaw, hasOutputConfig := result.AdditionalModelRequestFields.Get("output_config")
require.True(t, hasOutputConfig, "expected output_config for anthropic structured output")
outputConfig, ok := outputConfigRaw.(*schemas.OrderedMap)
require.True(t, ok, "expected output_config to be an ordered map")
formatRaw, hasFormat := outputConfig.Get("format")
require.True(t, hasFormat, "expected output_config.format")
format, ok := formatRaw.(*schemas.OrderedMap)
require.True(t, ok, "expected output_config.format to be an ordered map")
formatType, hasType := format.Get("type")
require.True(t, hasType, "expected output_config.format.type")
assert.Equal(t, "json_schema", formatType)
_, hasSchema := format.Get("schema")
assert.True(t, hasSchema, "expected output_config.format.schema")
// reasoning should still be preserved for anthropic
thinkingRaw, hasThinking := result.AdditionalModelRequestFields.Get("thinking")
require.True(t, hasThinking, "expected thinking field for anthropic reasoning")
thinking, ok := thinkingRaw.(map[string]any)
require.True(t, ok, "expected thinking to be a map")
assert.Equal(t, "enabled", thinking["type"])
// structured output should NOT force tool choice on Bedrock anthropic
if result.ToolConfig != nil {
assert.Nil(t, result.ToolConfig.ToolChoice, "expected no forced tool choice for anthropic structured output")
assert.Empty(t, result.ToolConfig.Tools, "expected no synthetic structured output tool for anthropic structured output")
}
}
func TestAnthropicStructuredOutputAcceptsOrderedMaps(t *testing.T) {
responseFormat := any(schemas.NewOrderedMapFromPairs(
schemas.KV("type", "json_schema"),
schemas.KV("json_schema", schemas.NewOrderedMapFromPairs(
schemas.KV("name", "classification"),
schemas.KV("schema", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "object"),
schemas.KV("description", "Return structured classification"),
schemas.KV("properties", schemas.NewOrderedMapFromPairs(
schemas.KV("topic", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
)),
)),
schemas.KV("required", []any{"topic"}),
)),
)),
))
bifrostReq := &schemas.BifrostChatRequest{
Model: "anthropic.claude-3-7-sonnet-v1",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Classify this"),
},
},
},
Params: &schemas.ChatParameters{
ResponseFormat: &responseFormat,
Reasoning: &schemas.ChatReasoning{
MaxTokens: schemas.Ptr(2048),
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.AdditionalModelRequestFields)
outputConfigRaw, hasOutputConfig := result.AdditionalModelRequestFields.Get("output_config")
require.True(t, hasOutputConfig, "expected output_config for anthropic structured output")
outputConfig, ok := outputConfigRaw.(*schemas.OrderedMap)
require.True(t, ok, "expected output_config to be an ordered map")
formatRaw, hasFormat := outputConfig.Get("format")
require.True(t, hasFormat, "expected output_config.format")
format, ok := formatRaw.(*schemas.OrderedMap)
require.True(t, ok, "expected output_config.format to be an ordered map")
formatType, ok := format.Get("type")
require.True(t, ok, "expected output_config.format.type")
assert.Equal(t, "json_schema", formatType)
schemaRaw, ok := format.Get("schema")
require.True(t, ok, "expected output_config.format.schema")
_, ok = schemaRaw.(*schemas.OrderedMap)
require.True(t, ok, "expected output_config.format.schema to remain ordered")
}
// TestNonAnthropicStructuredOutputStillUsesToolConversion ensures Bedrock models
// other than Anthropic continue to use the legacy response_format->tool path.
func TestNonAnthropicStructuredOutputStillUsesToolConversion(t *testing.T) {
responseFormat := any(schemas.NewOrderedMapFromPairs(
schemas.KV("type", "json_schema"),
schemas.KV("json_schema", schemas.NewOrderedMapFromPairs(
schemas.KV("name", "classification"),
schemas.KV("schema", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "object"),
schemas.KV("description", "Return structured classification"),
schemas.KV("properties", schemas.NewOrderedMapFromPairs(
schemas.KV("topic", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
)),
)),
schemas.KV("required", []any{"topic"}),
)),
)),
))
bifrostReq := &schemas.BifrostChatRequest{
Model: "amazon.nova-pro-v1",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Classify this"),
},
},
},
Params: &schemas.ChatParameters{
ResponseFormat: &responseFormat,
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
// Non-Anthropic models should not use output_config.format.
if result.AdditionalModelRequestFields != nil {
_, hasOutputConfig := result.AdditionalModelRequestFields.Get("output_config")
assert.False(t, hasOutputConfig, "expected no output_config for non-anthropic structured output")
}
require.NotNil(t, result.ToolConfig, "expected tool_config for non-anthropic structured output")
require.NotEmpty(t, result.ToolConfig.Tools, "expected synthetic structured output tool to be added")
require.NotNil(t, result.ToolConfig.ToolChoice, "expected structured output tool choice to be forced")
require.NotNil(t, result.ToolConfig.ToolChoice.Tool, "expected structured output tool choice to target the synthetic tool")
assert.Equal(t, "bf_so_classification", result.ToolConfig.ToolChoice.Tool.Name)
assert.Equal(t, "bf_so_classification", result.ToolConfig.Tools[0].ToolSpec.Name)
schemaRaw := result.ToolConfig.Tools[0].ToolSpec.InputSchema.JSON
var schema schemas.OrderedMap
require.NoError(t, schema.UnmarshalJSON(schemaRaw))
schemaType, ok := schema.Get("type")
require.True(t, ok, "expected tool schema type")
assert.Equal(t, "object", schemaType)
}
// TestAnthropicStructuredOutputMergesAdditionalModelRequestFieldPaths ensures
// additionalModelRequestFieldPaths are merged into existing AdditionalModelRequestFields
// and output_config is deep-merged instead of overwritten.
func TestAnthropicStructuredOutputMergesAdditionalModelRequestFieldPaths(t *testing.T) {
responseFormat := any(map[string]any{
"type": "json_schema",
"json_schema": map[string]any{
"name": "classification",
"schema": map[string]any{
"type": "object",
"properties": map[string]any{
"topic": map[string]any{
"type": "string",
},
},
"required": []any{"topic"},
},
},
})
bifrostReq := &schemas.BifrostChatRequest{
Model: "anthropic.claude-3-7-sonnet-v1",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Classify this"),
},
},
},
Params: &schemas.ChatParameters{
ResponseFormat: &responseFormat,
Reasoning: &schemas.ChatReasoning{
MaxTokens: schemas.Ptr(2048),
},
ExtraParams: map[string]any{
"additionalModelRequestFieldPaths": schemas.NewOrderedMapFromPairs(
schemas.KV("output_config", map[string]any{
"foo": "bar",
}),
schemas.KV("customField", "customValue"),
),
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.AdditionalModelRequestFields)
outputConfigRaw, hasOutputConfig := result.AdditionalModelRequestFields.Get("output_config")
require.True(t, hasOutputConfig, "expected output_config to exist after merge")
outputConfig, ok := outputConfigRaw.(*schemas.OrderedMap)
require.True(t, ok, "expected output_config to be an ordered map")
// Existing structured output format must be preserved.
formatRaw, hasFormat := outputConfig.Get("format")
require.True(t, hasFormat, "expected output_config.format to be preserved")
format, ok := formatRaw.(*schemas.OrderedMap)
require.True(t, ok, "expected output_config.format to be an ordered map")
formatType, hasType := format.Get("type")
require.True(t, hasType, "expected output_config.format.type")
assert.Equal(t, "json_schema", formatType)
_, hasSchema := format.Get("schema")
assert.True(t, hasSchema, "expected output_config.format.schema")
// Incoming additionalModelRequestFieldPaths.output_config key must be merged.
foo, hasFoo := outputConfig.Get("foo")
require.True(t, hasFoo, "expected output_config.foo to be preserved")
assert.Equal(t, "bar", foo)
// Existing top-level field (thinking) must not be lost.
_, hasThinking := result.AdditionalModelRequestFields.Get("thinking")
assert.True(t, hasThinking, "expected thinking to be preserved")
// Incoming top-level keys must be merged.
customField, hasCustomField := result.AdditionalModelRequestFields.Get("customField")
require.True(t, hasCustomField, "expected customField to be merged")
assert.Equal(t, "customValue", customField)
}
// TestNovaReasoningConfigUsesReasoningConfigField verifies that Nova models use
// the "reasoningConfig" field (camelCase) and NOT "thinking".
func TestNovaReasoningConfigUsesReasoningConfigField(t *testing.T) {
bifrostReq := &schemas.BifrostChatRequest{
Model: "amazon.nova-pro-v1",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello"),
},
},
},
Params: &schemas.ChatParameters{
Reasoning: &schemas.ChatReasoning{
Effort: schemas.Ptr("medium"),
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.AdditionalModelRequestFields)
// Nova should use reasoningConfig (camelCase)
_, hasReasoningConfig := result.AdditionalModelRequestFields.Get("reasoningConfig")
assert.True(t, hasReasoningConfig, "Nova models should use reasoningConfig field")
// Nova should NOT use "thinking"
_, hasThinking := result.AdditionalModelRequestFields.Get("thinking")
assert.False(t, hasThinking, "Nova models should NOT use thinking field")
}
// TestStandaloneCachePointBlockHandling tests that standalone cachePoint content blocks
// (those with only cachePoint field and no type) are properly converted.
func TestStandaloneCachePointBlockHandling(t *testing.T) {
t.Run("UserMessage_WithStandaloneCachePoint", func(t *testing.T) {
bifrostReq := &schemas.BifrostChatRequest{
Model: "anthropic.claude-3-sonnet-20240229-v1:0",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentBlocks: []schemas.ChatContentBlock{
{
Type: schemas.ChatContentBlockTypeText,
Text: schemas.Ptr("Hello, this is a test message"),
},
{
// Standalone cachePoint block (no type, just cachePoint)
CachePoint: &schemas.CachePoint{
Type: "default",
},
},
},
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.Messages, 1)
require.Len(t, result.Messages[0].Content, 2)
// First block should be text
assert.NotNil(t, result.Messages[0].Content[0].Text)
assert.Equal(t, "Hello, this is a test message", *result.Messages[0].Content[0].Text)
// Second block should be cachePoint
assert.NotNil(t, result.Messages[0].Content[1].CachePoint)
assert.Equal(t, bedrock.BedrockCachePointTypeDefault, result.Messages[0].Content[1].CachePoint.Type)
})
t.Run("BedrockNativeFormat_TextWithoutType", func(t *testing.T) {
// This tests the Bedrock native format where text blocks don't have a "type" field
// Example: {"text": "hello"} instead of {"type": "text", "text": "hello"}
bifrostReq := &schemas.BifrostChatRequest{
Model: "anthropic.claude-3-sonnet-20240229-v1:0",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentBlocks: []schemas.ChatContentBlock{
{
// No Type field set, but Text is present (Bedrock native format)
Text: schemas.Ptr("hello this is a test request"),
},
{
// Standalone cachePoint block
CachePoint: &schemas.CachePoint{
Type: "default",
},
},
},
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.Messages, 1)
require.Len(t, result.Messages[0].Content, 2)
// First block should be text (even without explicit type)
assert.NotNil(t, result.Messages[0].Content[0].Text)
assert.Equal(t, "hello this is a test request", *result.Messages[0].Content[0].Text)
// Second block should be cachePoint
assert.NotNil(t, result.Messages[0].Content[1].CachePoint)
assert.Equal(t, bedrock.BedrockCachePointTypeDefault, result.Messages[0].Content[1].CachePoint.Type)
})
t.Run("SystemMessage_WithStandaloneCachePoint", func(t *testing.T) {
bifrostReq := &schemas.BifrostChatRequest{
Model: "anthropic.claude-3-sonnet-20240229-v1:0",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleSystem,
Content: &schemas.ChatMessageContent{
ContentBlocks: []schemas.ChatContentBlock{
{
Type: schemas.ChatContentBlockTypeText,
Text: schemas.Ptr("You are a helpful assistant"),
},
{
// Standalone cachePoint block
CachePoint: &schemas.CachePoint{
Type: "default",
},
},
{
Type: schemas.ChatContentBlockTypeText,
Text: schemas.Ptr("Additional system instructions"),
},
},
},
},
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello"),
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.System)
require.Len(t, result.System, 3) // Two text blocks + one cachePoint
// First system message should be text
assert.NotNil(t, result.System[0].Text)
assert.Equal(t, "You are a helpful assistant", *result.System[0].Text)
// Second should be cachePoint
assert.NotNil(t, result.System[1].CachePoint)
// Third should be text
assert.NotNil(t, result.System[2].Text)
assert.Equal(t, "Additional system instructions", *result.System[2].Text)
})
}
func TestMultiTurnReasoningContentPassthrough(t *testing.T) {
t.Parallel()
t.Run("AssistantMessage_WithReasoningDetails_ConvertsToBedrockReasoningContent", func(t *testing.T) {
reasoningText := "Let me think step by step..."
signature := "abc123signature"
assistantContent := "The answer is 42."
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Bedrock,
Model: "anthropic.claude-opus-4-6-v1",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("What is the meaning of life?"),
},
},
{
Role: schemas.ChatMessageRoleAssistant,
Content: &schemas.ChatMessageContent{
ContentStr: &assistantContent,
},
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ReasoningDetails: []schemas.ChatReasoningDetails{
{
Index: 0,
Type: schemas.BifrostReasoningDetailsTypeText,
Text: &reasoningText,
Signature: &signature,
},
},
},
},
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Can you elaborate?"),
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
// The assistant message (index 1) should have reasoning content blocks
require.Len(t, result.Messages, 3) // user, assistant, user
assistantMsg := result.Messages[1]
assert.Equal(t, bedrock.BedrockMessageRoleAssistant, assistantMsg.Role)
// Should have text block + reasoning content block
require.GreaterOrEqual(t, len(assistantMsg.Content), 2)
// Find the reasoning content block
var foundReasoning bool
for _, block := range assistantMsg.Content {
if block.ReasoningContent != nil {
foundReasoning = true
require.NotNil(t, block.ReasoningContent.ReasoningText)
assert.Equal(t, &reasoningText, block.ReasoningContent.ReasoningText.Text)
assert.Equal(t, &signature, block.ReasoningContent.ReasoningText.Signature)
}
}
assert.True(t, foundReasoning, "Expected reasoning content block in assistant message")
})
t.Run("AssistantMessage_WithoutReasoningDetails_NoReasoningContent", func(t *testing.T) {
assistantContent := "Simple response"
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Bedrock,
Model: "anthropic.claude-opus-4-6-v1",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello"),
},
},
{
Role: schemas.ChatMessageRoleAssistant,
Content: &schemas.ChatMessageContent{
ContentStr: &assistantContent,
},
},
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hi again"),
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
assistantMsg := result.Messages[1]
for _, block := range assistantMsg.Content {
assert.Nil(t, block.ReasoningContent, "Should not have reasoning content without ReasoningDetails")
}
})
}
func TestDocumentFormatMapping(t *testing.T) {
t.Parallel()
tests := []struct {
name string
fileType string
expectedFormat string
}{
{"PDF_MimeType", "application/pdf", "pdf"},
{"PDF_Short", "pdf", "pdf"},
{"TXT_MimeType", "text/plain", "txt"},
{"TXT_Short", "txt", "txt"},
{"Markdown_MimeType", "text/markdown", "md"},
{"Markdown_Short", "md", "md"},
{"HTML_MimeType", "text/html", "html"},
{"HTML_Short", "html", "html"},
{"CSV_MimeType", "text/csv", "csv"},
{"CSV_Short", "csv", "csv"},
{"DOC_MimeType", "application/msword", "doc"},
{"DOC_Short", "doc", "doc"},
{"DOCX_MimeType", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"},
{"DOCX_Short", "docx", "docx"},
{"XLS_MimeType", "application/vnd.ms-excel", "xls"},
{"XLS_Short", "xls", "xls"},
{"XLSX_MimeType", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"},
{"XLSX_Short", "xlsx", "xlsx"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fileData := "Hello World" // plain text; base64 requires a data: URL prefix
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Bedrock,
Model: "anthropic.claude-3-5-sonnet-v2",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentBlocks: []schemas.ChatContentBlock{
{
Type: schemas.ChatContentBlockTypeFile,
File: &schemas.ChatInputFile{
Filename: schemas.Ptr("testfile"),
FileType: &tt.fileType,
FileData: &fileData,
},
},
},
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.Messages, 1)
require.Len(t, result.Messages[0].Content, 1)
require.NotNil(t, result.Messages[0].Content[0].Document)
assert.Equal(t, tt.expectedFormat, result.Messages[0].Content[0].Document.Format,
"File type %q should map to format %q", tt.fileType, tt.expectedFormat)
})
}
}
func TestBedrockStopReasonMapping(t *testing.T) {
t.Parallel()
tests := []struct {
name string
bedrockStopReason string
expectedBifrost string
}{
{"EndTurn", "end_turn", "stop"},
{"MaxTokens", "max_tokens", "length"},
{"StopSequence", "stop_sequence", "stop"},
{"ToolUse", "tool_use", "tool_calls"},
{"GuardrailIntervened", "guardrail_intervened", "content_filter"},
{"ContentFiltered", "content_filtered", "content_filter"},
{"UnknownReason", "some_unknown_reason", "stop"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
response := &bedrock.BedrockConverseResponse{
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{Text: schemas.Ptr("Response text")},
},
},
},
StopReason: tt.bedrockStopReason,
Usage: &bedrock.BedrockTokenUsage{
InputTokens: 10,
OutputTokens: 5,
TotalTokens: 15,
},
}
bifrostResp, err := response.ToBifrostChatResponse(context.Background(), "test-model")
require.NoError(t, err)
require.NotNil(t, bifrostResp)
require.Len(t, bifrostResp.Choices, 1)
require.NotNil(t, bifrostResp.Choices[0].FinishReason)
assert.Equal(t, tt.expectedBifrost, *bifrostResp.Choices[0].FinishReason,
"Bedrock stop reason %q should map to %q", tt.bedrockStopReason, tt.expectedBifrost)
})
}
}
func TestGuardrailConfigStreamProcessingMode(t *testing.T) {
t.Parallel()
t.Run("WithStreamProcessingMode", func(t *testing.T) {
mode := "async"
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Bedrock,
Model: "anthropic.claude-3-5-sonnet-v2",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello"),
},
},
},
Params: &schemas.ChatParameters{
ExtraParams: map[string]interface{}{
"guardrailConfig": map[string]interface{}{
"guardrailIdentifier": "test-guardrail",
"guardrailVersion": "1",
"trace": "enabled",
"streamProcessingMode": mode,
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.GuardrailConfig)
assert.Equal(t, "test-guardrail", result.GuardrailConfig.GuardrailIdentifier)
assert.Equal(t, "1", result.GuardrailConfig.GuardrailVersion)
require.NotNil(t, result.GuardrailConfig.Trace)
assert.Equal(t, "enabled", *result.GuardrailConfig.Trace)
require.NotNil(t, result.GuardrailConfig.StreamProcessingMode)
assert.Equal(t, mode, *result.GuardrailConfig.StreamProcessingMode)
})
t.Run("WithoutStreamProcessingMode", func(t *testing.T) {
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Bedrock,
Model: "anthropic.claude-3-5-sonnet-v2",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("Hello"),
},
},
},
Params: &schemas.ChatParameters{
ExtraParams: map[string]interface{}{
"guardrailConfig": map[string]interface{}{
"guardrailIdentifier": "test-guardrail",
"guardrailVersion": "1",
},
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.GuardrailConfig)
assert.Nil(t, result.GuardrailConfig.StreamProcessingMode)
})
}
func TestToolChoiceAutoHandling(t *testing.T) {
t.Parallel()
t.Run("AutoToolChoice_OmitsToolChoice", func(t *testing.T) {
autoStr := "auto"
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Bedrock,
Model: "anthropic.claude-3-5-sonnet-v2",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("What's the weather?"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "get_weather",
Description: schemas.Ptr("Get weather"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: &testProps,
},
},
},
},
ToolChoice: &schemas.ChatToolChoice{
ChatToolChoiceStr: &autoStr,
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.ToolConfig)
assert.Nil(t, result.ToolConfig.ToolChoice, "Auto tool choice should be omitted (nil) as it's the default")
})
t.Run("RequiredToolChoice_SetsAny", func(t *testing.T) {
requiredStr := "required"
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Bedrock,
Model: "anthropic.claude-3-5-sonnet-v2",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: schemas.Ptr("What's the weather?"),
},
},
},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{
{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "get_weather",
Description: schemas.Ptr("Get weather"),
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: &testProps,
},
},
},
},
ToolChoice: &schemas.ChatToolChoice{
ChatToolChoiceStr: &requiredStr,
},
},
}
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.ToolConfig)
require.NotNil(t, result.ToolConfig.ToolChoice)
assert.NotNil(t, result.ToolConfig.ToolChoice.Any, "Required tool choice should map to Any")
})
}
func TestDocumentFormatResponseMapping(t *testing.T) {
t.Parallel()
tests := []struct {
name string
bedrockFormat string
expectedMimeType string
}{
{"PDF", "pdf", "application/pdf"},
{"TXT", "txt", "text/plain"},
{"Markdown", "md", "text/markdown"},
{"HTML", "html", "text/html"},
{"CSV", "csv", "text/csv"},
{"DOC", "doc", "application/msword"},
{"DOCX", "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
{"XLS", "xls", "application/vnd.ms-excel"},
{"XLSX", "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
{"Unknown", "xyz", "application/pdf"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
docBytes := "SGVsbG8=" // base64 "Hello"
response := &bedrock.BedrockConverseResponse{
Output: &bedrock.BedrockConverseOutput{
Message: &bedrock.BedrockMessage{
Role: bedrock.BedrockMessageRoleAssistant,
Content: []bedrock.BedrockContentBlock{
{
Document: &bedrock.BedrockDocumentSource{
Format: tt.bedrockFormat,
Name: "testdoc",
Source: &bedrock.BedrockDocumentSourceData{
Bytes: &docBytes,
},
},
},
},
},
},
StopReason: "end_turn",
Usage: &bedrock.BedrockTokenUsage{
InputTokens: 10,
OutputTokens: 5,
TotalTokens: 15,
},
}
bifrostResp, err := response.ToBifrostChatResponse(context.Background(), "test-model")
require.NoError(t, err)
require.NotNil(t, bifrostResp)
require.Len(t, bifrostResp.Choices, 1)
choice := bifrostResp.Choices[0]
require.NotNil(t, choice.ChatNonStreamResponseChoice)
require.NotNil(t, choice.ChatNonStreamResponseChoice.Message)
require.NotNil(t, choice.ChatNonStreamResponseChoice.Message.Content)
blocks := choice.ChatNonStreamResponseChoice.Message.Content.ContentBlocks
require.Len(t, blocks, 1)
assert.Equal(t, schemas.ChatContentBlockTypeFile, blocks[0].Type)
require.NotNil(t, blocks[0].File)
require.NotNil(t, blocks[0].File.FileType)
assert.Equal(t, tt.expectedMimeType, *blocks[0].File.FileType,
"Bedrock format %q should map to MIME type %q", tt.bedrockFormat, tt.expectedMimeType)
})
}
}
// TestBedrockToolInputKeyOrderPreservation verifies that multiple parallel tool calls
// preserve the client's original key ordering after conversion to Bedrock format.
func TestBedrockToolInputKeyOrderPreservation(t *testing.T) {
bifrostReq := &schemas.BifrostChatRequest{
Model: "anthropic.claude-3-sonnet",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("test")},
},
{
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{
Index: 0,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("toolu_001"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("bash"),
Arguments: `{"description":"Find references quickly","timeout":30000,"command":"grep -r auth_injector ."}`,
},
},
{
Index: 1,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("toolu_002"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("bash"),
Arguments: `{"command":"git diff main...HEAD --stat","description":"Show diff of commits"}`,
},
},
{
Index: 2,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("toolu_003"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("bash"),
Arguments: `{"command":"git log main..HEAD","description":"Show commits in branch"}`,
},
},
},
},
},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq)
require.NoError(t, err)
// Collect all tool use content blocks from assistant messages
var toolUseInputs []interface{}
for _, msg := range result.Messages {
for _, block := range msg.Content {
if block.ToolUse != nil {
toolUseInputs = append(toolUseInputs, block.ToolUse.Input)
}
}
}
require.Len(t, toolUseInputs, 3, "expected 3 tool use blocks")
// Block 0: keys should be description, timeout, command (NOT alphabetical)
json0, _ := json.Marshal(toolUseInputs[0])
s0 := string(json0)
descIdx0 := strings.Index(s0, `"description"`)
timeIdx0 := strings.Index(s0, `"timeout"`)
cmdIdx0 := strings.Index(s0, `"command"`)
if descIdx0 < 0 || timeIdx0 < 0 || cmdIdx0 < 0 {
t.Fatalf("block 0: missing expected key(s) in: %s", s0)
}
assert.True(t, descIdx0 < timeIdx0 && timeIdx0 < cmdIdx0,
"block 0: key order not preserved, expected description < timeout < command in: %s", s0)
// Block 1: keys should be command, description (NOT alphabetical)
json1, _ := json.Marshal(toolUseInputs[1])
s1 := string(json1)
cmdIdx1 := strings.Index(s1, `"command"`)
descIdx1 := strings.Index(s1, `"description"`)
if cmdIdx1 < 0 || descIdx1 < 0 {
t.Fatalf("block 1: missing expected key(s) in: %s", s1)
}
assert.True(t, cmdIdx1 < descIdx1,
"block 1: key order not preserved, expected command < description in: %s", s1)
// Block 2: keys should be command, description
json2, _ := json.Marshal(toolUseInputs[2])
s2 := string(json2)
cmdIdx2 := strings.Index(s2, `"command"`)
descIdx2 := strings.Index(s2, `"description"`)
if cmdIdx2 < 0 || descIdx2 < 0 {
t.Fatalf("block 2: missing expected key(s) in: %s", s2)
}
assert.True(t, cmdIdx2 < descIdx2,
"block 2: key order not preserved, expected command < description in: %s", s2)
}
// TestToBedrockInvokeMessagesStreamResponse_NoDuplicateContentBlockStop verifies that
// ContentPartDone does not emit a content_block_stop event (only OutputItemDone does),
// preventing duplicate content_block_stop events in the stream. (Issue #2293)
func TestToBedrockInvokeMessagesStreamResponse_NoDuplicateContentBlockStop(t *testing.T) {
ctx := &schemas.BifrostContext{}
contentIdx := 0
model := "anthropic.claude-sonnet-4-5-20250929-v1:0"
// Simulate the sequence FinalizeBedrockStream emits for a text block:
// 1. OutputTextDone — should be skipped
// 2. ContentPartDone — should be skipped (was previously emitting content_block_stop)
// 3. OutputItemDone — should emit content_block_stop
events := []*schemas.BifrostResponsesStreamResponse{
{
Type: schemas.ResponsesStreamResponseTypeOutputTextDone,
ContentIndex: &contentIdx,
ExtraFields: schemas.BifrostResponseExtraFields{OriginalModelRequested: model},
},
{
Type: schemas.ResponsesStreamResponseTypeContentPartDone,
ContentIndex: &contentIdx,
ExtraFields: schemas.BifrostResponseExtraFields{OriginalModelRequested: model},
},
{
Type: schemas.ResponsesStreamResponseTypeOutputItemDone,
ContentIndex: &contentIdx,
ExtraFields: schemas.BifrostResponseExtraFields{OriginalModelRequested: model},
},
}
type bedrockChunk struct {
InvokeModelRawChunks [][]byte `json:"invokeModelRawChunks"`
}
var stopCount int
for _, ev := range events {
_, result, err := bedrock.ToBedrockInvokeMessagesStreamResponse(ctx, ev)
require.NoError(t, err)
if result == nil {
continue
}
raw, err := json.Marshal(result)
require.NoError(t, err)
var chunk bedrockChunk
require.NoError(t, json.Unmarshal(raw, &chunk))
for _, rawChunk := range chunk.InvokeModelRawChunks {
if strings.Contains(string(rawChunk), "content_block_stop") {
stopCount++
}
}
}
assert.Equal(t, 1, stopCount, "expected exactly one content_block_stop event, got %d", stopCount)
}
func TestToolResultImageContentResponsesAPI(t *testing.T) {
// Minimal 1x1 red PNG
pngBase64 := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
t.Run("ImageBlockPreservedInToolResult", func(t *testing.T) {
input := []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("tooluse_screenshot_001"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesFunctionToolCallOutputBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeImage,
ResponsesInputMessageContentBlockImage: &schemas.ResponsesInputMessageContentBlockImage{
ImageURL: schemas.Ptr("data:image/png;base64," + pngBase64),
},
},
},
},
},
},
}
messages, _, err := bedrock.ConvertBifrostMessagesToBedrockMessages(input)
require.NoError(t, err)
require.Len(t, messages, 1)
toolResultMsg := messages[0]
assert.Equal(t, bedrock.BedrockMessageRoleUser, toolResultMsg.Role)
require.Len(t, toolResultMsg.Content, 1)
toolResult := toolResultMsg.Content[0].ToolResult
require.NotNil(t, toolResult, "expected tool result in content block")
assert.Equal(t, "tooluse_screenshot_001", toolResult.ToolUseID)
require.Len(t, toolResult.Content, 1, "tool result should contain exactly one content block")
imageBlock := toolResult.Content[0]
require.NotNil(t, imageBlock.Image, "tool result content should be an image")
assert.Equal(t, "png", imageBlock.Image.Format)
require.NotNil(t, imageBlock.Image.Source.Bytes)
assert.Equal(t, pngBase64, *imageBlock.Image.Source.Bytes)
})
t.Run("MixedTextAndImageBlocksPreserved", func(t *testing.T) {
input := []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("tooluse_mixed_002"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesFunctionToolCallOutputBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Screenshot captured successfully"),
},
{
Type: schemas.ResponsesInputMessageContentBlockTypeImage,
ResponsesInputMessageContentBlockImage: &schemas.ResponsesInputMessageContentBlockImage{
ImageURL: schemas.Ptr("data:image/png;base64," + pngBase64),
},
},
},
},
},
},
}
messages, _, err := bedrock.ConvertBifrostMessagesToBedrockMessages(input)
require.NoError(t, err)
require.Len(t, messages, 1)
toolResult := messages[0].Content[0].ToolResult
require.NotNil(t, toolResult)
require.Len(t, toolResult.Content, 2, "both text and image blocks should be preserved")
assert.NotNil(t, toolResult.Content[0].Text, "first block should be text")
assert.NotNil(t, toolResult.Content[1].Image, "second block should be image")
assert.Equal(t, "png", toolResult.Content[1].Image.Format)
})
t.Run("RemoteURLImageGracefullyDropped", func(t *testing.T) {
input := []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("tooluse_remote_003"),
Output: &schemas.ResponsesToolMessageOutputStruct{
ResponsesFunctionToolCallOutputBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesInputMessageContentBlockTypeImage,
ResponsesInputMessageContentBlockImage: &schemas.ResponsesInputMessageContentBlockImage{
ImageURL: schemas.Ptr("https://example.com/screenshot.png"),
},
},
},
},
},
},
}
messages, _, err := bedrock.ConvertBifrostMessagesToBedrockMessages(input)
require.NoError(t, err)
require.Len(t, messages, 1)
toolResult := messages[0].Content[0].ToolResult
require.NotNil(t, toolResult)
assert.Empty(t, toolResult.Content, "remote URL image should be dropped (Bedrock only supports base64)")
})
}