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

1844 lines
61 KiB
Go

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