1844 lines
61 KiB
Go
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)}
|
|
}
|
|
}
|