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