package bedrock import ( "bytes" "encoding/base64" "encoding/json" "fmt" "regexp" "strings" "github.com/maximhq/bifrost/core/providers/anthropic" providerUtils "github.com/maximhq/bifrost/core/providers/utils" schemas "github.com/maximhq/bifrost/core/schemas" ) var ( invalidCharRegex = regexp.MustCompile(`[^a-zA-Z0-9\s\-\(\)\[\]]`) multiSpaceRegex = regexp.MustCompile(`\s{2,}`) // bedrockFinishReasonToBifrost maps Bedrock Converse API stop reasons to Bifrost format. // Bedrock has additional stop reasons beyond Anthropic (guardrail_intervened, content_filtered). bedrockFinishReasonToBifrost = map[string]string{ "end_turn": "stop", "max_tokens": "length", "stop_sequence": "stop", "tool_use": "tool_calls", "guardrail_intervened": "content_filter", "content_filtered": "content_filter", } ) // convertBedrockStopReason converts a Bedrock stop reason to Bifrost format. func convertBedrockStopReason(stopReason string) string { if reason, ok := bedrockFinishReasonToBifrost[stopReason]; ok { return reason } return "stop" } // normalizeBedrockFilename normalizes a filename to meet Bedrock's requirements: // - Only alphanumeric characters, whitespace, hyphens, parentheses, and square brackets // - No more than one consecutive whitespace character // - Trims leading and trailing whitespace func normalizeBedrockFilename(filename string) string { if filename == "" { return "document" } // Replace invalid characters with underscores normalized := invalidCharRegex.ReplaceAllString(filename, "_") // Replace multiple consecutive whitespace with a single space normalized = multiSpaceRegex.ReplaceAllString(normalized, " ") // Trim leading and trailing whitespace normalized = strings.TrimSpace(normalized) // If the result is empty after normalization, return a default name if normalized == "" { return "document" } return normalized } // convertParameters handles parameter conversion func convertChatParameters(ctx *schemas.BifrostContext, bifrostReq *schemas.BifrostChatRequest, bedrockReq *BedrockConverseRequest) error { // Parameters are optional - if not provided, just skip conversion if bifrostReq.Params == nil { return nil } // Convert inference config if inferenceConfig := convertInferenceConfig(bifrostReq.Params); inferenceConfig != nil { bedrockReq.InferenceConfig = inferenceConfig } // Handle structured output conversion: // - Anthropic models on Bedrock use native output_config.format // - Other models keep the response_format->tool conversion. responseFormatTool, anthropicOutputFormat := convertResponseFormatToTool(ctx, bifrostReq.Model, bifrostReq.Params) if anthropicOutputFormat != nil { if bedrockReq.AdditionalModelRequestFields == nil { bedrockReq.AdditionalModelRequestFields = schemas.NewOrderedMap() } setOutputConfigField(bedrockReq.AdditionalModelRequestFields, "format", anthropicOutputFormat) } // Filter provider-unsupported server tools once; both convertToolConfig and // collectBedrockServerTools consume the same filtered set, and // buildBedrockServerToolChoice resolves pinned names against it. filteredTools, _ := anthropic.ValidateChatToolsForProvider(bifrostReq.Params.Tools, schemas.Bedrock) // Convert tool config (function/custom tools → Converse toolConfig.tools). if toolConfig := convertToolConfigFromFiltered(bifrostReq.Model, bifrostReq.Params, filteredTools); toolConfig != nil { bedrockReq.ToolConfig = toolConfig } // Tunnel Bedrock-supported Anthropic server tools through Converse's // additionalModelRequestFields (model-specific passthrough) since Converse's // typed toolSpec shape can't express server tools like bash_*, computer_*, // memory_*, text_editor_*, tool_search_tool_*. Fields injected: // - tools: array of server tools in Anthropic-native shape, which // Bedrock merges into the underlying Messages request. // - anthropic_beta: activation header(s) for the relevant server tool, in // addition to whatever the existing anthropic-beta HTTP // header path in bedrock.go:214/447 already forwards. // - tool_choice: Anthropic-native pin for a kept server tool OR an // any/required contract when only server tools are // present. Emitted only when Converse's typed // toolConfig.toolChoice path can't express the intent // (see buildBedrockServerToolChoice). if serverTools, betaHeaders := collectBedrockServerToolsFromFiltered(filteredTools); len(serverTools) > 0 { if bedrockReq.AdditionalModelRequestFields == nil { bedrockReq.AdditionalModelRequestFields = schemas.NewOrderedMap() } bedrockReq.AdditionalModelRequestFields.Set("tools", serverTools) if len(betaHeaders) > 0 { bedrockReq.AdditionalModelRequestFields.Set("anthropic_beta", betaHeaders) } // Skip the tunneled tool_choice when response_format forces the synthetic // bf_so_* tool at lines 263-275 below; otherwise Bedrock receives two // conflicting tool-choice directives and the structured-output contract // can silently break. if responseFormatTool == nil { if choice, ok := buildBedrockServerToolChoice(bifrostReq.Params, filteredTools); ok { bedrockReq.AdditionalModelRequestFields.Set("tool_choice", choice) } } } // Convert reasoning config if bifrostReq.Params.Reasoning != nil { if bedrockReq.AdditionalModelRequestFields == nil { bedrockReq.AdditionalModelRequestFields = schemas.NewOrderedMap() } if bifrostReq.Params.Reasoning.MaxTokens != nil { tokenBudget := *bifrostReq.Params.Reasoning.MaxTokens if *bifrostReq.Params.Reasoning.MaxTokens == -1 { // bedrock does not support dynamic reasoning budget like gemini // setting it to default max tokens tokenBudget = anthropic.MinimumReasoningMaxTokens } if schemas.IsAnthropicModel(bifrostReq.Model) { if tokenBudget < anthropic.MinimumReasoningMaxTokens { return fmt.Errorf("reasoning.max_tokens must be >= %d for anthropic", anthropic.MinimumReasoningMaxTokens) } bedrockReq.AdditionalModelRequestFields.Set("thinking", map[string]any{ "type": "enabled", "budget_tokens": tokenBudget, }) } else if schemas.IsNovaModel(bifrostReq.Model) { minBudgetTokens := MinimumReasoningMaxTokens modelDefaultMaxTokens := providerUtils.GetMaxOutputTokensOrDefault(bifrostReq.Model, DefaultCompletionMaxTokens) defaultMaxTokens := modelDefaultMaxTokens if bedrockReq.InferenceConfig != nil && bedrockReq.InferenceConfig.MaxTokens != nil { defaultMaxTokens = *bedrockReq.InferenceConfig.MaxTokens } else if bedrockReq.InferenceConfig != nil { bedrockReq.InferenceConfig.MaxTokens = schemas.Ptr(modelDefaultMaxTokens) } else { bedrockReq.InferenceConfig = &BedrockInferenceConfig{ MaxTokens: schemas.Ptr(modelDefaultMaxTokens), } } maxReasoningEffort := providerUtils.GetReasoningEffortFromBudgetTokens(tokenBudget, minBudgetTokens, defaultMaxTokens) typeStr := "enabled" switch maxReasoningEffort { case "high": if bedrockReq.InferenceConfig != nil { bedrockReq.InferenceConfig.MaxTokens = nil bedrockReq.InferenceConfig.Temperature = nil bedrockReq.InferenceConfig.TopP = nil } case "minimal": maxReasoningEffort = "low" case "none": typeStr = "disabled" } config := map[string]any{ "type": typeStr, } if typeStr != "disabled" { config["maxReasoningEffort"] = maxReasoningEffort } bedrockReq.AdditionalModelRequestFields.Set("reasoningConfig", config) } else { bedrockReq.AdditionalModelRequestFields.Set("reasoning_config", map[string]any{ "type": "enabled", "budget_tokens": tokenBudget, }) } } else if bifrostReq.Params.Reasoning.Effort != nil && *bifrostReq.Params.Reasoning.Effort != "none" { modelDefaultMaxTokens := providerUtils.GetMaxOutputTokensOrDefault(bifrostReq.Model, DefaultCompletionMaxTokens) maxTokens := modelDefaultMaxTokens if bedrockReq.InferenceConfig != nil && bedrockReq.InferenceConfig.MaxTokens != nil { maxTokens = *bedrockReq.InferenceConfig.MaxTokens } else { if bedrockReq.InferenceConfig != nil { bedrockReq.InferenceConfig.MaxTokens = schemas.Ptr(modelDefaultMaxTokens) } else { bedrockReq.InferenceConfig = &BedrockInferenceConfig{ MaxTokens: schemas.Ptr(modelDefaultMaxTokens), } } } if schemas.IsNovaModel(bifrostReq.Model) { effort := *bifrostReq.Params.Reasoning.Effort typeStr := "enabled" switch effort { case "high": if bedrockReq.InferenceConfig != nil { bedrockReq.InferenceConfig.MaxTokens = nil bedrockReq.InferenceConfig.Temperature = nil bedrockReq.InferenceConfig.TopP = nil } case "minimal": effort = "low" case "none": typeStr = "disabled" } config := map[string]any{ "type": typeStr, } if typeStr != "disabled" { config["maxReasoningEffort"] = effort } bedrockReq.AdditionalModelRequestFields.Set("reasoningConfig", config) } else if schemas.IsAnthropicModel(bifrostReq.Model) { if anthropic.SupportsAdaptiveThinking(bifrostReq.Model) { // Opus 4.6+: adaptive thinking + output_config.effort effort := anthropic.MapBifrostEffortToAnthropic(*bifrostReq.Params.Reasoning.Effort) bedrockReq.AdditionalModelRequestFields.Set("thinking", map[string]any{ "type": "adaptive", }) setOutputConfigField(bedrockReq.AdditionalModelRequestFields, "effort", effort) } else { // Opus 4.5 and older models: budget_tokens thinking budgetTokens, err := providerUtils.GetBudgetTokensFromReasoningEffort(*bifrostReq.Params.Reasoning.Effort, anthropic.MinimumReasoningMaxTokens, maxTokens) if err != nil { return err } bedrockReq.AdditionalModelRequestFields.Set("thinking", map[string]any{ "type": "enabled", "budget_tokens": budgetTokens, }) } } } else { if schemas.IsAnthropicModel(bifrostReq.Model) { bedrockReq.AdditionalModelRequestFields.Set("thinking", map[string]any{ "type": "disabled", }) } else if schemas.IsNovaModel(bifrostReq.Model) { bedrockReq.AdditionalModelRequestFields.Set("reasoningConfig", map[string]any{ "type": "disabled", }) } else { bedrockReq.AdditionalModelRequestFields.Set("reasoning_config", map[string]any{ "type": "disabled", }) } } } // If response_format was converted to a tool, add it to the tool config if responseFormatTool != nil { if bedrockReq.ToolConfig == nil { bedrockReq.ToolConfig = &BedrockToolConfig{} } // Add the response format tool to the beginning of the tools list bedrockReq.ToolConfig.Tools = append([]BedrockTool{*responseFormatTool}, bedrockReq.ToolConfig.Tools...) // Force the model to use this specific tool bedrockReq.ToolConfig.ToolChoice = &BedrockToolChoice{ Tool: &BedrockToolChoiceTool{ Name: responseFormatTool.ToolSpec.Name, }, } } if bifrostReq.Params.ServiceTier != nil { bedrockReq.ServiceTier = &BedrockServiceTier{ Type: *bifrostReq.Params.ServiceTier, } } // Add extra parameters if len(bifrostReq.Params.ExtraParams) > 0 { bedrockReq.ExtraParams = bifrostReq.Params.ExtraParams // Handle guardrail configuration if guardrailConfig, exists := bifrostReq.Params.ExtraParams["guardrailConfig"]; exists { if gc, ok := guardrailConfig.(map[string]interface{}); ok { config := &BedrockGuardrailConfig{} if identifier, ok := gc["guardrailIdentifier"].(string); ok { config.GuardrailIdentifier = identifier } if version, ok := gc["guardrailVersion"].(string); ok { config.GuardrailVersion = version } if trace, ok := gc["trace"].(string); ok { config.Trace = &trace } if mode, ok := gc["streamProcessingMode"].(string); ok { config.StreamProcessingMode = &mode } delete(bedrockReq.ExtraParams, "guardrailConfig") bedrockReq.GuardrailConfig = config } } // Handle additional model request field paths if bifrostReq.Params != nil && bifrostReq.Params.ExtraParams != nil { if requestFields, exists := bifrostReq.Params.ExtraParams["additionalModelRequestFieldPaths"]; exists { if orderedFields, ok := schemas.SafeExtractOrderedMap(requestFields); ok { delete(bedrockReq.ExtraParams, "additionalModelRequestFieldPaths") bedrockReq.AdditionalModelRequestFields = mergeAdditionalModelRequestFields( bedrockReq.AdditionalModelRequestFields, orderedFields, ) } } // Handle additional model response field paths if responseFields, exists := bifrostReq.Params.ExtraParams["additionalModelResponseFieldPaths"]; exists { // Handle both []string and []interface{} types if fields, ok := responseFields.([]string); ok { delete(bedrockReq.ExtraParams, "additionalModelResponseFieldPaths") bedrockReq.AdditionalModelResponseFieldPaths = fields } else if fieldsInterface, ok := responseFields.([]interface{}); ok { stringFields := make([]string, 0, len(fieldsInterface)) for _, field := range fieldsInterface { if fieldStr, ok := field.(string); ok { stringFields = append(stringFields, fieldStr) } } if len(stringFields) > 0 { delete(bedrockReq.ExtraParams, "additionalModelResponseFieldPaths") bedrockReq.AdditionalModelResponseFieldPaths = stringFields } } } // Handle performance configuration if perfConfig, exists := bifrostReq.Params.ExtraParams["performanceConfig"]; exists { if pc, ok := perfConfig.(map[string]interface{}); ok { config := &BedrockPerformanceConfig{} if latency, ok := pc["latency"].(string); ok { config.Latency = &latency } delete(bedrockReq.ExtraParams, "performanceConfig") bedrockReq.PerformanceConfig = config } } // Handle prompt variables if promptVars, exists := bifrostReq.Params.ExtraParams["promptVariables"]; exists { if vars, ok := promptVars.(map[string]interface{}); ok { delete(bedrockReq.ExtraParams, "promptVariables") variables := make(map[string]BedrockPromptVariable) for key, value := range vars { if valueMap, ok := value.(map[string]interface{}); ok { variable := BedrockPromptVariable{} if text, ok := valueMap["text"].(string); ok { variable.Text = &text } variables[key] = variable } } if len(variables) > 0 { bedrockReq.PromptVariables = variables } } } // Handle request metadata if reqMetadata, exists := bifrostReq.Params.ExtraParams["requestMetadata"]; exists { if metadata, ok := schemas.SafeExtractStringMap(reqMetadata); ok { delete(bedrockReq.ExtraParams, "requestMetadata") bedrockReq.RequestMetadata = metadata } } } // Set ExtraParams to nil if all keys were extracted to dedicated fields if len(bedrockReq.ExtraParams) == 0 { bedrockReq.ExtraParams = nil } } return nil } // setOutputConfigField upserts a single key in additionalModelRequestFields.output_config // while preserving any existing output_config keys (e.g. keep "format" when adding "effort"). func setOutputConfigField(fields *schemas.OrderedMap, key string, value any) { if fields == nil { return } current := schemas.NewOrderedMap() if existing, ok := fields.Get("output_config"); ok { if om, ok := toOrderedMap(existing); ok && om != nil { current = om } } current.Set(key, value) fields.Set("output_config", current) } func mergeAdditionalModelRequestFields(existing, incoming *schemas.OrderedMap) *schemas.OrderedMap { if existing == nil { if incoming == nil { return nil } return incoming.Clone() } if incoming == nil { return existing } merged := existing.Clone() incoming.Range(func(key string, value interface{}) bool { if key == "output_config" { current := schemas.NewOrderedMap() if existingValue, ok := merged.Get(key); ok { if om, ok := toOrderedMap(existingValue); ok && om != nil { current = om } } if incomingMap, ok := toOrderedMap(value); ok && incomingMap != nil { mergeOrderedMapInto(current, incomingMap) merged.Set(key, current) } else { merged.Set(key, value) } return true } merged.Set(key, value) return true }) return merged } func toOrderedMap(v any) (*schemas.OrderedMap, bool) { switch m := v.(type) { case *schemas.OrderedMap: if m == nil { return nil, false } return m.Clone(), true case schemas.OrderedMap: return m.Clone(), true case map[string]interface{}: // Fallback for callers that still provide a plain map. Order cannot be // reconstructed here, but keeping this path preserves compatibility. return schemas.OrderedMapFromMap(m), true default: return nil, false } } // mergeOrderedMapInto deep-merges src into dst. Nested OrderedMap values are // merged recursively; non-map values from src overwrite dst. Existing key order // is preserved and newly introduced keys are appended in source order. func mergeOrderedMapInto(dst, src *schemas.OrderedMap) { if dst == nil || src == nil { return } src.Range(func(key string, srcVal interface{}) bool { if srcMap, ok := toOrderedMap(srcVal); ok && srcMap != nil { if dstVal, exists := dst.Get(key); exists { if dstMap, ok := toOrderedMap(dstVal); ok && dstMap != nil { mergeOrderedMapInto(dstMap, srcMap) dst.Set(key, dstMap) return true } } } dst.Set(key, srcVal) return true }) } func newAnthropicOutputFormatOrderedMap(schemaObj any) *schemas.OrderedMap { return schemas.NewOrderedMapFromPairs( schemas.KV("type", "json_schema"), schemas.KV("schema", schemaObj), ) } // ensureChatToolConfigForConversation ensures toolConfig is present when tool content exists func ensureChatToolConfigForConversation(bifrostReq *schemas.BifrostChatRequest, bedrockReq *BedrockConverseRequest) { if bedrockReq.ToolConfig != nil { return // Already has tool config } hasToolContent, tools := extractToolsFromConversationHistory(bifrostReq.Input) if hasToolContent && len(tools) > 0 { bedrockReq.ToolConfig = &BedrockToolConfig{Tools: tools} } } // convertMessages converts Bifrost messages to Bedrock format // Returns regular messages and system messages separately func convertMessages(bifrostMessages []schemas.ChatMessage) ([]BedrockMessage, []BedrockSystemMessage, error) { var messages []BedrockMessage var systemMessages []BedrockSystemMessage for i := 0; i < len(bifrostMessages); i++ { msg := bifrostMessages[i] switch msg.Role { case schemas.ChatMessageRoleSystem: // Convert system message systemMsgs, err := convertSystemMessages(msg) if err != nil { return nil, nil, fmt.Errorf("failed to convert system message: %w", err) } systemMessages = append(systemMessages, systemMsgs...) case schemas.ChatMessageRoleUser, schemas.ChatMessageRoleAssistant: // Convert regular message bedrockMsg, err := convertMessage(msg) if err != nil { return nil, nil, fmt.Errorf("failed to convert message: %w", err) } messages = append(messages, bedrockMsg) case schemas.ChatMessageRoleTool: // Collect all consecutive tool messages and group them into a single user message var toolMessages []schemas.ChatMessage toolMessages = append(toolMessages, msg) // Look ahead for more consecutive tool messages for j := i + 1; j < len(bifrostMessages) && bifrostMessages[j].Role == schemas.ChatMessageRoleTool; j++ { toolMessages = append(toolMessages, bifrostMessages[j]) i = j } // Convert all collected tool messages into a single Bedrock message bedrockMsg, err := convertToolMessages(toolMessages) if err != nil { return nil, nil, fmt.Errorf("failed to convert tool messages: %w", err) } messages = append(messages, bedrockMsg) default: return nil, nil, fmt.Errorf("unsupported message role: %s", msg.Role) } } return messages, systemMessages, nil } // convertSystemMessages converts a Bifrost system message to Bedrock format func convertSystemMessages(msg schemas.ChatMessage) ([]BedrockSystemMessage, error) { systemMsgs := []BedrockSystemMessage{} // Convert content if msg.Content.ContentStr != nil { systemMsgs = append(systemMsgs, BedrockSystemMessage{ Text: msg.Content.ContentStr, }) } else if msg.Content.ContentBlocks != nil { for _, block := range msg.Content.ContentBlocks { // Handle Bedrock native format where type may be empty but text is set directly blockType := block.Type if blockType == "" && block.Text != nil { blockType = schemas.ChatContentBlockTypeText } if blockType == schemas.ChatContentBlockTypeText && block.Text != nil { systemMsgs = append(systemMsgs, BedrockSystemMessage{ Text: block.Text, }) if block.CacheControl != nil { systemMsgs = append(systemMsgs, BedrockSystemMessage{ CachePoint: &BedrockCachePoint{ Type: BedrockCachePointTypeDefault, }, }) } } else if block.CachePoint != nil { // Handle standalone cache point blocks systemMsgs = append(systemMsgs, BedrockSystemMessage{ CachePoint: &BedrockCachePoint{ Type: BedrockCachePointTypeDefault, }, }) } } } return systemMsgs, nil } // convertMessage converts a Bifrost message to Bedrock format func convertMessage(msg schemas.ChatMessage) (BedrockMessage, error) { bedrockMsg := BedrockMessage{ Role: BedrockMessageRole(msg.Role), } // Convert content var contentBlocks []BedrockContentBlock if msg.Content != nil { var err error contentBlocks, err = convertContent(*msg.Content) if err != nil { return BedrockMessage{}, fmt.Errorf("failed to convert content: %w", err) } } // Add tool calls if present (for assistant messages) if msg.ChatAssistantMessage != nil && msg.ChatAssistantMessage.ToolCalls != nil { for _, toolCall := range msg.ChatAssistantMessage.ToolCalls { toolUseBlock := convertToolCallToContentBlock(toolCall) contentBlocks = append(contentBlocks, toolUseBlock) } } // Add reasoning content if present (for multi-turn conversations with thinking) if msg.ChatAssistantMessage != nil && len(msg.ChatAssistantMessage.ReasoningDetails) > 0 { for _, detail := range msg.ChatAssistantMessage.ReasoningDetails { if detail.Type == schemas.BifrostReasoningDetailsTypeText { contentBlocks = append(contentBlocks, BedrockContentBlock{ ReasoningContent: &BedrockReasoningContent{ ReasoningText: &BedrockReasoningContentText{ Text: detail.Text, Signature: detail.Signature, }, }, }) } } } bedrockMsg.Content = contentBlocks return bedrockMsg, nil } // convertToolMessages converts multiple consecutive Bifrost tool messages to a single Bedrock message func convertToolMessages(msgs []schemas.ChatMessage) (BedrockMessage, error) { if len(msgs) == 0 { return BedrockMessage{}, fmt.Errorf("no tool messages provided") } bedrockMsg := BedrockMessage{ Role: "user", } var contentBlocks []BedrockContentBlock for _, msg := range msgs { var toolResultContent []BedrockContentBlock if msg.Content.ContentStr != nil { // Bedrock expects JSON to be a parsed object, not a string // Validate and compact JSON without parsing into Go types (preserves key ordering) var buf bytes.Buffer if err := json.Compact(&buf, []byte(*msg.Content.ContentStr)); err != nil { // If it's not valid JSON, wrap it as a text block instead toolResultContent = append(toolResultContent, BedrockContentBlock{ Text: msg.Content.ContentStr, }) } else { compacted := buf.Bytes() // Bedrock does not accept primitives or arrays directly in the json field if len(compacted) > 0 && compacted[0] == '{' { // Objects are valid as-is toolResultContent = append(toolResultContent, BedrockContentBlock{ JSON: json.RawMessage(compacted), }) } else if len(compacted) > 0 && compacted[0] == '[' { // Arrays need to be wrapped wrapped := make([]byte, 0, len(compacted)+len(`{"results":}`)) wrapped = append(wrapped, `{"results":`...) wrapped = append(wrapped, compacted...) wrapped = append(wrapped, '}') toolResultContent = append(toolResultContent, BedrockContentBlock{ JSON: json.RawMessage(wrapped), }) } else { // Primitives (string, number, boolean, null) need to be wrapped wrapped := make([]byte, 0, len(compacted)+len(`{"value":}`)) wrapped = append(wrapped, `{"value":`...) wrapped = append(wrapped, compacted...) wrapped = append(wrapped, '}') toolResultContent = append(toolResultContent, BedrockContentBlock{ JSON: json.RawMessage(wrapped), }) } } } else if msg.Content.ContentBlocks != nil { for _, block := range msg.Content.ContentBlocks { switch block.Type { case schemas.ChatContentBlockTypeText: if block.Text != nil { toolResultContent = append(toolResultContent, BedrockContentBlock{ Text: block.Text, }) // Cache point must be in a separate block if block.CacheControl != nil { toolResultContent = append(toolResultContent, BedrockContentBlock{ CachePoint: &BedrockCachePoint{ Type: BedrockCachePointTypeDefault, }, }) } } case schemas.ChatContentBlockTypeImage: if block.ImageURLStruct != nil { imageSource, err := convertImageToBedrockSource(block.ImageURLStruct.URL) if err != nil { return BedrockMessage{}, fmt.Errorf("failed to convert image in tool result: %w", err) } toolResultContent = append(toolResultContent, BedrockContentBlock{ Image: imageSource, }) // Cache point must be in a separate block if block.CacheControl != nil { toolResultContent = append(toolResultContent, BedrockContentBlock{ CachePoint: &BedrockCachePoint{ Type: BedrockCachePointTypeDefault, }, }) } } } } } if msg.ChatToolMessage == nil { return BedrockMessage{}, fmt.Errorf("tool message missing required ChatToolMessage") } if msg.ChatToolMessage.ToolCallID == nil { return BedrockMessage{}, fmt.Errorf("tool message missing required ToolCallID") } // Create tool result content block for this tool message toolResultBlock := BedrockContentBlock{ ToolResult: &BedrockToolResult{ ToolUseID: *msg.ChatToolMessage.ToolCallID, Content: toolResultContent, Status: schemas.Ptr("success"), // Default to success }, } contentBlocks = append(contentBlocks, toolResultBlock) } bedrockMsg.Content = contentBlocks return bedrockMsg, nil } // convertContent converts Bifrost message content to Bedrock content blocks func convertContent(content schemas.ChatMessageContent) ([]BedrockContentBlock, error) { var contentBlocks []BedrockContentBlock if content.ContentStr != nil && *content.ContentStr != "" { // Simple text content (skip empty strings as Bedrock rejects blank text) contentBlocks = append(contentBlocks, BedrockContentBlock{ Text: content.ContentStr, }) } else if content.ContentBlocks != nil { // Multi-modal content for _, block := range content.ContentBlocks { bedrockBlocks, err := convertContentBlock(block) if err != nil { return nil, fmt.Errorf("failed to convert content block: %w", err) } contentBlocks = append(contentBlocks, bedrockBlocks...) } } return contentBlocks, nil } // convertContentBlock converts a Bifrost content block to Bedrock format func convertContentBlock(block schemas.ChatContentBlock) ([]BedrockContentBlock, error) { // Handle Bedrock native format where type may be empty but text is set directly // This occurs when requests are sent in Bedrock's native format (e.g., from Claude Code) // In Bedrock format: {"text": "hello"} vs OpenAI format: {"type": "text", "text": "hello"} if block.Type == "" && block.Text != nil { block.Type = schemas.ChatContentBlockTypeText } switch block.Type { case schemas.ChatContentBlockTypeText: // NOTE: we are doing this because LiteLLM does this for empty text blocks. // Ideally we should not play with the payload - we should let the provider handle it. // But for now, we are doing this to avoid the API error. // Once the world onboards on Bifrost - we should remove these shitty patterns. if block.Text == nil || *block.Text == "" { // Skip nil or empty text as Bedrock rejects blank text content blocks return []BedrockContentBlock{}, nil } blocks := []BedrockContentBlock{ { Text: block.Text, }, } // Cache point must be in a separate block if block.CacheControl != nil { blocks = append(blocks, BedrockContentBlock{ CachePoint: &BedrockCachePoint{ Type: BedrockCachePointTypeDefault, }, }) } return blocks, nil case schemas.ChatContentBlockTypeImage: if block.ImageURLStruct == nil { return nil, fmt.Errorf("image_url block missing image_url field") } imageSource, err := convertImageToBedrockSource(block.ImageURLStruct.URL) if err != nil { return nil, fmt.Errorf("failed to convert image: %w", err) } blocks := []BedrockContentBlock{ { Image: imageSource, }, } // Cache point must be in a separate block if block.CacheControl != nil { blocks = append(blocks, BedrockContentBlock{ CachePoint: &BedrockCachePoint{ Type: BedrockCachePointTypeDefault, }, }) } return blocks, nil case schemas.ChatContentBlockTypeFile: if block.File == nil { return nil, fmt.Errorf("file block missing file field") } documentSource := &BedrockDocumentSource{ Name: "document", Format: "pdf", Source: &BedrockDocumentSourceData{}, } // Set filename (normalized for Bedrock) if block.File.Filename != nil { documentSource.Name = normalizeBedrockFilename(*block.File.Filename) } // Convert MIME type to Bedrock format isText := false if block.File.FileType != nil { fileType := *block.File.FileType switch { case fileType == "text/plain" || fileType == "txt": documentSource.Format = "txt" isText = true case fileType == "text/markdown" || fileType == "md": documentSource.Format = "md" isText = true case fileType == "text/html" || fileType == "html": documentSource.Format = "html" isText = true case fileType == "text/csv" || fileType == "csv": documentSource.Format = "csv" isText = true case fileType == "application/msword" || fileType == "doc": documentSource.Format = "doc" case strings.Contains(fileType, "wordprocessingml") || fileType == "docx": documentSource.Format = "docx" case fileType == "application/vnd.ms-excel" || fileType == "xls": documentSource.Format = "xls" case strings.Contains(fileType, "spreadsheetml") || fileType == "xlsx": documentSource.Format = "xlsx" case strings.Contains(fileType, "pdf") || fileType == "pdf": documentSource.Format = "pdf" } } // Handle file data - strip data URL prefix if present if block.File.FileData != nil { fileData := *block.File.FileData // Check if it's a data URL and extract raw base64 if strings.HasPrefix(fileData, "data:") { urlInfo := schemas.ExtractURLTypeInfo(fileData) if urlInfo.DataURLWithoutPrefix != nil { documentSource.Source.Bytes = urlInfo.DataURLWithoutPrefix return []BedrockContentBlock{ { Document: documentSource, }, }, nil } } // Set text or bytes based on file type if isText { documentSource.Source.Text = &fileData // Plain text encoded := base64.StdEncoding.EncodeToString([]byte(fileData)) documentSource.Source.Bytes = &encoded // Also sets Bytes } else { documentSource.Source.Bytes = &fileData } } return []BedrockContentBlock{ { Document: documentSource, }, }, nil case schemas.ChatContentBlockTypeInputAudio: // Bedrock doesn't support audio input in Converse API return nil, fmt.Errorf("audio input not supported in Bedrock Converse API") default: // Handle cache-point-only blocks (Type is empty but CachePoint is set) if block.Type == "" && block.CachePoint != nil { return []BedrockContentBlock{ { CachePoint: &BedrockCachePoint{ Type: BedrockCachePointTypeDefault, }, }, }, nil } return nil, fmt.Errorf("unsupported content block type: %s", block.Type) } } // convertImageToBedrockSource converts a Bifrost image URL to Bedrock image source // Uses centralized utility functions like Anthropic converter // Returns an error for URL-based images (non-base64) since Bedrock requires base64 data func convertImageToBedrockSource(imageURL string) (*BedrockImageSource, error) { // Use centralized utility functions from schemas package sanitizedURL, err := schemas.SanitizeImageURL(imageURL) if err != nil { return nil, fmt.Errorf("failed to sanitize image URL: %w", err) } urlTypeInfo := schemas.ExtractURLTypeInfo(sanitizedURL) // Check if this is a URL-based image (not base64/data URI) if urlTypeInfo.Type != schemas.ImageContentTypeBase64 || urlTypeInfo.DataURLWithoutPrefix == nil { return nil, fmt.Errorf("only base64-encoded images (data URI format) are supported; remote image URLs are not allowed") } // Determine format from media type or default to jpeg format := "jpeg" if urlTypeInfo.MediaType != nil { switch *urlTypeInfo.MediaType { case "image/png": format = "png" case "image/gif": format = "gif" case "image/webp": format = "webp" case "image/jpeg", "image/jpg": format = "jpeg" } } imageSource := &BedrockImageSource{ Format: format, Source: BedrockImageSourceData{ Bytes: urlTypeInfo.DataURLWithoutPrefix, }, } return imageSource, nil } // convertResponseFormatToTool converts a response_format parameter to a Bedrock tool // Returns nil if no response_format is present or if it's not a json_schema type // Ref: https://aws.amazon.com/blogs/machine-learning/structured-data-response-with-amazon-bedrock-prompt-engineering-and-tool-use/ func convertResponseFormatToTool( ctx *schemas.BifrostContext, model string, params *schemas.ChatParameters, ) (*BedrockTool, any) { if params == nil || params.ResponseFormat == nil { return nil, nil } responseFormatMap, ok := schemas.SafeExtractOrderedMap(*params.ResponseFormat) if !ok || responseFormatMap == nil { return nil, nil } // Check if type is "json_schema" formatTypeRaw, ok := responseFormatMap.Get("type") if !ok { return nil, nil } formatType, ok := schemas.SafeExtractString(formatTypeRaw) if !ok || formatType != "json_schema" { return nil, nil } // Extract json_schema object jsonSchemaRaw, ok := responseFormatMap.Get("json_schema") if !ok { return nil, nil } jsonSchemaObj, ok := schemas.SafeExtractOrderedMap(jsonSchemaRaw) if !ok || jsonSchemaObj == nil { return nil, nil } schemaObj, ok := jsonSchemaObj.Get("schema") if !ok { return nil, nil } // Anthropic Bedrock supports native output_config.format. Keep this provider-specific // conversion encapsulated here, and let caller just apply returned values. if schemas.IsAnthropicModel(model) { return nil, newAnthropicOutputFormatOrderedMap(schemaObj) } // Extract name and schema toolNameRaw, hasName := jsonSchemaObj.Get("name") toolName, ok := schemas.SafeExtractString(toolNameRaw) if !hasName || !ok || toolName == "" { toolName = "json_response" } // Extract description from schema if available description := "Returns structured JSON output" if schemaMap, ok := schemas.SafeExtractOrderedMap(schemaObj); ok && schemaMap != nil { if descRaw, hasDesc := schemaMap.Get("description"); hasDesc { if desc, ok := schemas.SafeExtractString(descRaw); ok && desc != "" { description = desc } } } else if schemaMap, ok := schemaObj.(map[string]interface{}); ok { if desc, ok := schemaMap["description"].(string); ok && desc != "" { description = desc } } // set bifrost context key structured output tool name toolName = fmt.Sprintf("bf_so_%s", toolName) ctx.SetValue(schemas.BifrostContextKeyStructuredOutputToolName, toolName) // Create the Bedrock tool schemaObjBytes, err := providerUtils.MarshalSorted(schemaObj) if err != nil { return nil, nil } return &BedrockTool{ ToolSpec: &BedrockToolSpec{ Name: toolName, Description: schemas.Ptr(description), InputSchema: BedrockToolInputSchema{ JSON: json.RawMessage(schemaObjBytes), }, }, }, nil } // convertTextFormatToTool converts a Responses text.format config to either a // synthetic Bedrock tool or an Anthropic-native output_config.format value. func convertTextFormatToTool(ctx *schemas.BifrostContext, model string, textConfig *schemas.ResponsesTextConfig) (*BedrockTool, any) { if textConfig == nil || textConfig.Format == nil { return nil, nil } format := textConfig.Format if format.Type != "json_schema" { return nil, nil } toolName := "json_response" if format.Name != nil && strings.TrimSpace(*format.Name) != "" { toolName = strings.TrimSpace(*format.Name) } description := "Returns structured JSON output" if format.JSONSchema == nil || format.JSONSchema.Schema == nil { return nil, nil // Schema is required for structured output } if format.JSONSchema.Description != nil { description = *format.JSONSchema.Description } schemaObj := *format.JSONSchema.Schema if schemas.IsAnthropicModel(model) { return nil, newAnthropicOutputFormatOrderedMap(schemaObj) } toolName = fmt.Sprintf("bf_so_%s", toolName) ctx.SetValue(schemas.BifrostContextKeyStructuredOutputToolName, toolName) schemaObjBytes2, err := providerUtils.MarshalSorted(schemaObj) if err != nil { return nil, nil } return &BedrockTool{ ToolSpec: &BedrockToolSpec{ Name: toolName, Description: schemas.Ptr(description), InputSchema: BedrockToolInputSchema{ JSON: json.RawMessage(schemaObjBytes2), }, }, }, nil } // convertInferenceConfig converts Bifrost parameters to Bedrock inference config func convertInferenceConfig(params *schemas.ChatParameters) *BedrockInferenceConfig { var config BedrockInferenceConfig if params.MaxCompletionTokens != nil { config.MaxTokens = params.MaxCompletionTokens } if params.Temperature != nil { config.Temperature = params.Temperature } if params.TopP != nil { config.TopP = params.TopP } if params.Stop != nil { config.StopSequences = params.Stop } return &config } // collectBedrockServerTools partitions kept tools into the function/custom // set (which convertToolConfig materializes into Converse's toolConfig.tools) // and the kept-server-tool set (which cannot be expressed via Converse's // typed toolSpec slot and must be tunneled via additionalModelRequestFields). // // Returns: // - serverTools: each ChatTool serialized to its Anthropic-native JSON shape // (e.g. `{"type":"computer_20251124","name":"computer","display_width_px":1280}`) // ready to drop into additionalModelRequestFields.tools. Per the comment on // ChatTool in core/schemas/chatcompletions.go:340-351, the default marshaler // produces this shape directly — no custom codec needed. // - betaHeaders: anthropic-beta header values derived from the server tool // Types, filtered through FilterBetaHeadersForProvider(schemas.Bedrock) so // only Bedrock-approved headers survive. Only high-confidence mappings are // derived here (computer_* and memory_*); callers relying on other betas // (e.g. text_editor-specific headers) should continue supplying them via // extra-headers / ctx — they flow through bedrock.go's existing // anthropic-beta HTTP header path. // // Unsupported server tools (e.g. web_search on Bedrock) are dropped upstream // by ValidateChatToolsForProvider, so they never reach this helper. func collectBedrockServerTools(params *schemas.ChatParameters) (serverTools []json.RawMessage, betaHeaders []string) { if params == nil || len(params.Tools) == 0 { return nil, nil } filtered, _ := anthropic.ValidateChatToolsForProvider(params.Tools, schemas.Bedrock) return collectBedrockServerToolsFromFiltered(filtered) } // collectBedrockServerToolsFromFiltered is the inner variant that accepts a // pre-filtered tool set (already run through ValidateChatToolsForProvider). // convertChatParameters filters once and passes the result to both this helper // and convertToolConfigFromFiltered to avoid re-filtering twice per request. func collectBedrockServerToolsFromFiltered(filtered []schemas.ChatTool) (serverTools []json.RawMessage, betaHeaders []string) { if len(filtered) == 0 { return nil, nil } seenBeta := make(map[string]struct{}) for _, tool := range filtered { if tool.Function != nil || tool.Custom != nil { continue } bytes, err := providerUtils.MarshalSorted(tool) if err != nil { continue } serverTools = append(serverTools, json.RawMessage(bytes)) for _, h := range deriveBedrockBetaHeadersForToolType(string(tool.Type)) { if _, ok := seenBeta[h]; ok { continue } seenBeta[h] = struct{}{} betaHeaders = append(betaHeaders, h) } } if len(betaHeaders) > 0 { // Gate through the Bedrock-approved beta-header list. betaHeaders = anthropic.FilterBetaHeadersForProvider(betaHeaders, schemas.Bedrock) } return serverTools, betaHeaders } // buildBedrockServerToolChoice emits an Anthropic-native tool_choice value // for tunneling through additionalModelRequestFields.tool_choice ONLY when // Converse's typed toolConfig.toolChoice path cannot express the caller's // intent: // // - Named pin of a kept server tool: convertToolConfig builds toolConfig.tools // from function/custom tools only, and its reconciliation (around line // 1274) drops any named pin that doesn't match an entry in that slice. // Server-tool names never appear there, so a legitimate pin like // tool_choice={type:"function", function:{name:"computer"}} gets silently // nuked. We tunnel {"type":"tool","name":"computer"} instead so the // forced-tool contract reaches Anthropic via Bedrock's merge. // - any/required with only server tools: convertToolConfig returns nil // entirely (empty-slice guard since bedrockTools is empty), so the typed // "any" contract is lost. We tunnel {"type":"any"} to preserve it. // // Returns (nil, false) when the typed Converse path is adequate (auto/none, // function-tool pin, any with function tools present, or a pin whose name // doesn't match any kept server tool). // // Anthropic tool_choice shape ref: platform.claude.com/docs/en/docs/agents-and-tools/tool-use/define-tools // ("Controlling Claude's output / Forcing tool use" — four options: // auto, any, tool, none; forced tool shape is {"type":"tool","name":"..."}). func buildBedrockServerToolChoice(params *schemas.ChatParameters, filtered []schemas.ChatTool) (json.RawMessage, bool) { if params == nil || params.ToolChoice == nil { return nil, false } // Resolve effective type and optional pinned name from either the string // or struct representation of ChatToolChoice. var ( choiceType schemas.ChatToolChoiceType pinnedName string ) if params.ToolChoice.ChatToolChoiceStr != nil { choiceType = schemas.ChatToolChoiceType(*params.ToolChoice.ChatToolChoiceStr) } else if params.ToolChoice.ChatToolChoiceStruct != nil { s := params.ToolChoice.ChatToolChoiceStruct choiceType = s.Type if s.Function != nil { pinnedName = s.Function.Name } else if s.Custom != nil { pinnedName = s.Custom.Name } } else { return nil, false } // Partition kept tools: server-tool name set, plus whether any // function/custom tool is present. serverToolNames := make(map[string]struct{}) hasFunctionOrCustom := false for _, tool := range filtered { if tool.Function != nil || tool.Custom != nil { hasFunctionOrCustom = true continue } if tool.Name != "" { serverToolNames[tool.Name] = struct{}{} } } switch choiceType { case schemas.ChatToolChoiceTypeFunction, schemas.ChatToolChoiceTypeCustom, schemas.ChatToolChoiceType("tool"): // Only tunnel when the pinned name matches a kept server tool. // Function/custom pins stay on the typed Converse path. if pinnedName == "" { return nil, false } if _, ok := serverToolNames[pinnedName]; !ok { return nil, false } bytes, err := providerUtils.MarshalSorted(map[string]any{ "type": "tool", "name": pinnedName, }) if err != nil { return nil, false } return json.RawMessage(bytes), true case schemas.ChatToolChoiceTypeAny, schemas.ChatToolChoiceTypeRequired: // When function/custom tools are present, Converse's typed // toolChoice.any handles the any contract — don't double-emit. if hasFunctionOrCustom || len(serverToolNames) == 0 { return nil, false } bytes, err := providerUtils.MarshalSorted(map[string]any{"type": "any"}) if err != nil { return nil, false } return json.RawMessage(bytes), true default: // auto, none, allowed_tools, empty, unknown — no tunneling. return nil, false } } // deriveBedrockBetaHeadersForToolType maps an Anthropic server-tool Type string // to the anthropic-beta header(s) Bedrock requires for the feature to activate. // Only high-confidence mappings are encoded here — both are anchored in // core/providers/anthropic/types.go (cite: B-header comments around lines 178-183). // Unknown prefixes return nil; callers can still inject betas via extra-headers. func deriveBedrockBetaHeadersForToolType(toolType string) []string { switch { case strings.HasPrefix(toolType, "computer_"): // computer_YYYYMMDD → computer-use-YYYY-MM-DD (Bedrock B-header). rest := strings.TrimPrefix(toolType, "computer_") if len(rest) == 8 { return []string{"computer-use-" + rest[0:4] + "-" + rest[4:6] + "-" + rest[6:8]} } return nil case strings.HasPrefix(toolType, "memory_"): // Memory activates via the context-management bundle on Bedrock // (see anthropic/types.go:179 — "context-management-2025-06-27 per // B-header (bundles memory)"). return []string{"context-management-2025-06-27"} } return nil } // convertToolConfig converts Bifrost tools to Bedrock tool config. // // Responsibilities (split from collectBedrockServerTools): // - Filters server tools the target provider doesn't support via // ValidateChatToolsForProvider (e.g. web_search on Bedrock per cited // docs — AWS user guide beta-header list, Anthropic overview feature // table). Silently stripped. // - Materializes function/custom tools into Converse's typed toolConfig.tools. // Kept server tools (bash_*, computer_*, memory_*, text_editor_*, // tool_search_tool_*) are NOT emitted here — they are handled separately // by collectBedrockServerTools → additionalModelRequestFields.tools, since // Converse's toolSpec slot has no shape for them. // - Returns nil instead of an empty-slice ToolConfig, since Bedrock's // Converse API rejects `"toolConfig": {"tools": []}` with a 400. func convertToolConfig(model string, params *schemas.ChatParameters) *BedrockToolConfig { if params == nil || len(params.Tools) == 0 { return nil } // Strip unsupported server tools before the conversion loop. filtered, _ := anthropic.ValidateChatToolsForProvider(params.Tools, schemas.Bedrock) return convertToolConfigFromFiltered(model, params, filtered) } // convertToolConfigFromFiltered is the inner variant that accepts a // pre-filtered tool set. convertChatParameters uses this to avoid filtering // twice (once here, once in collectBedrockServerTools). The public // convertToolConfig entry point is a thin wrapper preserved for tests. func convertToolConfigFromFiltered(model string, params *schemas.ChatParameters, filtered []schemas.ChatTool) *BedrockToolConfig { if params == nil { return nil } var bedrockTools []BedrockTool for _, tool := range filtered { if tool.Function != nil { // Serialize the parameters (or a default empty schema) to json.RawMessage var schemaObjectBytes []byte if tool.Function.Parameters != nil { // ToolFunctionParameters.MarshalJSON handles all fields including // properties, required, enum, additionalProperties, $defs, etc. var err error schemaObjectBytes, err = providerUtils.MarshalSorted(tool.Function.Parameters) if err != nil { continue } } else { // Fallback to empty object schema if no parameters schemaObjectBytes = []byte(`{"type":"object","properties":{}}`) } // Use the tool description if available, otherwise use a generic description description := "Function tool" if tool.Function.Description != nil { description = *tool.Function.Description } bedrockTool := BedrockTool{ ToolSpec: &BedrockToolSpec{ Name: tool.Function.Name, Description: new(description), InputSchema: BedrockToolInputSchema{ JSON: json.RawMessage(schemaObjectBytes), }, }, } bedrockTools = append(bedrockTools, bedrockTool) if tool.CacheControl != nil && !schemas.IsNovaModel(model) { bedrockTools = append(bedrockTools, BedrockTool{ CachePoint: &BedrockCachePoint{ Type: BedrockCachePointTypeDefault, }, }) } } } // Empty-guard: Bedrock's Converse API rejects {"toolConfig": {"tools": []}} // with a 400 "The provided request is not valid". If every incoming tool // was filtered out above (e.g. only server tools the target provider // doesn't support), omit ToolConfig entirely so the request is valid and // the model simply answers without tool access. if len(bedrockTools) == 0 { return nil } toolConfig := &BedrockToolConfig{ Tools: bedrockTools, } // Convert tool choice if params.ToolChoice != nil { toolChoice := convertToolChoice(*params.ToolChoice) if toolChoice != nil { // Reconcile: if the choice forces a specific tool by name, // verify that name still exists in the filtered tool set. // Without this, a caller that pinned a server tool we just // stripped (e.g. web_search on Bedrock) would ship a // toolChoice.tool.name ∉ tools, and Bedrock's Converse API // rejects that with a 400 ValidationException — defeating // the silent-strip contract. if toolChoice.Tool != nil && toolChoice.Tool.Name != "" { found := false for _, bt := range bedrockTools { if bt.ToolSpec != nil && bt.ToolSpec.Name == toolChoice.Tool.Name { found = true break } } if !found { toolChoice = nil } } if toolChoice != nil { toolConfig.ToolChoice = toolChoice } } } return toolConfig } // convertToolChoice converts Bifrost tool choice to Bedrock format func convertToolChoice(toolChoice schemas.ChatToolChoice) *BedrockToolChoice { // String variant if toolChoice.ChatToolChoiceStr != nil { switch schemas.ChatToolChoiceType(*toolChoice.ChatToolChoiceStr) { case schemas.ChatToolChoiceTypeAuto: // Auto is Bedrock's default behavior - omit ToolChoice return nil case schemas.ChatToolChoiceTypeAny, schemas.ChatToolChoiceTypeRequired: return &BedrockToolChoice{Any: &BedrockToolChoiceAny{}} case schemas.ChatToolChoiceTypeNone: // Bedrock doesn't have explicit "none" - omit ToolChoice return nil case schemas.ChatToolChoiceTypeFunction: // Not representable without a name; expect struct form instead. return nil } } // Struct variant if toolChoice.ChatToolChoiceStruct != nil { switch toolChoice.ChatToolChoiceStruct.Type { case schemas.ChatToolChoiceTypeFunction: name := "" if toolChoice.ChatToolChoiceStruct.Function != nil { name = toolChoice.ChatToolChoiceStruct.Function.Name } if name != "" { return &BedrockToolChoice{ Tool: &BedrockToolChoiceTool{Name: name}, } } return nil case schemas.ChatToolChoiceTypeAny, schemas.ChatToolChoiceTypeRequired: return &BedrockToolChoice{Any: &BedrockToolChoiceAny{}} case schemas.ChatToolChoiceTypeNone: return nil } } return nil } // extractToolsFromConversationHistory analyzes conversation history for tool content func extractToolsFromConversationHistory(messages []schemas.ChatMessage) (bool, []BedrockTool) { hasToolContent := false toolsMap := make(map[string]BedrockTool) for _, msg := range messages { hasToolContent = checkMessageForToolContent(msg, toolsMap) || hasToolContent } tools := make([]BedrockTool, 0, len(toolsMap)) for _, tool := range toolsMap { tools = append(tools, tool) } return hasToolContent, tools } // checkMessageForToolContent checks a single message for tool content and updates the tools map func checkMessageForToolContent(msg schemas.ChatMessage, toolsMap map[string]BedrockTool) bool { hasContent := false // Check assistant tool calls if msg.ChatAssistantMessage != nil && msg.ChatAssistantMessage.ToolCalls != nil { hasContent = true for _, toolCall := range msg.ChatAssistantMessage.ToolCalls { if toolCall.Function.Name != nil { if _, exists := toolsMap[*toolCall.Function.Name]; !exists { // Create a complete schema object for extracted tools schemaObject := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{}, } extractedSchemaBytes, _ := providerUtils.MarshalSorted(schemaObject) toolsMap[*toolCall.Function.Name] = BedrockTool{ ToolSpec: &BedrockToolSpec{ Name: *toolCall.Function.Name, Description: schemas.Ptr("Tool extracted from conversation history"), InputSchema: BedrockToolInputSchema{ JSON: json.RawMessage(extractedSchemaBytes), }, }, } } } } } // Check tool messages if msg.ChatToolMessage != nil && msg.ChatToolMessage.ToolCallID != nil { hasContent = true } // Check content blocks if msg.Content != nil && msg.Content.ContentBlocks != nil { for _, block := range msg.Content.ContentBlocks { if block.Type == "tool_use" || block.Type == "tool_result" { hasContent = true } } } return hasContent } // convertToolCallToContentBlock converts a Bifrost tool call to a Bedrock content block func convertToolCallToContentBlock(toolCall schemas.ChatAssistantMessageToolCall) BedrockContentBlock { toolUseID := "" if toolCall.ID != nil { toolUseID = *toolCall.ID } toolName := "" if toolCall.Function.Name != nil { toolName = *toolCall.Function.Name } // Preserve original key ordering of tool arguments for prompt caching. // Using json.RawMessage avoids the map[string]interface{} round-trip // that would destroy key order. var input json.RawMessage args := strings.TrimSpace(toolCall.Function.Arguments) if args == "" { input = json.RawMessage("{}") } else { var buf bytes.Buffer if err := json.Compact(&buf, []byte(args)); err == nil { input = buf.Bytes() } else { // Preserve original payload instead of silently dropping args. input = json.RawMessage([]byte(args)) } } return BedrockContentBlock{ ToolUse: &BedrockToolUse{ ToolUseID: toolUseID, Name: toolName, Input: input, }, } } // ToBedrockError converts a BifrostError to BedrockError // This is a standalone function similar to ToAnthropicChatCompletionError func ToBedrockError(bifrostErr *schemas.BifrostError) *BedrockError { if bifrostErr == nil || bifrostErr.Error == nil { return &BedrockError{ Type: "InternalServerError", Message: "unknown error", } } // Safely extract message from nested error message := "" if bifrostErr.Error != nil { message = bifrostErr.Error.Message } bedrockErr := &BedrockError{ Message: message, } // Map error type/code if bifrostErr.Error != nil && bifrostErr.Error.Code != nil { bedrockErr.Type = *bifrostErr.Error.Code bedrockErr.Code = bifrostErr.Error.Code } else if bifrostErr.Type != nil { bedrockErr.Type = *bifrostErr.Type } else { bedrockErr.Type = "InternalServerError" } return bedrockErr } // convertMapToToolFunctionParameters converts a map[string]interface{} to ToolFunctionParameters // This handles the conversion from flexible parameter formats to Bifrost's structured format func convertMapToToolFunctionParameters(paramsMap map[string]interface{}) *schemas.ToolFunctionParameters { if paramsMap == nil { return nil } params := &schemas.ToolFunctionParameters{} // Extract type if typeVal, ok := paramsMap["type"].(string); ok { params.Type = typeVal } // Extract description if descVal, ok := paramsMap["description"].(string); ok { params.Description = &descVal } // Extract properties if props, ok := schemas.SafeExtractOrderedMap(paramsMap["properties"]); ok { params.Properties = props } // Extract required if required, ok := paramsMap["required"].([]interface{}); ok { reqStrings := make([]string, 0, len(required)) for _, r := range required { if rStr, ok := r.(string); ok { reqStrings = append(reqStrings, rStr) } } params.Required = reqStrings } else if required, ok := paramsMap["required"].([]string); ok { params.Required = required } // Extract enum if enumVal, ok := paramsMap["enum"].([]interface{}); ok { enum := make([]string, 0, len(enumVal)) for _, v := range enumVal { if s, ok := v.(string); ok { enum = append(enum, s) } } params.Enum = enum } // Extract additionalProperties if addPropsVal, ok := paramsMap["additionalProperties"].(bool); ok { params.AdditionalProperties = &schemas.AdditionalPropertiesStruct{ AdditionalPropertiesBool: &addPropsVal, } } else if addPropsVal, ok := schemas.SafeExtractOrderedMap(paramsMap["additionalProperties"]); ok { params.AdditionalProperties = &schemas.AdditionalPropertiesStruct{ AdditionalPropertiesMap: addPropsVal, } } // Extract $defs (JSON Schema draft 2019-09+) if defsVal, ok := schemas.SafeExtractOrderedMap(paramsMap["$defs"]); ok { params.Defs = defsVal } // Extract definitions (legacy JSON Schema draft-07) if defsVal, ok := schemas.SafeExtractOrderedMap(paramsMap["definitions"]); ok { params.Definitions = defsVal } // Extract $ref if refVal, ok := paramsMap["$ref"].(string); ok { params.Ref = &refVal } // Extract items (array element schema) if itemsVal, ok := schemas.SafeExtractOrderedMap(paramsMap["items"]); ok { params.Items = itemsVal } // Extract minItems if minItemsVal, ok := bedrockExtractInt64(paramsMap["minItems"]); ok { params.MinItems = &minItemsVal } // Extract maxItems if maxItemsVal, ok := bedrockExtractInt64(paramsMap["maxItems"]); ok { params.MaxItems = &maxItemsVal } // Extract anyOf if anyOfVal, ok := paramsMap["anyOf"].([]interface{}); ok { anyOf := make([]schemas.OrderedMap, 0, len(anyOfVal)) for _, v := range anyOfVal { if m, ok := schemas.SafeExtractOrderedMap(v); ok { anyOf = append(anyOf, *m) } } params.AnyOf = anyOf } // Extract oneOf if oneOfVal, ok := paramsMap["oneOf"].([]interface{}); ok { oneOf := make([]schemas.OrderedMap, 0, len(oneOfVal)) for _, v := range oneOfVal { if m, ok := schemas.SafeExtractOrderedMap(v); ok { oneOf = append(oneOf, *m) } } params.OneOf = oneOf } // Extract allOf if allOfVal, ok := paramsMap["allOf"].([]interface{}); ok { allOf := make([]schemas.OrderedMap, 0, len(allOfVal)) for _, v := range allOfVal { if m, ok := schemas.SafeExtractOrderedMap(v); ok { allOf = append(allOf, *m) } } params.AllOf = allOf } // Extract format if formatVal, ok := paramsMap["format"].(string); ok { params.Format = &formatVal } // Extract pattern if patternVal, ok := paramsMap["pattern"].(string); ok { params.Pattern = &patternVal } // Extract minLength if minLengthVal, ok := bedrockExtractInt64(paramsMap["minLength"]); ok { params.MinLength = &minLengthVal } // Extract maxLength if maxLengthVal, ok := bedrockExtractInt64(paramsMap["maxLength"]); ok { params.MaxLength = &maxLengthVal } // Extract minimum if minVal, ok := bedrockExtractFloat64(paramsMap["minimum"]); ok { params.Minimum = &minVal } // Extract maximum if maxVal, ok := bedrockExtractFloat64(paramsMap["maximum"]); ok { params.Maximum = &maxVal } // Extract title if titleVal, ok := paramsMap["title"].(string); ok { params.Title = &titleVal } // Extract default if defaultVal, exists := paramsMap["default"]; exists { params.Default = defaultVal } // Extract nullable if nullableVal, ok := paramsMap["nullable"].(bool); ok { params.Nullable = &nullableVal } return params } // bedrockExtractInt64 extracts an int64 from various numeric types func bedrockExtractInt64(v interface{}) (int64, bool) { switch val := v.(type) { case int: return int64(val), true case int64: return val, true case float64: return int64(val), true case float32: return int64(val), true default: return 0, false } } // bedrockExtractFloat64 extracts a float64 from various numeric types func bedrockExtractFloat64(v interface{}) (float64, bool) { switch val := v.(type) { case float64: return val, true case float32: return float64(val), true case int: return float64(val), true case int64: return float64(val), true default: return 0, false } } // tryParseJSONIntoContentBlock try to parse input text into a JSON and returns a proper // BedrockContentBlock based on the result. func tryParseJSONIntoContentBlock(text string) BedrockContentBlock { // Validate and compact JSON without parsing into Go types (preserves key ordering) var buf bytes.Buffer if err := json.Compact(&buf, []byte(text)); err != nil { return BedrockContentBlock{Text: schemas.Ptr(text)} } compacted := buf.Bytes() // Bedrock does not accept primitives or arrays directly in the json field if len(compacted) > 0 && compacted[0] == '{' { // Objects are valid as-is return BedrockContentBlock{JSON: json.RawMessage(compacted)} } else if len(compacted) > 0 && compacted[0] == '[' { // Arrays need to be wrapped wrapped := make([]byte, 0, len(compacted)+len(`{"results":}`)) wrapped = append(wrapped, `{"results":`...) wrapped = append(wrapped, compacted...) wrapped = append(wrapped, '}') return BedrockContentBlock{JSON: json.RawMessage(wrapped)} } else { // Primitives (string, number, boolean, null) need to be wrapped wrapped := make([]byte, 0, len(compacted)+len(`{"value":}`)) wrapped = append(wrapped, `{"value":`...) wrapped = append(wrapped, compacted...) wrapped = append(wrapped, '}') return BedrockContentBlock{JSON: json.RawMessage(wrapped)} } }