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

2713 lines
92 KiB
Go

package anthropic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"github.com/bytedance/sonic"
"github.com/valyala/fasthttp"
providerUtils "github.com/maximhq/bifrost/core/providers/utils"
"github.com/maximhq/bifrost/core/schemas"
)
// anthropicToolTypePrefixToFeature maps Anthropic server-tool type prefixes
// to the corresponding ProviderFeatureSupport flag. Mirrors the structure of
// betaHeaderPrefixToFeature (defined later in this file) so tool-type gating
// and beta-header gating share the same shape.
//
// Prefix-based so future version bumps (e.g. web_search_20261231) flow
// through without a code change. Exact-match types (currently just
// "mcp_toolset") are handled separately.
var anthropicToolTypePrefixToFeature = map[string]func(ProviderFeatureSupport) bool{
"web_search_": func(f ProviderFeatureSupport) bool { return f.WebSearch },
"web_fetch_": func(f ProviderFeatureSupport) bool { return f.WebFetch },
"code_execution_": func(f ProviderFeatureSupport) bool { return f.CodeExecution },
"computer_": func(f ProviderFeatureSupport) bool { return f.ComputerUse },
"bash_": func(f ProviderFeatureSupport) bool { return f.Bash },
"memory_": func(f ProviderFeatureSupport) bool { return f.Memory },
"text_editor_": func(f ProviderFeatureSupport) bool { return f.TextEditor },
"tool_search_tool_": func(f ProviderFeatureSupport) bool { return f.ToolSearch },
}
// isAnthropicServerToolSupported returns whether the given Anthropic server-tool
// type string is supported by the provider's ProviderFeatureSupport. Unknown
// types return true (forward-compat: let the provider reject if truly invalid
// rather than Bifrost dropping a tool Anthropic has just added).
func isAnthropicServerToolSupported(toolType string, features ProviderFeatureSupport) bool {
// Exact-match types first.
if toolType == "mcp_toolset" {
return features.MCP
}
// Prefix match for versioned types.
for prefix, check := range anthropicToolTypePrefixToFeature {
if strings.HasPrefix(toolType, prefix) {
return check(features)
}
}
return true
}
// ValidateChatToolsForProvider is the chat-path mirror of
// ValidateToolsForProvider. It partitions []schemas.ChatTool into a keep-set
// (function/custom tools + server tools supported on the target provider)
// and a dropped-set (server-tool Type strings the provider doesn't support
// per ProviderFeatures).
//
// Does NOT mutate its input. Callers decide the policy (silent strip vs
// fail-fast). The Bedrock ChatCompletion path uses silent strip so the
// request still reaches the provider without the unsupported tool; the model
// responds with a prose completion instead of tool use.
//
// Unknown providers keep all tools (safe default for custom providers),
// matching ValidateToolsForProvider.
func ValidateChatToolsForProvider(tools []schemas.ChatTool, provider schemas.ModelProvider) (keep []schemas.ChatTool, dropped []string) {
features, ok := ProviderFeatures[provider]
if !ok {
return tools, nil
}
for _, tool := range tools {
// Function/custom tools are universal — always keep.
if tool.Function != nil || tool.Custom != nil {
keep = append(keep, tool)
continue
}
t := string(tool.Type)
if isAnthropicServerToolSupported(t, features) {
keep = append(keep, tool)
} else {
dropped = append(dropped, t)
}
}
return keep, dropped
}
// ValidateToolsForProvider checks if all tools in the request are supported by the given provider.
// Returns an error for the first unsupported tool found.
func ValidateToolsForProvider(tools []schemas.ResponsesTool, provider schemas.ModelProvider) error {
features, ok := ProviderFeatures[provider]
if !ok {
// Unknown provider — allow all tools (safe default for custom providers)
return nil
}
for _, tool := range tools {
switch tool.Type {
case schemas.ResponsesToolTypeWebSearch, schemas.ResponsesToolTypeWebSearchPreview:
if !features.WebSearch {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
case schemas.ResponsesToolTypeWebFetch:
if !features.WebFetch {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
case schemas.ResponsesToolTypeCodeInterpreter:
if !features.CodeExecution {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
case schemas.ResponsesToolTypeComputerUsePreview:
if !features.ComputerUse {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
case schemas.ResponsesToolTypeMCP:
if !features.MCP {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
case schemas.ResponsesToolTypeLocalShell:
if !features.Bash {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
case schemas.ResponsesToolTypeMemory:
if !features.Memory {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
case schemas.ResponsesToolTypeToolSearch:
if !features.ToolSearch {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
case schemas.ResponsesToolTypeFileSearch:
if !features.FileSearch {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
case schemas.ResponsesToolTypeImageGeneration:
if !features.ImageGeneration {
return fmt.Errorf("tool type '%s' is not supported by provider '%s'", tool.Type, provider)
}
// ResponsesToolTypeFunction, ResponsesToolTypeCustom, etc. are always allowed
}
}
return nil
}
var (
// Maps provider-specific finish reasons to Bifrost format
anthropicFinishReasonToBifrost = map[AnthropicStopReason]string{
AnthropicStopReasonEndTurn: "stop",
AnthropicStopReasonMaxTokens: "length",
AnthropicStopReasonStopSequence: "stop",
AnthropicStopReasonToolUse: "tool_calls",
AnthropicStopReasonCompaction: "compaction",
}
// Maps Bifrost finish reasons to provider-specific format
bifrostToAnthropicFinishReason = map[string]AnthropicStopReason{
"stop": AnthropicStopReasonEndTurn, // canonical default
"length": AnthropicStopReasonMaxTokens,
"tool_calls": AnthropicStopReasonToolUse,
"compaction": AnthropicStopReasonCompaction,
}
)
// stripUnsupportedAnthropicFields removes request-level and tool-level fields
// that the target Anthropic-family provider does not support, according to the
// ProviderFeatures map (types.go). Tool-type validation (fail-closed) is handled
// separately by ValidateToolsForProvider; this helper handles request-level
// fields (strip silently, since they're additive enhancements).
//
// Mutates req in place. Safe to call multiple times.
func stripUnsupportedAnthropicFields(req *AnthropicMessageRequest, provider schemas.ModelProvider, model string) {
if req == nil {
return
}
features, ok := ProviderFeatures[provider]
if !ok {
// Unknown provider — safe default: don't strip anything.
return
}
// Request-level fields gated by ProviderFeatures flags.
if req.Container != nil {
// Skills form (object with skills[]) is beta-gated; bare string id is universal.
// Intent signal: non-empty skills = caller explicitly wants skills; empty
// skills:[] = likely caller oversight we can silently correct.
hasSkills := req.Container.ContainerObject != nil && len(req.Container.ContainerObject.Skills) > 0
// Strip an explicit empty or non-empty skills array on Skills=false
// providers. omitempty already handles this at serialize time for empty
// arrays, but we clear it explicitly so hasSkills-based decisions below
// and raw-path parity both stay correct.
if !features.Skills && req.Container.ContainerObject != nil && req.Container.ContainerObject.Skills != nil {
req.Container.ContainerObject.Skills = nil
}
switch {
case hasSkills && !features.Skills:
// Caller wanted non-empty skills but provider doesn't support them.
req.Container = nil
case !hasSkills && !features.ContainerBasic:
req.Container = nil
}
}
if len(req.MCPServers) > 0 && !features.MCP {
req.MCPServers = nil
}
// Speed is both provider-gated (FastMode flag) and model-gated
// (Opus 4.6 only per SupportsFastMode). Strip if either gate fails —
// Anthropic's API rejects speed:"fast" on non-Opus-4.6 models with a 400.
if req.Speed != nil && (!features.FastMode || !SupportsFastMode(model)) {
req.Speed = nil
}
if req.OutputConfig != nil && req.OutputConfig.TaskBudget != nil && !features.TaskBudgets {
req.OutputConfig.TaskBudget = nil
// Clean up an empty OutputConfig so it doesn't serialize as {}
if req.OutputConfig.Format == nil && req.OutputConfig.Effort == nil {
req.OutputConfig = nil
}
}
if req.InferenceGeo != nil && !features.InferenceGeo {
req.InferenceGeo = nil
}
// cache_control.scope — strip on providers without PromptCachingScope
// support at every slot scope can live: top-level request, tools, system
// blocks, and message content blocks. Vertex additionally uses the
// marshal-time SetStripCacheControlScope mechanism (vertex/utils.go:104,
// types.go MarshalJSON); after this strip runs, that marshal-time pass
// becomes a safe no-op for Vertex (nothing left to strip).
if !features.PromptCachingScope {
// Top-level.
if req.CacheControl != nil && req.CacheControl.Scope != nil {
req.CacheControl.Scope = nil
// If scope was the only meaningful field, drop the whole CacheControl
// so we don't serialize an empty object.
if req.CacheControl.TTL == nil && req.CacheControl.Type == "" {
req.CacheControl = nil
}
}
// Per-tool cache_control.scope.
for i := range req.Tools {
if req.Tools[i].CacheControl != nil && req.Tools[i].CacheControl.Scope != nil {
req.Tools[i].CacheControl.Scope = nil
// Drop the parent if scope was the only meaningful field.
if req.Tools[i].CacheControl.TTL == nil && req.Tools[i].CacheControl.Type == "" {
req.Tools[i].CacheControl = nil
}
}
}
// System block scopes.
if req.System != nil {
for i := range req.System.ContentBlocks {
if req.System.ContentBlocks[i].CacheControl != nil && req.System.ContentBlocks[i].CacheControl.Scope != nil {
req.System.ContentBlocks[i].CacheControl.Scope = nil
if req.System.ContentBlocks[i].CacheControl.TTL == nil && req.System.ContentBlocks[i].CacheControl.Type == "" {
req.System.ContentBlocks[i].CacheControl = nil
}
}
}
}
// Message block scopes.
for mi := range req.Messages {
for ci := range req.Messages[mi].Content.ContentBlocks {
cc := req.Messages[mi].Content.ContentBlocks[ci].CacheControl
if cc != nil && cc.Scope != nil {
cc.Scope = nil
if cc.TTL == nil && cc.Type == "" {
req.Messages[mi].Content.ContentBlocks[ci].CacheControl = nil
}
}
}
}
}
if req.ContextManagement != nil {
// Gate edits by their type — compaction vs context-editing flags.
kept := make([]ContextManagementEdit, 0, len(req.ContextManagement.Edits))
for _, edit := range req.ContextManagement.Edits {
switch edit.Type {
case ContextManagementEditTypeCompact:
if features.Compaction {
kept = append(kept, edit)
}
case ContextManagementEditTypeClearToolUses, ContextManagementEditTypeClearThinking:
if features.ContextEditing {
kept = append(kept, edit)
}
default:
// Unknown edit type — keep and let upstream reject.
kept = append(kept, edit)
}
}
if len(kept) == 0 {
req.ContextManagement = nil
} else {
req.ContextManagement.Edits = kept
}
}
// Tool-level flags — strip per-tool without dropping the tool itself.
for i := range req.Tools {
tool := &req.Tools[i]
if tool.DeferLoading != nil && !features.AdvancedToolUse {
tool.DeferLoading = nil
}
if len(tool.AllowedCallers) > 0 && !features.AdvancedToolUse {
tool.AllowedCallers = nil
}
// InputExamples has its own feature flag (InputExamples) because
// Bedrock supports the tool-examples-2025-10-29 header standalone —
// without the full advanced-tool-use-2025-11-20 bundle. On Anthropic
// and Azure, the bundle flag (AdvancedToolUse) is also set, so either
// gate would work there.
if len(tool.InputExamples) > 0 && !features.InputExamples {
tool.InputExamples = nil
}
if tool.EagerInputStreaming != nil && !features.EagerInputStreaming {
tool.EagerInputStreaming = nil
}
if tool.Strict != nil && !features.StructuredOutputs {
tool.Strict = nil
}
}
}
// stripUnsupportedFieldsFromRawBody is the raw-JSON equivalent of
// StripUnsupportedAnthropicFields. It mutates the request body bytes using
// sjson/gjson (preserving key order for prompt caching) so the raw-body
// passthrough path has behavioural parity with the typed conversion path.
//
// Scope: every field the typed helper handles.
// - top-level: speed (provider + model gated), container (.skills gated by
// features.Skills, bare string by features.ContainerBasic), mcp_servers,
// inference_geo, cache_control.scope, output_config.task_budget,
// context_management.edits[] (gated per edit type).
// - nested: tool.CacheControl.Scope, system block scopes, message block
// scopes (all stripped when !features.PromptCachingScope).
// - per-tool: defer_loading, allowed_callers (AdvancedToolUse bundle),
// input_examples (narrow InputExamples flag), eager_input_streaming
// (EagerInputStreaming), strict (StructuredOutputs).
//
// Unknown providers: safe default — no stripping (parity with the typed helper).
// Unknown edit types in context_management: left in place for the provider
// to reject (parity with the typed helper).
func stripUnsupportedFieldsFromRawBody(jsonBody []byte, provider schemas.ModelProvider, model string) ([]byte, error) {
if len(jsonBody) == 0 {
return jsonBody, nil
}
features, ok := ProviderFeatures[provider]
if !ok {
return jsonBody, nil
}
// Fall back to body-embedded model when caller didn't pass one.
if model == "" {
if modelResult := providerUtils.GetJSONField(jsonBody, "model"); modelResult.Exists() {
model = modelResult.String()
}
}
var err error
// speed — provider AND model gate
if providerUtils.JSONFieldExists(jsonBody, "speed") {
if !features.FastMode || !SupportsFastMode(model) {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "speed")
if err != nil {
return nil, fmt.Errorf("strip raw speed: %w", err)
}
}
}
// inference_geo
if !features.InferenceGeo && providerUtils.JSONFieldExists(jsonBody, "inference_geo") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "inference_geo")
if err != nil {
return nil, fmt.Errorf("strip raw inference_geo: %w", err)
}
}
// mcp_servers
if !features.MCP && providerUtils.JSONFieldExists(jsonBody, "mcp_servers") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "mcp_servers")
if err != nil {
return nil, fmt.Errorf("strip raw mcp_servers: %w", err)
}
}
// container — two variants: bare string id (ContainerBasic), or object
// {id, skills[]} where skills require Skills flag.
// Distinguishes three states: no skills field (bare form), skills:[] (empty
// array — caller oversight, silently strip), skills:[…] (non-empty — caller
// explicitly wants skills). Mirrors the typed path's hybrid decision.
if containerResult := providerUtils.GetJSONField(jsonBody, "container"); containerResult.Exists() {
hasSkillsField, hasNonEmptySkills := false, false
if containerResult.IsObject() {
if skills := containerResult.Get("skills"); skills.Exists() {
hasSkillsField = true
if skills.IsArray() && len(skills.Array()) > 0 {
hasNonEmptySkills = true
}
}
}
// Always strip the skills key on Skills=false providers — critical on
// the raw path since bytes flow directly to the provider and an
// explicit empty array would still be rejected as unknown field.
if !features.Skills && hasSkillsField {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "container.skills")
if err != nil {
return nil, fmt.Errorf("strip raw container.skills: %w", err)
}
}
drop := false
switch {
case hasNonEmptySkills:
drop = !features.Skills
default:
drop = !features.ContainerBasic
}
if drop {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "container")
if err != nil {
return nil, fmt.Errorf("strip raw container: %w", err)
}
}
}
// output_config.task_budget
if !features.TaskBudgets && providerUtils.JSONFieldExists(jsonBody, "output_config.task_budget") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "output_config.task_budget")
if err != nil {
return nil, fmt.Errorf("strip raw output_config.task_budget: %w", err)
}
// Drop an empty parent so we don't serialize output_config:{} (matches
// typed-path behavior at lines 129-134).
if oc := providerUtils.GetJSONField(jsonBody, "output_config"); oc.IsObject() && len(oc.Map()) == 0 {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "output_config")
if err != nil {
return nil, fmt.Errorf("strip raw output_config: %w", err)
}
}
}
// top-level cache_control.scope
if !features.PromptCachingScope && providerUtils.JSONFieldExists(jsonBody, "cache_control.scope") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "cache_control.scope")
if err != nil {
return nil, fmt.Errorf("strip raw cache_control.scope: %w", err)
}
// Drop an empty parent so we don't serialize cache_control:{} (matches
// typed-path behavior at lines 147-153).
if cc := providerUtils.GetJSONField(jsonBody, "cache_control"); cc.IsObject() && len(cc.Map()) == 0 {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "cache_control")
if err != nil {
return nil, fmt.Errorf("strip raw cache_control: %w", err)
}
}
}
// context_management.edits[] — gate per edit.type.
if editsResult := providerUtils.GetJSONField(jsonBody, "context_management.edits"); editsResult.Exists() && editsResult.IsArray() {
edits := editsResult.Array()
// Collect indices to drop (iterate forwards, delete in reverse).
dropIndices := []int{}
for i, edit := range edits {
editType := edit.Get("type").String()
keep := true
switch editType {
case string(ContextManagementEditTypeCompact):
keep = features.Compaction
case string(ContextManagementEditTypeClearToolUses), string(ContextManagementEditTypeClearThinking):
keep = features.ContextEditing
}
if !keep {
dropIndices = append(dropIndices, i)
}
}
if len(dropIndices) == len(edits) && len(edits) > 0 {
// All edits unsupported — drop the whole context_management.
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "context_management")
if err != nil {
return nil, fmt.Errorf("strip raw context_management: %w", err)
}
} else {
for i := len(dropIndices) - 1; i >= 0; i-- {
path := fmt.Sprintf("context_management.edits.%d", dropIndices[i])
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, path)
if err != nil {
return nil, fmt.Errorf("strip raw context_management.edits[%d]: %w", dropIndices[i], err)
}
}
}
}
// per-tool flags + nested scope
if toolsResult := providerUtils.GetJSONField(jsonBody, "tools"); toolsResult.Exists() && toolsResult.IsArray() {
for i := range toolsResult.Array() {
base := fmt.Sprintf("tools.%d", i)
if !features.AdvancedToolUse {
if providerUtils.JSONFieldExists(jsonBody, base+".defer_loading") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, base+".defer_loading")
if err != nil {
return nil, fmt.Errorf("strip raw %s.defer_loading: %w", base, err)
}
}
if providerUtils.JSONFieldExists(jsonBody, base+".allowed_callers") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, base+".allowed_callers")
if err != nil {
return nil, fmt.Errorf("strip raw %s.allowed_callers: %w", base, err)
}
}
}
if !features.InputExamples && providerUtils.JSONFieldExists(jsonBody, base+".input_examples") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, base+".input_examples")
if err != nil {
return nil, fmt.Errorf("strip raw %s.input_examples: %w", base, err)
}
}
if !features.EagerInputStreaming && providerUtils.JSONFieldExists(jsonBody, base+".eager_input_streaming") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, base+".eager_input_streaming")
if err != nil {
return nil, fmt.Errorf("strip raw %s.eager_input_streaming: %w", base, err)
}
}
if !features.StructuredOutputs && providerUtils.JSONFieldExists(jsonBody, base+".strict") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, base+".strict")
if err != nil {
return nil, fmt.Errorf("strip raw %s.strict: %w", base, err)
}
}
if !features.PromptCachingScope && providerUtils.JSONFieldExists(jsonBody, base+".cache_control.scope") {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, base+".cache_control.scope")
if err != nil {
return nil, fmt.Errorf("strip raw %s.cache_control.scope: %w", base, err)
}
// Drop the parent if cache_control is now an empty object, so
// we don't forward a malformed `cache_control: {}` marker.
if ccResult := providerUtils.GetJSONField(jsonBody, base+".cache_control"); ccResult.Exists() && ccResult.IsObject() && len(ccResult.Map()) == 0 {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, base+".cache_control")
if err != nil {
return nil, fmt.Errorf("strip raw %s.cache_control empty parent: %w", base, err)
}
}
}
}
}
// Nested scope on system blocks (system can be a string OR array of blocks).
if !features.PromptCachingScope {
if systemResult := providerUtils.GetJSONField(jsonBody, "system"); systemResult.Exists() && systemResult.IsArray() {
for i := range systemResult.Array() {
path := fmt.Sprintf("system.%d.cache_control.scope", i)
if providerUtils.JSONFieldExists(jsonBody, path) {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, path)
if err != nil {
return nil, fmt.Errorf("strip raw system[%d].cache_control.scope: %w", i, err)
}
parentPath := fmt.Sprintf("system.%d.cache_control", i)
if ccResult := providerUtils.GetJSONField(jsonBody, parentPath); ccResult.Exists() && ccResult.IsObject() && len(ccResult.Map()) == 0 {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, parentPath)
if err != nil {
return nil, fmt.Errorf("strip raw system[%d].cache_control empty parent: %w", i, err)
}
}
}
}
}
// Nested scope on messages[].content[] blocks.
if messagesResult := providerUtils.GetJSONField(jsonBody, "messages"); messagesResult.Exists() && messagesResult.IsArray() {
messages := messagesResult.Array()
for mi := range messages {
contentResult := providerUtils.GetJSONField(jsonBody, fmt.Sprintf("messages.%d.content", mi))
if !contentResult.Exists() || !contentResult.IsArray() {
continue
}
for ci := range contentResult.Array() {
path := fmt.Sprintf("messages.%d.content.%d.cache_control.scope", mi, ci)
if providerUtils.JSONFieldExists(jsonBody, path) {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, path)
if err != nil {
return nil, fmt.Errorf("strip raw messages[%d].content[%d].cache_control.scope: %w", mi, ci, err)
}
parentPath := fmt.Sprintf("messages.%d.content.%d.cache_control", mi, ci)
if ccResult := providerUtils.GetJSONField(jsonBody, parentPath); ccResult.Exists() && ccResult.IsObject() && len(ccResult.Map()) == 0 {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, parentPath)
if err != nil {
return nil, fmt.Errorf("strip raw messages[%d].content[%d].cache_control empty parent: %w", mi, ci, err)
}
}
}
}
}
}
}
return jsonBody, nil
}
// IsOpus47 returns true if the model is Claude Opus 4.7 or a later generation where:
// - Extended thinking (budget_tokens) is removed — only adaptive thinking is supported.
// - temperature, top_p, and top_k are not supported (setting them returns a 400).
func IsOpus47(model string) bool {
model = strings.ToLower(model)
if !strings.Contains(model, "opus") {
return false
}
return strings.Contains(model, "4-7") || strings.Contains(model, "4.7")
}
// SupportsNativeEffort returns true if the model supports Anthropic's native output_config.effort parameter.
// Currently supported on Claude Opus 4.5 and Opus 4.6.
func SupportsNativeEffort(model string) bool {
model = strings.ToLower(model)
if !strings.Contains(model, "opus") {
return false
}
return strings.Contains(model, "4-5") || strings.Contains(model, "4.5") ||
strings.Contains(model, "4-6") || strings.Contains(model, "4.6")
}
// SupportsFastMode returns true if the model supports speed:"fast" (research
// preview). Per Anthropic's fast-mode docs, only Opus 4.6 supports it;
// requests carrying speed:"fast" to any other model are rejected with 400.
// Beta header: fast-mode-2026-02-01.
//
// Source: https://platform.claude.com/docs/en/build-with-claude/fast-mode
func SupportsFastMode(model string) bool {
model = strings.ToLower(model)
if !strings.Contains(model, "opus") {
return false
}
return strings.Contains(model, "4-6") || strings.Contains(model, "4.6")
}
// SupportsAdaptiveThinking returns true if the model supports thinking.type: "adaptive".
// Currently supported on Claude Opus 4.6, Claude Sonnet 4.6, and Claude Opus 4.7+.
// On Opus 4.7+ adaptive is the only thinking-on mode; on Opus 4.6 and Sonnet 4.6 it
// coexists with the deprecated budget_tokens-based extended thinking.
func SupportsAdaptiveThinking(model string) bool {
if IsOpus47(model) {
return true
}
model = strings.ToLower(model)
if !strings.Contains(model, "4-6") && !strings.Contains(model, "4.6") {
return false
}
return strings.Contains(model, "opus") || strings.Contains(model, "sonnet")
}
// MapBifrostEffortToAnthropic maps a Bifrost effort level to an Anthropic effort level.
// Anthropic supports "low", "medium", "high", "max"; Bifrost also has "minimal" which maps to "low".
func MapBifrostEffortToAnthropic(effort string) string {
if effort == "minimal" {
return "low"
}
return effort
}
// setEffortOnOutputConfig merges the effort value into the request's OutputConfig,
// preserving any existing Format field (used for structured outputs).
func setEffortOnOutputConfig(req *AnthropicMessageRequest, effort string) {
if req.OutputConfig == nil {
req.OutputConfig = &AnthropicOutputConfig{}
}
req.OutputConfig.Effort = &effort
}
func getRequestBodyForResponses(ctx *schemas.BifrostContext, request *schemas.BifrostResponsesRequest, isStreaming bool, excludeFields []string) ([]byte, *schemas.BifrostError) {
// Large payload mode: body streams directly from the LP reader in completeRequest/
// setAnthropicRequestBody — skip all body building here (matches CheckContextAndGetRequestBody).
if providerUtils.IsLargePayloadPassthroughEnabled(ctx) {
return nil, nil
}
var jsonBody []byte
var err error
// Check if raw request body should be used
if useRawBody, ok := ctx.Value(schemas.BifrostContextKeyUseRawRequestBody).(bool); ok && useRawBody {
jsonBody = request.GetRawRequestBody()
// Update model with provider model (using gjson/sjson to preserve key order for prompt caching)
if modelResult := providerUtils.GetJSONField(jsonBody, "model"); modelResult.Exists() {
if modelStr := modelResult.String(); modelStr != "" {
_, model := schemas.ParseModelString(modelStr, schemas.Anthropic)
jsonBody, err = providerUtils.SetJSONField(jsonBody, "model", model)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
}
}
// Add max_tokens if not present
if !providerUtils.JSONFieldExists(jsonBody, "max_tokens") {
defaultMaxTokens := AnthropicDefaultMaxTokens
if modelResult := providerUtils.GetJSONField(jsonBody, "model"); modelResult.Exists() {
defaultMaxTokens = providerUtils.GetMaxOutputTokensOrDefault(modelResult.String(), AnthropicDefaultMaxTokens)
}
jsonBody, err = providerUtils.SetJSONField(jsonBody, "max_tokens", defaultMaxTokens)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
}
// Add stream if streaming
if isStreaming {
jsonBody, err = providerUtils.SetJSONField(jsonBody, "stream", true)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
}
// Strip auto-injectable server-side tools to prevent conflicts with API auto-injection
jsonBody, err = StripAutoInjectableTools(jsonBody)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
// Sanitize raw-body fields the target provider does not support.
// Behavioural parity with StripUnsupportedAnthropicFields on the typed path.
// Feature gating keyed to schemas.Anthropic (not providerName) to match
// the typed path below which also hardcodes schemas.Anthropic — ensures
// custom Anthropic aliases get identical feature lookup in both modes.
jsonBody, err = stripUnsupportedFieldsFromRawBody(jsonBody, schemas.Anthropic, "")
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
// Auto-inject matching anthropic-beta headers for fields the sanitizer
// preserved (speed, task_budget, cache_control.scope, input_examples,
// defer_loading, allowed_callers, eager_input_streaming, mcp_servers,
// structured outputs, etc). Without this, raw-body callers who supply
// gated fields but not headers would 400 upstream. Single source of
// truth: probe-unmarshal into the typed struct and reuse the typed
// path's header walker.
var probe AnthropicMessageRequest
if err := schemas.Unmarshal(jsonBody, &probe); err == nil {
AddMissingBetaHeadersToContext(ctx, &probe, schemas.Anthropic)
}
// Remove excluded fields
for _, field := range excludeFields {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, field)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
}
} else {
// Convert request to Anthropic format
reqBody, convErr := ToAnthropicResponsesRequest(ctx, request)
if convErr != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrRequestBodyConversion, convErr)
}
if reqBody == nil {
return nil, providerUtils.NewBifrostOperationError("request body is not provided", nil)
}
AddMissingBetaHeadersToContext(ctx, reqBody, schemas.Anthropic)
if isStreaming {
reqBody.Stream = schemas.Ptr(true)
}
// Marshal struct to JSON bytes
jsonBody, err = providerUtils.MarshalSorted(reqBody)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, fmt.Errorf("failed to marshal request body: %w", err))
}
// Merge ExtraParams into the JSON if passthrough is enabled
if ctx.Value(schemas.BifrostContextKeyPassthroughExtraParams) != nil && ctx.Value(schemas.BifrostContextKeyPassthroughExtraParams) == true {
extraParams := reqBody.GetExtraParams()
if len(extraParams) > 0 {
// Use MergeExtraParamsIntoJSON which preserves key order
jsonBody, err = providerUtils.MergeExtraParamsIntoJSON(jsonBody, extraParams)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
}
// Remove excluded fields after merging (using sjson to preserve order)
for _, field := range excludeFields {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, field)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
}
} else if len(excludeFields) > 0 {
// Remove excluded fields using sjson to preserve key order
for _, field := range excludeFields {
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, field)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
}
}
}
// delete fallbacks field
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "fallbacks")
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err)
}
return jsonBody, nil
}
// AddMissingBetaHeadersToContext analyzes the Anthropic request and adds missing beta headers to the context.
// The provider parameter controls which headers are included — unsupported headers for the given provider are skipped.
func AddMissingBetaHeadersToContext(ctx *schemas.BifrostContext, req *AnthropicMessageRequest, provider schemas.ModelProvider) error {
features, hasProvider := ProviderFeatures[provider]
headers := []string{}
hasCachingScope := false
if req.Tools != nil {
for _, tool := range req.Tools {
// Check for version-specific beta headers based on tool type
if tool.Type != nil {
switch *tool.Type {
case AnthropicToolTypeComputer20251124:
if !hasProvider || features.ComputerUse {
headers = appendUniqueHeader(headers, AnthropicComputerUseBetaHeader20251124)
}
case AnthropicToolTypeComputer20250124:
if !hasProvider || features.ComputerUse {
headers = appendUniqueHeader(headers, AnthropicComputerUseBetaHeader20250124)
}
}
}
// Check for strict (structured-outputs)
if tool.Strict != nil && *tool.Strict {
if !hasProvider || features.StructuredOutputs {
headers = appendUniqueHeader(headers, AnthropicStructuredOutputsBetaHeader)
}
}
// Check for advanced-tool-use features. defer_loading and
// allowed_callers are only available as part of the bundle
// header; input_examples additionally has a standalone header
// (tool-examples-2025-10-29) used on Bedrock where the bundle is
// not accepted.
if tool.DeferLoading != nil && *tool.DeferLoading {
if !hasProvider || features.AdvancedToolUse {
headers = appendUniqueHeader(headers, AnthropicAdvancedToolUseBetaHeader)
}
}
if len(tool.InputExamples) > 0 {
if !hasProvider || features.AdvancedToolUse {
// Bundle header covers input_examples transitively.
headers = appendUniqueHeader(headers, AnthropicAdvancedToolUseBetaHeader)
} else if features.InputExamples {
// Narrow standalone header (e.g. Bedrock).
headers = appendUniqueHeader(headers, AnthropicToolExamplesBetaHeader)
}
}
if len(tool.AllowedCallers) > 0 {
if !hasProvider || features.AdvancedToolUse {
headers = appendUniqueHeader(headers, AnthropicAdvancedToolUseBetaHeader)
}
}
// input_examples has both bundle coverage AND a standalone header.
// Prefer the bundle header when the provider accepts the bundle
// (covers input_examples transitively); fall back to the narrow
// standalone header (Bedrock) when only InputExamples is set.
if len(tool.InputExamples) > 0 {
if !hasProvider || features.AdvancedToolUse {
headers = appendUniqueHeader(headers, AnthropicAdvancedToolUseBetaHeader)
} else if features.InputExamples {
headers = appendUniqueHeader(headers, AnthropicToolExamplesBetaHeader)
}
}
// Check for fine-grained tool streaming (eager_input_streaming).
// Beta fine-grained-tool-streaming-2025-05-14 — required for
// input_json_delta streaming on custom tools.
if tool.EagerInputStreaming != nil && *tool.EagerInputStreaming {
if !hasProvider || features.EagerInputStreaming {
headers = appendUniqueHeader(headers, AnthropicEagerInputStreamingBetaHeader)
}
}
// Check for cache control with scope
if !hasCachingScope && tool.CacheControl != nil && tool.CacheControl.Scope != nil {
if !hasProvider || features.PromptCachingScope {
headers = appendUniqueHeader(headers, AnthropicPromptCachingScopeBetaHeader)
hasCachingScope = true
}
}
}
}
// Check for cache control with scope at the top level of the request
// (mirrors the tool/system/message checks below).
if !hasCachingScope && req.CacheControl != nil && req.CacheControl.Scope != nil {
if !hasProvider || features.PromptCachingScope {
headers = appendUniqueHeader(headers, AnthropicPromptCachingScopeBetaHeader)
hasCachingScope = true
}
}
// Check for compaction
if req.ContextManagement != nil {
for _, edit := range req.ContextManagement.Edits {
if edit.Type == ContextManagementEditTypeCompact {
if !hasProvider || features.Compaction {
headers = appendUniqueHeader(headers, AnthropicCompactionBetaHeader)
}
}
if edit.Type == ContextManagementEditTypeClearToolUses || edit.Type == ContextManagementEditTypeClearThinking {
if !hasProvider || features.ContextEditing {
headers = appendUniqueHeader(headers, AnthropicContextManagementBetaHeader)
}
}
}
}
// Check for MCP servers
if len(req.MCPServers) > 0 {
if !hasProvider || features.MCP {
headers = appendUniqueHeader(headers, AnthropicMCPClientBetaHeader)
}
}
// Check for interleaved thinking (required for older Claude 4 models with thinking enabled)
if req.Thinking != nil && req.Thinking.Type == "enabled" {
if !hasProvider || features.InterleavedThinking {
headers = appendUniqueHeader(headers, AnthropicInterleavedThinkingBetaHeader)
}
}
// Check for fast mode. Only add the beta header when both the provider
// supports fast mode AND the model does (Opus 4.6 only per
// SupportsFastMode); otherwise sending the header guarantees a 400.
if req.Speed != nil && *req.Speed == "fast" {
if (!hasProvider || features.FastMode) && SupportsFastMode(req.Model) {
headers = appendUniqueHeader(headers, AnthropicFastModeBetaHeader)
}
}
// Check for task budget
if req.OutputConfig != nil && req.OutputConfig.TaskBudget != nil {
if !hasProvider || features.TaskBudgets {
headers = appendUniqueHeader(headers, AnthropicTaskBudgetsBetaHeader)
}
}
// Check for output format (structured outputs)
if req.OutputFormat != nil {
if !hasProvider || features.StructuredOutputs {
headers = appendUniqueHeader(headers, AnthropicStructuredOutputsBetaHeader)
}
}
// Check for cache control with scope in system message (only if not already found)
if !hasCachingScope && req.System != nil && req.System.ContentBlocks != nil {
for _, block := range req.System.ContentBlocks {
if block.CacheControl != nil && block.CacheControl.Scope != nil {
if !hasProvider || features.PromptCachingScope {
headers = appendUniqueHeader(headers, AnthropicPromptCachingScopeBetaHeader)
hasCachingScope = true
}
break
}
}
}
// Check for cache control with scope in messages (only if not already found)
if !hasCachingScope {
for _, message := range req.Messages {
if message.Content.ContentBlocks != nil {
for _, block := range message.Content.ContentBlocks {
if block.CacheControl != nil && block.CacheControl.Scope != nil {
if !hasProvider || features.PromptCachingScope {
headers = appendUniqueHeader(headers, AnthropicPromptCachingScopeBetaHeader)
hasCachingScope = true
}
break
}
}
if hasCachingScope {
break
}
}
}
}
if len(headers) == 0 {
return nil
}
var extraHeaders map[string][]string
if ctx.Value(schemas.BifrostContextKeyExtraHeaders) == nil {
extraHeaders = map[string][]string{}
} else {
if ctxExtraHeaders, ok := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string); ok {
extraHeaders = ctxExtraHeaders
}
}
existing := extraHeaders[AnthropicBetaHeader]
if len(existing) == 0 {
extraHeaders[AnthropicBetaHeader] = headers
} else {
// Passthrough wins: skip auto-injected headers when a same-prefix header
// already exists from passthrough. This prevents conflicting versions
// (e.g. mcp-client-2025-04-04 + mcp-client-2025-11-20) in the same request.
for _, h := range headers {
if !betaHeaderPrefixExists(existing, h) {
existing = append(existing, h)
}
}
extraHeaders[AnthropicBetaHeader] = existing
}
ctx.SetValue(schemas.BifrostContextKeyExtraHeaders, extraHeaders)
return nil
}
// betaHeaderPrefixKnown maps known beta header prefixes for prefix-aware dedup.
var betaHeaderPrefixKnown = []string{
"computer-use-",
AnthropicStructuredOutputsBetaHeaderPrefix,
AnthropicMCPClientBetaHeaderPrefix,
AnthropicPromptCachingScopeBetaHeaderPrefix,
"compact-",
"context-management-",
"files-api-",
AnthropicAdvancedToolUseBetaHeaderPrefix,
AnthropicToolExamplesBetaHeaderPrefix,
AnthropicInterleavedThinkingBetaHeaderPrefix,
AnthropicSkillsBetaHeaderPrefix,
AnthropicContext1MBetaHeaderPrefix,
AnthropicFastModeBetaHeaderPrefix,
AnthropicRedactThinkingBetaHeaderPrefix,
AnthropicTaskBudgetsBetaHeaderPrefix,
AnthropicEagerInputStreamingBetaHeaderPrefix,
}
// betaHeaderPrefixExists checks if any header in existing shares a known prefix with newHeader.
// Returns true if a same-prefix header is already present (passthrough wins).
// Handles comma-separated values within a single header string (per HTTP spec).
func betaHeaderPrefixExists(existing []string, newHeader string) bool {
// Find which known prefix the new header belongs to
var matchedPrefix string
for _, prefix := range betaHeaderPrefixKnown {
if strings.HasPrefix(newHeader, prefix) {
matchedPrefix = prefix
break
}
}
match := func(candidate string) bool {
if matchedPrefix == "" {
return candidate == newHeader
}
return strings.HasPrefix(candidate, matchedPrefix)
}
for _, headerValue := range existing {
for _, candidate := range strings.Split(headerValue, ",") {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
if match(candidate) {
return true
}
}
}
return false
}
// ToolVersionRemap defines a mapping from an unsupported tool version to a supported one.
type ToolVersionRemap struct {
From string
To string
}
// providerToolVersionRemaps defines version downgrades per provider.
// When a raw request contains a tool type not supported by the target provider,
// it gets remapped to the supported version.
var providerToolVersionRemaps = map[schemas.ModelProvider][]ToolVersionRemap{
schemas.Vertex: {
// Vertex only supports basic web search, not dynamic filtering
{From: string(AnthropicToolTypeWebSearch20260209), To: string(AnthropicToolTypeWebSearch20250305)},
// Vertex does not support web fetch at all — no remap, these should error
// Vertex does not support code execution — no remap, these should error
},
// Bedrock does not support web search, web fetch, or code execution at all — no remaps
// Anthropic and Azure support all versions — no remaps needed
}
// unsupportedRawToolTypes lists tool type prefixes that should be rejected per provider
// when found in raw request bodies (no remap possible, the feature itself is unsupported).
var unsupportedRawToolTypes = map[schemas.ModelProvider][]string{
schemas.Vertex: {
"web_fetch_", // No web fetch support on Vertex
"code_execution", // No code execution on Vertex
},
schemas.Bedrock: {
"web_search_", // No web search on Bedrock
"web_fetch_", // No web fetch on Bedrock
"code_execution", // No code execution on Bedrock
},
}
// StripAutoInjectableTools removes code_execution tools from the raw JSON body's tools array
// when web_search or web_fetch tools are also present. The Anthropic API auto-injects
// code_execution when web_search_20260209 or web_fetch_20260209 is included in the request,
// and returns an error if code_execution is also explicitly included.
// This function strips code_execution only in that case to prevent the
// "Auto-injecting tools would conflict" error.
func StripAutoInjectableTools(jsonBody []byte) ([]byte, error) {
toolsResult := providerUtils.GetJSONField(jsonBody, "tools")
if !toolsResult.Exists() || !toolsResult.IsArray() {
return jsonBody, nil
}
tools := toolsResult.Array()
if len(tools) == 0 {
return jsonBody, nil
}
// Check if web_search or web_fetch is present — only then does Anthropic
// auto-inject code_execution, causing a conflict if it's also explicit.
hasWebSearchOrFetch := false
for _, tool := range tools {
toolType := tool.Get("type").String()
if strings.HasPrefix(toolType, "web_search_") || strings.HasPrefix(toolType, "web_fetch_") {
hasWebSearchOrFetch = true
break
}
}
if !hasWebSearchOrFetch {
return jsonBody, nil
}
// Collect indices of code_execution tools to strip
var indicesToStrip []int
for i, tool := range tools {
toolType := tool.Get("type").String()
if strings.HasPrefix(toolType, "code_execution") {
indicesToStrip = append(indicesToStrip, i)
}
}
if len(indicesToStrip) == 0 {
return jsonBody, nil
}
// If all tools would be stripped, remove the tools key entirely
if len(indicesToStrip) == len(tools) {
return providerUtils.DeleteJSONField(jsonBody, "tools")
}
// Delete in reverse order to preserve indices
var err error
for i := len(indicesToStrip) - 1; i >= 0; i-- {
path := fmt.Sprintf("tools.%d", indicesToStrip[i])
jsonBody, err = providerUtils.DeleteJSONField(jsonBody, path)
if err != nil {
return nil, fmt.Errorf("failed to strip auto-injectable tool at index %d: %w", indicesToStrip[i], err)
}
}
return jsonBody, nil
}
// RemapRawToolVersionsForProvider inspects tools in a raw JSON body and remaps
// unsupported tool versions to supported ones for the target provider.
// Returns an error if a tool type is fundamentally unsupported (no remap possible).
func RemapRawToolVersionsForProvider(jsonBody []byte, provider schemas.ModelProvider) ([]byte, error) {
toolsResult := providerUtils.GetJSONField(jsonBody, "tools")
if !toolsResult.Exists() || !toolsResult.IsArray() {
return jsonBody, nil
}
var err error
tools := toolsResult.Array()
// Check for unsupported types first
if prefixes, ok := unsupportedRawToolTypes[provider]; ok {
for _, tool := range tools {
toolType := tool.Get("type").String()
for _, prefix := range prefixes {
if strings.HasPrefix(toolType, prefix) {
return nil, fmt.Errorf("tool type '%s' is not supported by provider '%s'", toolType, provider)
}
}
}
}
// Apply version remaps
remaps, ok := providerToolVersionRemaps[provider]
if !ok {
return jsonBody, nil
}
for i, tool := range tools {
toolType := tool.Get("type").String()
for _, remap := range remaps {
if toolType == remap.From {
path := fmt.Sprintf("tools.%d.type", i)
jsonBody, err = providerUtils.SetJSONField(jsonBody, path, remap.To)
if err != nil {
return nil, fmt.Errorf("failed to remap tool type: %w", err)
}
break
}
}
}
return jsonBody, nil
}
// betaHeaderPrefixToFeature maps each known beta header prefix to a function that checks
// whether the feature is supported by the provider's default feature set.
var betaHeaderPrefixToFeature = map[string]func(ProviderFeatureSupport) bool{
"computer-use-": func(f ProviderFeatureSupport) bool { return f.ComputerUse },
AnthropicStructuredOutputsBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.StructuredOutputs },
AnthropicMCPClientBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.MCP },
AnthropicPromptCachingScopeBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.PromptCachingScope },
"compact-": func(f ProviderFeatureSupport) bool { return f.Compaction },
"context-management-": func(f ProviderFeatureSupport) bool { return f.ContextEditing },
"files-api-": func(f ProviderFeatureSupport) bool { return f.FilesAPI },
AnthropicAdvancedToolUseBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.AdvancedToolUse },
AnthropicToolExamplesBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.InputExamples },
AnthropicInterleavedThinkingBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.InterleavedThinking },
AnthropicSkillsBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.Skills },
AnthropicContext1MBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.Context1M },
AnthropicFastModeBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.FastMode },
AnthropicRedactThinkingBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.RedactThinking },
AnthropicTaskBudgetsBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.TaskBudgets },
AnthropicEagerInputStreamingBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.EagerInputStreaming },
}
// MergeBetaHeaders collects anthropic-beta values from provider ExtraHeaders and
// per-request context headers, deduplicating them.
func MergeBetaHeaders(providerExtraHeaders map[string]string, ctx context.Context) []string {
seen := make(map[string]bool)
var all []string
add := func(v string) {
for _, part := range strings.Split(v, ",") {
if t := strings.TrimSpace(part); t != "" && !seen[t] {
seen[t] = true
all = append(all, t)
}
}
}
for k, v := range providerExtraHeaders {
if strings.EqualFold(k, AnthropicBetaHeader) && v != "" {
add(v)
}
}
if ctxHeaders, ok := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string); ok {
for k, vals := range ctxHeaders {
if !strings.EqualFold(k, AnthropicBetaHeader) {
continue
}
for _, v := range vals {
add(v)
}
}
}
return all
}
// FilterBetaHeadersForProvider validates that all beta headers are supported by the given provider.
// Returns an error if a known beta header is not supported by the provider.
// Unknown headers are forwarded only to Anthropic; for other providers they are silently dropped.
// If overrides is non-nil, its entries (keyed by prefix) take precedence over the hardcoded defaults.
func FilterBetaHeadersForProvider(headers []string, provider schemas.ModelProvider, overrides ...map[string]bool) []string {
features, hasProvider := ProviderFeatures[provider]
if !hasProvider {
// Unknown provider — allow all headers (safe default for custom providers)
return headers
}
var overrideMap map[string]bool
if len(overrides) > 0 {
overrideMap = overrides[0]
}
filtered := make([]string, 0, len(headers))
for _, h := range headers {
tokens := strings.Split(h, ",")
for _, token := range tokens {
token = strings.TrimSpace(token)
if token == "" {
continue
}
// Find which known prefix this token matches
var matchedPrefix string
for _, prefix := range betaHeaderPrefixKnown {
if strings.HasPrefix(token, prefix) {
matchedPrefix = prefix
break
}
}
if matchedPrefix == "" {
// Check if any custom override prefix matches this unknown header
if overrideMap != nil {
matched := false
for prefix, allowed := range overrideMap {
if strings.HasPrefix(token, prefix) {
if allowed {
filtered = append(filtered, token)
}
// If not allowed, silently drop — custom overrides are user preferences,
// not hard incompatibilities that should break the request.
matched = true
break
}
}
if matched {
continue
}
}
// No override match — forward only to Anthropic API for forward compatibility.
// Non-Anthropic providers reject unrecognized headers, so drop unknown ones.
if provider == schemas.Anthropic {
filtered = append(filtered, token)
}
continue
}
// Check override first, then fall back to hardcoded feature support
supported := false
if overrideMap != nil {
if override, hasOverride := overrideMap[matchedPrefix]; hasOverride {
supported = override
} else if featureCheck, ok := betaHeaderPrefixToFeature[matchedPrefix]; ok {
supported = featureCheck(features)
}
} else if featureCheck, ok := betaHeaderPrefixToFeature[matchedPrefix]; ok {
supported = featureCheck(features)
}
if !supported {
continue
}
filtered = append(filtered, token)
}
}
return filtered
}
// appendUniqueHeader adds a header to the slice if not already present
func appendUniqueHeader(slice []string, item string) []string {
for _, s := range slice {
if s == item {
return slice
}
}
return append(slice, item)
}
// appendBetaHeader appends a beta header to the request, preserving any existing beta headers
func appendBetaHeader(req *fasthttp.Request, betaHeader string) {
existing := string(req.Header.Peek(AnthropicBetaHeader))
if existing == "" {
req.Header.Set(AnthropicBetaHeader, betaHeader)
return
}
// Check if header already present
for _, h := range strings.Split(existing, ",") {
if strings.TrimSpace(h) == betaHeader {
return
}
}
req.Header.Set(AnthropicBetaHeader, existing+","+betaHeader)
}
// convertChatResponseFormatToTool converts a response_format config to an Anthropic tool for structured output
// This is used when the provider is Vertex, which doesn't support native structured outputs
func convertChatResponseFormatToTool(ctx *schemas.BifrostContext, params *schemas.ChatParameters) *AnthropicTool {
if params == nil || params.ResponseFormat == nil {
return nil
}
// ResponseFormat is stored as interface{}, need to parse it
responseFormatMap, ok := (*params.ResponseFormat).(map[string]interface{})
if !ok {
return nil
}
// Check if type is "json_schema"
formatType, ok := responseFormatMap["type"].(string)
if !ok || formatType != "json_schema" {
return nil
}
// Extract json_schema object
jsonSchemaObj, ok := responseFormatMap["json_schema"].(map[string]interface{})
if !ok {
return nil
}
// Extract name and schema
toolName, ok := jsonSchemaObj["name"].(string)
if !ok || toolName == "" {
toolName = "json_response"
}
schemaObj, ok := jsonSchemaObj["schema"].(map[string]interface{})
if !ok {
return nil
}
// Extract description from schema if available
description := "Returns structured JSON output"
if desc, ok := schemaObj["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 Anthropic tool
normalizedSchema := normalizeSchemaForAnthropic(schemaObj)
schemaParams := convertMapToToolFunctionParameters(normalizedSchema)
return &AnthropicTool{
Name: toolName,
Description: schemas.Ptr(description),
InputSchema: schemaParams,
}
}
// convertResponsesTextFormatToTool converts a text config to an Anthropic tool for structured output
// This is used when the provider is Vertex, which doesn't support native structured outputs
func convertResponsesTextFormatToTool(ctx *schemas.BifrostContext, textConfig *schemas.ResponsesTextConfig) *AnthropicTool {
if textConfig == nil || textConfig.Format == nil {
return nil
}
format := textConfig.Format
if format.Type != "json_schema" {
return 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.Description != nil {
description = *format.JSONSchema.Description
}
toolName = fmt.Sprintf("bf_so_%s", toolName)
ctx.SetValue(schemas.BifrostContextKeyStructuredOutputToolName, toolName)
var schemaParams *schemas.ToolFunctionParameters
if format.JSONSchema != nil {
schemaParams = convertJSONSchemaToToolParameters(format.JSONSchema)
} else {
return nil // Schema is required for tooling
}
return &AnthropicTool{
Name: toolName,
Description: schemas.Ptr(description),
InputSchema: schemaParams,
}
}
// convertJSONSchemaToToolParameters directly converts ResponsesTextConfigFormatJSONSchema to ToolFunctionParameters
func convertJSONSchemaToToolParameters(schema *schemas.ResponsesTextConfigFormatJSONSchema) *schemas.ToolFunctionParameters {
if schema == nil {
return nil
}
// Default type to "object" if not specified
schemaType := "object"
if schema.Type != nil {
schemaType = *schema.Type
}
params := &schemas.ToolFunctionParameters{
Type: schemaType,
Description: schema.Description,
Required: schema.Required,
Enum: schema.Enum,
Ref: schema.Ref,
MinItems: schema.MinItems,
MaxItems: schema.MaxItems,
Format: schema.Format,
Pattern: schema.Pattern,
MinLength: schema.MinLength,
MaxLength: schema.MaxLength,
Minimum: schema.Minimum,
Maximum: schema.Maximum,
Title: schema.Title,
Default: schema.Default,
Nullable: schema.Nullable,
AdditionalProperties: schema.AdditionalProperties,
}
// Convert map[string]any to OrderedMap for Properties
if schema.Properties != nil {
if orderedMap, ok := schemas.SafeExtractOrderedMap(*schema.Properties); ok {
params.Properties = orderedMap
}
}
// Convert map[string]any to OrderedMap for Defs
if schema.Defs != nil {
if orderedMap, ok := schemas.SafeExtractOrderedMap(*schema.Defs); ok {
params.Defs = orderedMap
}
}
// Convert map[string]any to OrderedMap for Definitions
if schema.Definitions != nil {
if orderedMap, ok := schemas.SafeExtractOrderedMap(*schema.Definitions); ok {
params.Definitions = orderedMap
}
}
// Convert map[string]any to OrderedMap for Items
if schema.Items != nil {
if orderedMap, ok := schemas.SafeExtractOrderedMap(*schema.Items); ok {
params.Items = orderedMap
}
}
// Convert []map[string]any to []OrderedMap for composition fields
if len(schema.AnyOf) > 0 {
params.AnyOf = make([]schemas.OrderedMap, 0, len(schema.AnyOf))
for _, item := range schema.AnyOf {
if orderedMap, ok := schemas.SafeExtractOrderedMap(item); ok {
params.AnyOf = append(params.AnyOf, *orderedMap)
}
}
}
if len(schema.OneOf) > 0 {
params.OneOf = make([]schemas.OrderedMap, 0, len(schema.OneOf))
for _, item := range schema.OneOf {
if orderedMap, ok := schemas.SafeExtractOrderedMap(item); ok {
params.OneOf = append(params.OneOf, *orderedMap)
}
}
}
if len(schema.AllOf) > 0 {
params.AllOf = make([]schemas.OrderedMap, 0, len(schema.AllOf))
for _, item := range schema.AllOf {
if orderedMap, ok := schemas.SafeExtractOrderedMap(item); ok {
params.AllOf = append(params.AllOf, *orderedMap)
}
}
}
return params
}
// convertMapToToolFunctionParameters converts a map to ToolFunctionParameters
func convertMapToToolFunctionParameters(m map[string]interface{}) *schemas.ToolFunctionParameters {
params := &schemas.ToolFunctionParameters{}
if typeVal, ok := m["type"].(string); ok {
params.Type = typeVal
}
if desc, ok := m["description"].(string); ok {
params.Description = &desc
}
if props, ok := schemas.SafeExtractOrderedMap(m["properties"]); ok {
params.Properties = props
}
if req, ok := m["required"].([]interface{}); ok {
required := make([]string, 0, len(req))
for _, r := range req {
if str, ok := r.(string); ok {
required = append(required, str)
}
}
params.Required = required
}
if addProps, ok := m["additionalProperties"]; ok {
if addPropsBool, ok := addProps.(bool); ok {
params.AdditionalProperties = &schemas.AdditionalPropertiesStruct{
AdditionalPropertiesBool: &addPropsBool,
}
} else if addPropsMap, ok := schemas.SafeExtractOrderedMap(addProps); ok {
params.AdditionalProperties = &schemas.AdditionalPropertiesStruct{
AdditionalPropertiesMap: addPropsMap,
}
}
}
if defs, ok := schemas.SafeExtractOrderedMap(m["$defs"]); ok {
params.Defs = defs
}
if definitions, ok := schemas.SafeExtractOrderedMap(m["definitions"]); ok {
params.Definitions = definitions
}
if ref, ok := m["$ref"].(string); ok {
params.Ref = &ref
}
if items, ok := schemas.SafeExtractOrderedMap(m["items"]); ok {
params.Items = items
}
if minItems, ok := anthropicExtractInt64(m["minItems"]); ok {
params.MinItems = schemas.Ptr(minItems)
}
if maxItems, ok := anthropicExtractInt64(m["maxItems"]); ok {
params.MaxItems = schemas.Ptr(maxItems)
}
if anyOf, ok := m["anyOf"].([]interface{}); ok {
anyOfMaps := make([]schemas.OrderedMap, 0, len(anyOf))
for _, item := range anyOf {
if orderedMap, ok := schemas.SafeExtractOrderedMap(item); ok {
anyOfMaps = append(anyOfMaps, *orderedMap)
}
}
if len(anyOfMaps) > 0 {
params.AnyOf = anyOfMaps
}
}
if oneOf, ok := m["oneOf"].([]interface{}); ok {
oneOfMaps := make([]schemas.OrderedMap, 0, len(oneOf))
for _, item := range oneOf {
if orderedMap, ok := schemas.SafeExtractOrderedMap(item); ok {
oneOfMaps = append(oneOfMaps, *orderedMap)
}
}
if len(oneOfMaps) > 0 {
params.OneOf = oneOfMaps
}
}
if allOf, ok := m["allOf"].([]interface{}); ok {
allOfMaps := make([]schemas.OrderedMap, 0, len(allOf))
for _, item := range allOf {
if orderedMap, ok := schemas.SafeExtractOrderedMap(item); ok {
allOfMaps = append(allOfMaps, *orderedMap)
}
}
if len(allOfMaps) > 0 {
params.AllOf = allOfMaps
}
}
if format, ok := m["format"].(string); ok {
params.Format = &format
}
if pattern, ok := m["pattern"].(string); ok {
params.Pattern = &pattern
}
if minLength, ok := anthropicExtractInt64(m["minLength"]); ok {
params.MinLength = schemas.Ptr(minLength)
}
if maxLength, ok := anthropicExtractInt64(m["maxLength"]); ok {
params.MaxLength = schemas.Ptr(maxLength)
}
if minimum, ok := anthropicExtractFloat64(m["minimum"]); ok {
params.Minimum = &minimum
}
if maximum, ok := anthropicExtractFloat64(m["maximum"]); ok {
params.Maximum = &maximum
}
if title, ok := m["title"].(string); ok {
params.Title = &title
}
if enumVal, ok := m["enum"]; ok {
switch e := enumVal.(type) {
case []interface{}:
enumStrs := make([]string, 0, len(e))
for _, v := range e {
if s, ok := v.(string); ok {
enumStrs = append(enumStrs, s)
}
}
if len(enumStrs) > 0 {
params.Enum = enumStrs
}
case []string:
if len(e) > 0 {
params.Enum = e
}
}
}
if def, ok := m["default"]; ok {
params.Default = def
}
if nullable, ok := m["nullable"].(bool); ok {
params.Nullable = &nullable
}
if params.Type == "" {
params.Type = "object"
}
return params
}
// ConvertAnthropicFinishReasonToBifrost converts provider finish reasons to Bifrost format
func ConvertAnthropicFinishReasonToBifrost(providerReason AnthropicStopReason) string {
if bifrostReason, ok := anthropicFinishReasonToBifrost[providerReason]; ok {
return bifrostReason
}
return string(providerReason)
}
// ConvertBifrostFinishReasonToAnthropic converts Bifrost finish reasons to provider format
func ConvertBifrostFinishReasonToAnthropic(bifrostReason string) AnthropicStopReason {
if providerReason, ok := bifrostToAnthropicFinishReason[bifrostReason]; ok {
return providerReason
}
return AnthropicStopReason(bifrostReason)
}
// ConvertToAnthropicImageBlock converts a Bifrost image block to Anthropic format
// Uses the same pattern as the original buildAnthropicImageSourceMap function
func ConvertToAnthropicImageBlock(block schemas.ChatContentBlock) AnthropicContentBlock {
imageBlock := AnthropicContentBlock{
Type: AnthropicContentBlockTypeImage,
CacheControl: block.CacheControl,
Source: &AnthropicBlockSource{SourceObj: &AnthropicSource{}},
}
if block.ImageURLStruct == nil {
return imageBlock
}
// Use the centralized utility functions from schemas package
sanitizedURL, err := schemas.SanitizeImageURL(block.ImageURLStruct.URL)
if err != nil {
// Best-effort: treat as a regular URL without sanitization
imageBlock.Source.SourceObj.Type = "url"
imageBlock.Source.SourceObj.URL = &block.ImageURLStruct.URL
return imageBlock
}
urlTypeInfo := schemas.ExtractURLTypeInfo(sanitizedURL)
formattedImgContent := &AnthropicImageContent{
Type: urlTypeInfo.Type,
}
if urlTypeInfo.MediaType != nil {
formattedImgContent.MediaType = *urlTypeInfo.MediaType
}
if urlTypeInfo.DataURLWithoutPrefix != nil {
formattedImgContent.URL = *urlTypeInfo.DataURLWithoutPrefix
} else {
formattedImgContent.URL = sanitizedURL
}
// Convert to Anthropic source format
if formattedImgContent.Type == schemas.ImageContentTypeURL {
imageBlock.Source.SourceObj.Type = "url"
imageBlock.Source.SourceObj.URL = &formattedImgContent.URL
} else {
if formattedImgContent.MediaType != "" {
imageBlock.Source.SourceObj.MediaType = &formattedImgContent.MediaType
}
imageBlock.Source.SourceObj.Type = "base64"
// Use the base64 data without the data URL prefix
if urlTypeInfo.DataURLWithoutPrefix != nil {
imageBlock.Source.SourceObj.Data = urlTypeInfo.DataURLWithoutPrefix
} else {
imageBlock.Source.SourceObj.Data = &formattedImgContent.URL
}
}
return imageBlock
}
// ConvertToAnthropicDocumentBlock converts a Bifrost file block to Anthropic document format
func ConvertToAnthropicDocumentBlock(block schemas.ChatContentBlock) AnthropicContentBlock {
documentBlock := AnthropicContentBlock{
Type: AnthropicContentBlockTypeDocument,
CacheControl: block.CacheControl,
Source: &AnthropicBlockSource{SourceObj: &AnthropicSource{}},
}
if block.Citations != nil {
documentBlock.Citations = &AnthropicCitations{Config: block.Citations}
}
if block.File == nil {
return documentBlock
}
file := block.File
// Set title if provided
if file.Filename != nil {
documentBlock.Title = file.Filename
}
// Handle file URL
if file.FileURL != nil && *file.FileURL != "" {
documentBlock.Source.SourceObj.Type = "url"
documentBlock.Source.SourceObj.URL = file.FileURL
return documentBlock
}
// Handle file_data (base64 encoded data)
if file.FileData != nil && *file.FileData != "" {
fileData := *file.FileData
// Check if it's plain text based on file type
if file.FileType != nil && (*file.FileType == "text/plain" || *file.FileType == "txt") {
documentBlock.Source.SourceObj.Type = "text"
documentBlock.Source.SourceObj.Data = &fileData
return documentBlock
}
if strings.HasPrefix(fileData, "data:") {
urlTypeInfo := schemas.ExtractURLTypeInfo(fileData)
if urlTypeInfo.DataURLWithoutPrefix != nil {
// It's a data URL, extract the base64 content
documentBlock.Source.SourceObj.Type = "base64"
documentBlock.Source.SourceObj.Data = urlTypeInfo.DataURLWithoutPrefix
// Set media type from data URL or file type
if urlTypeInfo.MediaType != nil {
documentBlock.Source.SourceObj.MediaType = urlTypeInfo.MediaType
} else if file.FileType != nil {
documentBlock.Source.SourceObj.MediaType = file.FileType
}
return documentBlock
}
}
// Default to base64 for binary files
documentBlock.Source.SourceObj.Type = "base64"
documentBlock.Source.SourceObj.Data = &fileData
// Set media type
if file.FileType != nil {
documentBlock.Source.SourceObj.MediaType = file.FileType
} else {
// Default to PDF if not specified
mediaType := "application/pdf"
documentBlock.Source.SourceObj.MediaType = &mediaType
}
return documentBlock
}
return documentBlock
}
// ConvertResponsesFileBlockToAnthropic converts a Responses file block directly to Anthropic document format
func ConvertResponsesFileBlockToAnthropic(fileBlock *schemas.ResponsesInputMessageContentBlockFile, cacheControl *schemas.CacheControl, citations *schemas.Citations) AnthropicContentBlock {
documentBlock := AnthropicContentBlock{
Type: AnthropicContentBlockTypeDocument,
CacheControl: cacheControl,
Source: &AnthropicBlockSource{SourceObj: &AnthropicSource{}},
}
if citations != nil {
documentBlock.Citations = &AnthropicCitations{Config: citations}
}
if fileBlock == nil {
return documentBlock
}
// Set title if provided
if fileBlock.Filename != nil {
documentBlock.Title = fileBlock.Filename
}
// Handle file_data (base64 encoded data or plain text)
if fileBlock.FileData != nil && *fileBlock.FileData != "" {
fileData := *fileBlock.FileData
// Check if it's plain text based on file type
if fileBlock.FileType != nil && (*fileBlock.FileType == "text/plain" || *fileBlock.FileType == "txt") {
documentBlock.Source.SourceObj.Type = "text"
documentBlock.Source.SourceObj.Data = &fileData
documentBlock.Source.SourceObj.MediaType = schemas.Ptr("text/plain")
return documentBlock
}
// Check if it's a data URL (e.g., "data:application/pdf;base64,...")
if strings.HasPrefix(fileData, "data:") {
urlTypeInfo := schemas.ExtractURLTypeInfo(fileData)
if urlTypeInfo.DataURLWithoutPrefix != nil {
// It's a data URL, extract the base64 content
documentBlock.Source.SourceObj.Type = "base64"
documentBlock.Source.SourceObj.Data = urlTypeInfo.DataURLWithoutPrefix
// Set media type from data URL or file type
if urlTypeInfo.MediaType != nil {
documentBlock.Source.SourceObj.MediaType = urlTypeInfo.MediaType
} else if fileBlock.FileType != nil {
documentBlock.Source.SourceObj.MediaType = fileBlock.FileType
}
return documentBlock
}
}
// Default to base64 for binary files (raw base64 without prefix)
documentBlock.Source.SourceObj.Type = "base64"
documentBlock.Source.SourceObj.Data = &fileData
// Set media type
if fileBlock.FileType != nil {
documentBlock.Source.SourceObj.MediaType = fileBlock.FileType
} else {
// Default to PDF if not specified
mediaType := "application/pdf"
documentBlock.Source.SourceObj.MediaType = &mediaType
}
return documentBlock
}
// Handle file URL
if fileBlock.FileURL != nil && *fileBlock.FileURL != "" {
documentBlock.Source.SourceObj.Type = "url"
documentBlock.Source.SourceObj.URL = fileBlock.FileURL
return documentBlock
}
return documentBlock
}
func (block AnthropicContentBlock) ToBifrostContentImageBlock() schemas.ChatContentBlock {
return schemas.ChatContentBlock{
Type: schemas.ChatContentBlockTypeImage,
ImageURLStruct: &schemas.ChatInputImage{
URL: getImageURLFromBlock(block),
},
}
}
func getImageURLFromBlock(block AnthropicContentBlock) string {
// Image blocks always carry object-form sources (never string form).
if block.Source == nil || block.Source.SourceObj == nil {
return ""
}
src := block.Source.SourceObj
// Handle base64 data - convert to data URL
if src.Data != nil {
mime := "image/png"
if src.MediaType != nil && *src.MediaType != "" {
mime = *src.MediaType
}
return "data:" + mime + ";base64," + *src.Data
}
// Handle regular URLs
if src.URL != nil {
return *src.URL
}
return ""
}
// parseJSONInput returns a json.RawMessage that preserves the original key ordering
// of the JSON input. This is critical for prompt caching, which relies on exact
// byte-for-byte matching of the request prefix sent to providers.
func parseJSONInput(jsonStr string) json.RawMessage {
if jsonStr == "" || jsonStr == "{}" {
return json.RawMessage("{}")
}
// Compact removes insignificant whitespace while preserving key order.
compacted := compactJSONBytes([]byte(jsonStr))
if compacted != nil {
return json.RawMessage(compacted)
}
// If compaction fails (invalid JSON), return json.RawMessage of the raw string
return json.RawMessage(jsonStr)
}
// compactJSONBytes compacts JSON bytes, removing insignificant whitespace while
// preserving key ordering. Returns nil if the input is not valid JSON.
func compactJSONBytes(data []byte) []byte {
var buf bytes.Buffer
if err := json.Compact(&buf, data); err != nil {
return nil
}
return buf.Bytes()
}
// extractTypesFromValue extracts type strings from various formats (string, []string, []interface{})
func extractTypesFromValue(typeVal interface{}) []string {
switch t := typeVal.(type) {
case string:
return []string{t}
case []string:
return t
case []interface{}:
types := make([]string, 0, len(t))
for _, item := range t {
if typeStr, ok := item.(string); ok {
types = append(types, typeStr)
}
}
return types
default:
return nil
}
}
// filterEnumValuesByType filters enum values to only include those matching the specified JSON schema type.
// This ensures that when we split multi-type fields into anyOf branches, each branch only contains
// enum values compatible with its declared type.
func filterEnumValuesByType(enumValues []interface{}, schemaType string) []interface{} {
if len(enumValues) == 0 {
return nil
}
filtered := make([]interface{}, 0, len(enumValues))
for _, val := range enumValues {
// Determine the actual type of the enum value
var actualType string
switch val.(type) {
case string:
actualType = "string"
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
actualType = "integer"
case float32, float64:
// Check if it's actually an integer value in float form
if fv, ok := val.(float64); ok && fv == float64(int64(fv)) {
actualType = "integer"
} else {
actualType = "number"
}
case bool:
actualType = "boolean"
case nil:
actualType = "null"
default:
// For other types (objects, arrays), include them in all branches
filtered = append(filtered, val)
continue
}
// Include the value if its type matches the schema type
// Also handle "number" type which includes both integers and floats
if actualType == schemaType || (schemaType == "number" && actualType == "integer") {
filtered = append(filtered, val)
}
}
return filtered
}
// normalizeSchemaForAnthropic recursively normalizes a JSON schema to be compatible with Anthropic's API.
// This handles cases where:
// 1. type is an array like ["string", "null"] - converted to single type
// 2. type is an array with multiple types like ["string", "integer"] - converted to anyOf
// 3. Enums with nullable types need special handling
func normalizeSchemaForAnthropic(schema map[string]interface{}) map[string]interface{} {
if schema == nil {
return nil
}
normalized := make(map[string]interface{})
for k, v := range schema {
normalized[k] = v
}
// Handle type field if it's an array (e.g., ["string", "null"] or ["string", "integer"])
if typeVal, exists := normalized["type"]; exists {
types := extractTypesFromValue(typeVal)
if len(types) > 0 {
nonNullTypes := make([]string, 0, len(types))
for _, t := range types {
if t != "null" {
nonNullTypes = append(nonNullTypes, t)
}
}
if len(nonNullTypes) == 0 {
// Only null type
normalized["type"] = "null"
} else if len(nonNullTypes) == 1 && len(types) == 1 {
// Single type, no null (e.g., ["string"])
// Just use the single type
normalized["type"] = nonNullTypes[0]
} else {
// Multiple types OR single type with null
// Convert to anyOf structure for correctness
// Examples: ["string", "null"], ["string", "integer"], ["string", "integer", "null"]
delete(normalized, "type")
// Build anyOf with each non-null type
anyOfSchemas := make([]interface{}, 0, len(types))
for _, t := range nonNullTypes {
typeSchema := map[string]interface{}{"type": t}
// If there's an enum, filter enum values by type for each anyOf branch
if enumVal, hasEnum := normalized["enum"]; hasEnum {
// Convert enum to []interface{} if it's []string or other slice type
var enumArray []interface{}
switch e := enumVal.(type) {
case []interface{}:
enumArray = e
case []string:
enumArray = make([]interface{}, len(e))
for i, v := range e {
enumArray[i] = v
}
default:
// If enum is not a slice, skip filtering
typeSchema["enum"] = enumVal
anyOfSchemas = append(anyOfSchemas, typeSchema)
continue
}
filteredEnum := filterEnumValuesByType(enumArray, t)
if len(filteredEnum) > 0 {
typeSchema["enum"] = filteredEnum
}
}
anyOfSchemas = append(anyOfSchemas, typeSchema)
}
// If original had null, add it to anyOf
if len(nonNullTypes) < len(types) {
anyOfSchemas = append(anyOfSchemas, map[string]interface{}{"type": "null"})
}
normalized["anyOf"] = anyOfSchemas
// Remove enum from top level since it's now in anyOf branches
delete(normalized, "enum")
}
}
}
// Recursively normalize properties
if properties, ok := schema["properties"].(map[string]interface{}); ok {
newProps := make(map[string]interface{})
for key, prop := range properties {
if propMap, ok := prop.(map[string]interface{}); ok {
newProps[key] = normalizeSchemaForAnthropic(propMap)
} else {
newProps[key] = prop
}
}
normalized["properties"] = newProps
}
// Recursively normalize items (for arrays)
if items, ok := schema["items"].(map[string]interface{}); ok {
normalized["items"] = normalizeSchemaForAnthropic(items)
}
// Recursively normalize anyOf
if anyOf, ok := schema["anyOf"].([]interface{}); ok {
newAnyOf := make([]interface{}, 0, len(anyOf))
for _, item := range anyOf {
if itemMap, ok := item.(map[string]interface{}); ok {
newAnyOf = append(newAnyOf, normalizeSchemaForAnthropic(itemMap))
} else {
newAnyOf = append(newAnyOf, item)
}
}
normalized["anyOf"] = newAnyOf
}
// Recursively normalize oneOf
if oneOf, ok := schema["oneOf"].([]interface{}); ok {
newOneOf := make([]interface{}, 0, len(oneOf))
for _, item := range oneOf {
if itemMap, ok := item.(map[string]interface{}); ok {
newOneOf = append(newOneOf, normalizeSchemaForAnthropic(itemMap))
} else {
newOneOf = append(newOneOf, item)
}
}
normalized["oneOf"] = newOneOf
}
// Recursively normalize allOf
if allOf, ok := schema["allOf"].([]interface{}); ok {
newAllOf := make([]interface{}, 0, len(allOf))
for _, item := range allOf {
if itemMap, ok := item.(map[string]interface{}); ok {
newAllOf = append(newAllOf, normalizeSchemaForAnthropic(itemMap))
} else {
newAllOf = append(newAllOf, item)
}
}
normalized["allOf"] = newAllOf
}
// Recursively normalize definitions/defs
if definitions, ok := schema["definitions"].(map[string]interface{}); ok {
newDefs := make(map[string]interface{})
for key, def := range definitions {
if defMap, ok := def.(map[string]interface{}); ok {
newDefs[key] = normalizeSchemaForAnthropic(defMap)
} else {
newDefs[key] = def
}
}
normalized["definitions"] = newDefs
}
if defs, ok := schema["$defs"].(map[string]interface{}); ok {
newDefs := make(map[string]interface{})
for key, def := range defs {
if defMap, ok := def.(map[string]interface{}); ok {
newDefs[key] = normalizeSchemaForAnthropic(defMap)
} else {
newDefs[key] = def
}
}
normalized["$defs"] = newDefs
}
return normalized
}
// convertChatResponseFormatToAnthropicOutputFormat converts OpenAI Chat Completions response_format
// to Anthropic's output_format structure.
//
// OpenAI Chat Completions format:
//
// {
// "type": "json_schema",
// "json_schema": {
// "name": "MySchema",
// "schema": {...},
// "strict": true
// }
// }
//
// Anthropic's expected format (per https://docs.claude.com/en/docs/build-with-claude/structured-outputs):
//
// {
// "type": "json_schema",
// "name": "MySchema",
// "schema": {...},
// "strict": true
// }
func convertChatResponseFormatToAnthropicOutputFormat(responseFormat *interface{}) json.RawMessage {
if responseFormat == nil {
return nil
}
formatMap, ok := (*responseFormat).(map[string]interface{})
if !ok {
return nil
}
formatType, ok := formatMap["type"].(string)
if !ok || formatType != "json_schema" {
return nil
}
// Extract the nested json_schema object
jsonSchemaObj, ok := formatMap["json_schema"].(map[string]interface{})
if !ok {
return nil
}
// Build the flattened Anthropic-compatible output_format structure
// Note: name, description, and strict are NOT included as they are not permitted
// in Anthropic's GA structured outputs API (output_config.format)
outputFormat := map[string]interface{}{
"type": formatType,
}
if schema, ok := jsonSchemaObj["schema"].(map[string]interface{}); ok {
// Normalize the schema to handle type arrays like ["string", "null"]
normalizedSchema := normalizeSchemaForAnthropic(schema)
outputFormat["schema"] = normalizedSchema
}
result, err := providerUtils.MarshalSorted(outputFormat)
if err != nil {
return nil
}
return json.RawMessage(result)
}
// convertResponsesTextConfigToAnthropicOutputFormat converts OpenAI Responses API text config
// to Anthropic's output_format structure.
//
// OpenAI Responses API format:
//
// {
// "text": {
// "format": {
// "type": "json_schema",
// "schema": {...}
// }
// }
// }
//
// Anthropic's expected format (per https://docs.claude.com/en/docs/build-with-claude/structured-outputs):
//
// {
// "type": "json_schema",
// "schema": {...}
// }
func convertResponsesTextConfigToAnthropicOutputFormat(textConfig *schemas.ResponsesTextConfig) json.RawMessage {
if textConfig == nil || textConfig.Format == nil {
return nil
}
format := textConfig.Format
// Anthropic currently only supports json_schema type
if format.Type != "json_schema" {
return nil
}
// Build the Anthropic-compatible output_format structure
outputFormat := map[string]interface{}{
"type": format.Type,
}
if format.JSONSchema != nil {
// Convert the schema structure
schema := map[string]interface{}{}
if format.JSONSchema.Type != nil {
schema["type"] = *format.JSONSchema.Type
}
if format.JSONSchema.Properties != nil {
schema["properties"] = *format.JSONSchema.Properties
}
if len(format.JSONSchema.Required) > 0 {
schema["required"] = format.JSONSchema.Required
}
if format.JSONSchema.Type != nil && *format.JSONSchema.Type == "object" {
schema["additionalProperties"] = false
} else if format.JSONSchema.AdditionalProperties != nil {
schema["additionalProperties"] = *format.JSONSchema.AdditionalProperties
}
// Normalize the schema to handle type arrays like ["string", "null"]
normalizedSchema := normalizeSchemaForAnthropic(schema)
outputFormat["schema"] = normalizedSchema
}
result, err := providerUtils.MarshalSorted(outputFormat)
if err != nil {
return nil
}
return json.RawMessage(result)
}
// convertAnthropicOutputFormatToResponsesTextConfig converts Anthropic's output_format structure
// to OpenAI Responses API text config.
//
// Anthropic format:
//
// {
// "type": "json_schema",
// "schema": {...},
// }
//
// OpenAI Responses API format:
//
// {
// "text": {
// "format": {
// "type": "json_schema",
// "json_schema": {...},
// "name": "...",
// "strict": true
// }
// }
// }
func convertAnthropicOutputFormatToResponsesTextConfig(outputFormat json.RawMessage) *schemas.ResponsesTextConfig {
if outputFormat == nil {
return nil
}
// Unmarshal to map
var formatMap map[string]interface{}
if err := sonic.Unmarshal(outputFormat, &formatMap); err != nil {
return nil
}
// Extract type
formatType, ok := formatMap["type"].(string)
if !ok || formatType != "json_schema" {
return nil
}
format := &schemas.ResponsesTextConfigFormat{
Type: formatType,
}
// Extract name if present
if name, ok := formatMap["name"].(string); ok && strings.TrimSpace(name) != "" {
format.Name = schemas.Ptr(strings.TrimSpace(name))
} else {
format.Name = schemas.Ptr("output_format")
}
// Extract schema if present
if schemaMap, ok := formatMap["schema"].(map[string]interface{}); ok {
jsonSchema := &schemas.ResponsesTextConfigFormatJSONSchema{}
if schemaType, ok := schemaMap["type"].(string); ok {
jsonSchema.Type = &schemaType
}
if properties, ok := schemaMap["properties"].(map[string]interface{}); ok {
jsonSchema.Properties = &properties
}
if required, ok := schemaMap["required"].([]interface{}); ok {
requiredStrs := make([]string, 0, len(required))
for _, r := range required {
if rStr, ok := r.(string); ok {
requiredStrs = append(requiredStrs, rStr)
}
}
if len(requiredStrs) > 0 {
jsonSchema.Required = requiredStrs
}
}
if additionalProps, ok := schemaMap["additionalProperties"].(bool); ok {
jsonSchema.AdditionalProperties = &schemas.AdditionalPropertiesStruct{
AdditionalPropertiesBool: &additionalProps,
}
}
if additionalProps, ok := schemas.SafeExtractOrderedMap(schemaMap["additionalProperties"]); ok {
jsonSchema.AdditionalProperties = &schemas.AdditionalPropertiesStruct{
AdditionalPropertiesMap: additionalProps,
}
}
// Extract description
if description, ok := schemaMap["description"].(string); ok {
jsonSchema.Description = &description
}
// Extract $defs (JSON Schema draft 2019-09+)
if defs, ok := schemaMap["$defs"].(map[string]interface{}); ok {
jsonSchema.Defs = &defs
}
// Extract definitions (legacy JSON Schema draft-07)
if definitions, ok := schemaMap["definitions"].(map[string]interface{}); ok {
jsonSchema.Definitions = &definitions
}
// Extract $ref
if ref, ok := schemaMap["$ref"].(string); ok {
jsonSchema.Ref = &ref
}
// Extract items (array element schema)
if items, ok := schemaMap["items"].(map[string]interface{}); ok {
jsonSchema.Items = &items
}
// Extract minItems
if minItems, ok := anthropicExtractInt64(schemaMap["minItems"]); ok {
jsonSchema.MinItems = &minItems
}
// Extract maxItems
if maxItems, ok := anthropicExtractInt64(schemaMap["maxItems"]); ok {
jsonSchema.MaxItems = &maxItems
}
// Extract anyOf
if anyOf, ok := schemaMap["anyOf"].([]interface{}); ok {
anyOfMaps := make([]map[string]any, 0, len(anyOf))
for _, item := range anyOf {
if m, ok := item.(map[string]interface{}); ok {
anyOfMaps = append(anyOfMaps, m)
}
}
if len(anyOfMaps) > 0 {
jsonSchema.AnyOf = anyOfMaps
}
}
// Extract oneOf
if oneOf, ok := schemaMap["oneOf"].([]interface{}); ok {
oneOfMaps := make([]map[string]any, 0, len(oneOf))
for _, item := range oneOf {
if m, ok := item.(map[string]interface{}); ok {
oneOfMaps = append(oneOfMaps, m)
}
}
if len(oneOfMaps) > 0 {
jsonSchema.OneOf = oneOfMaps
}
}
// Extract allOf
if allOf, ok := schemaMap["allOf"].([]interface{}); ok {
allOfMaps := make([]map[string]any, 0, len(allOf))
for _, item := range allOf {
if m, ok := item.(map[string]interface{}); ok {
allOfMaps = append(allOfMaps, m)
}
}
if len(allOfMaps) > 0 {
jsonSchema.AllOf = allOfMaps
}
}
// Extract format
if formatVal, ok := schemaMap["format"].(string); ok {
jsonSchema.Format = &formatVal
}
// Extract pattern
if pattern, ok := schemaMap["pattern"].(string); ok {
jsonSchema.Pattern = &pattern
}
// Extract minLength
if minLength, ok := anthropicExtractInt64(schemaMap["minLength"]); ok {
jsonSchema.MinLength = &minLength
}
// Extract maxLength
if maxLength, ok := anthropicExtractInt64(schemaMap["maxLength"]); ok {
jsonSchema.MaxLength = &maxLength
}
// Extract minimum
if minimum, ok := anthropicExtractFloat64(schemaMap["minimum"]); ok {
jsonSchema.Minimum = &minimum
}
// Extract maximum
if maximum, ok := anthropicExtractFloat64(schemaMap["maximum"]); ok {
jsonSchema.Maximum = &maximum
}
// Extract title
if title, ok := schemaMap["title"].(string); ok {
jsonSchema.Title = &title
}
// Extract default
if defaultVal, exists := schemaMap["default"]; exists {
jsonSchema.Default = defaultVal
}
// Extract nullable
if nullable, ok := schemaMap["nullable"].(bool); ok {
jsonSchema.Nullable = &nullable
}
// Extract enum
if enum, ok := schemaMap["enum"].([]interface{}); ok {
enumStrs := make([]string, 0, len(enum))
for _, e := range enum {
if str, ok := e.(string); ok {
enumStrs = append(enumStrs, str)
}
}
if len(enumStrs) > 0 {
jsonSchema.Enum = enumStrs
}
} else if enumStrs, ok := schemaMap["enum"].([]string); ok && len(enumStrs) > 0 {
jsonSchema.Enum = enumStrs
}
format.JSONSchema = jsonSchema
}
return &schemas.ResponsesTextConfig{
Format: format,
}
}
// sanitizeWebSearchArguments sanitizes WebSearch tool arguments by removing conflicting domain filters.
// Anthropic only allows one of allowed_domains or blocked_domains, not both.
// This function handles empty and non-empty arrays:
// - If one array is empty, delete that one
// - If both arrays are filled, delete blocked_domains
// - If both arrays are empty, delete blocked_domains
func sanitizeWebSearchArguments(argumentsJSON string) string {
var toolArgs map[string]interface{}
if err := sonic.Unmarshal([]byte(argumentsJSON), &toolArgs); err != nil {
return argumentsJSON // Return original if parse fails
}
allowedVal, hasAllowed := toolArgs["allowed_domains"]
blockedVal, hasBlocked := toolArgs["blocked_domains"]
// Only process if both fields exist
if hasAllowed && hasBlocked {
// Helper function to check if array is empty
isEmptyArray := func(val interface{}) bool {
if arr, ok := val.([]interface{}); ok {
return len(arr) == 0
}
return false
}
allowedEmpty := isEmptyArray(allowedVal)
blockedEmpty := isEmptyArray(blockedVal)
var shouldDelete string
if allowedEmpty && !blockedEmpty {
// Delete allowed_domains if it's empty and blocked is not
shouldDelete = "allowed_domains"
} else if blockedEmpty && !allowedEmpty {
// Delete blocked_domains if it's empty and allowed is not
shouldDelete = "blocked_domains"
} else {
// Both are filled or both are empty: delete blocked_domains
shouldDelete = "blocked_domains"
}
delete(toolArgs, shouldDelete)
// Re-marshal the sanitized arguments
if sanitizedBytes, err := providerUtils.MarshalSorted(toolArgs); err == nil {
return string(sanitizedBytes)
}
}
return argumentsJSON
}
// attachWebSearchSourcesToCall finds a web_search_call by tool_use_id and attaches sources to it.
// It searches backwards through bifrostMessages to find the matching call and updates its action.
func attachWebSearchSourcesToCall(bifrostMessages []schemas.ResponsesMessage, toolUseID string, resultBlock AnthropicContentBlock, includeExtendedFields bool) {
// Search backwards to find matching web_search_call
for i := len(bifrostMessages) - 1; i >= 0; i-- {
msg := &bifrostMessages[i]
if msg.Type != nil && *msg.Type == schemas.ResponsesMessageTypeWebSearchCall &&
msg.ID != nil &&
*msg.ID == toolUseID {
if msg.ResponsesToolMessage == nil {
msg.ResponsesToolMessage = &schemas.ResponsesToolMessage{}
}
// Found the matching web_search_call, add sources
if resultBlock.Content != nil && len(resultBlock.Content.ContentBlocks) > 0 {
sources := extractWebSearchSources(resultBlock.Content.ContentBlocks, includeExtendedFields)
// Initialize action if needed
if msg.ResponsesToolMessage.Action == nil {
msg.ResponsesToolMessage.Action = &schemas.ResponsesToolMessageActionStruct{}
}
if msg.ResponsesToolMessage.Action.ResponsesWebSearchToolCallAction == nil {
msg.ResponsesToolMessage.Action.ResponsesWebSearchToolCallAction = &schemas.ResponsesWebSearchToolCallAction{
Type: "search",
}
}
msg.ResponsesToolMessage.Action.ResponsesWebSearchToolCallAction.Sources = sources
}
break
}
}
}
// extractWebSearchSources extracts search sources from Anthropic content blocks.
// When includeExtendedFields is true, it includes EncryptedContent, PageAge, and Title fields.
func extractWebSearchSources(contentBlocks []AnthropicContentBlock, includeExtendedFields bool) []schemas.ResponsesWebSearchToolCallActionSearchSource {
sources := make([]schemas.ResponsesWebSearchToolCallActionSearchSource, 0, len(contentBlocks))
for _, result := range contentBlocks {
if result.Type == AnthropicContentBlockTypeWebSearchResult && result.URL != nil {
source := schemas.ResponsesWebSearchToolCallActionSearchSource{
Type: "url",
URL: *result.URL,
}
if includeExtendedFields {
source.EncryptedContent = result.EncryptedContent
source.PageAge = result.PageAge
if result.Title != nil {
source.Title = result.Title
} else {
source.Title = schemas.Ptr(*result.URL)
}
}
sources = append(sources, source)
}
}
return sources
}
// anthropicExtractInt64 extracts an int64 from various numeric types
func anthropicExtractInt64(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
}
}
// anthropicExtractFloat64 extracts a float64 from various numeric types
func anthropicExtractFloat64(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
}
}
// IsClaudeCodeMaxMode checks if the request is a Claude Code max mode request.
// In the max mode - we don't need to forward the key
func IsClaudeCodeMaxMode(ctx *schemas.BifrostContext) bool {
userAgent, _ := ctx.Value(schemas.BifrostContextKeyUserAgent).(string)
skipKeySelection, _ := ctx.Value(schemas.BifrostContextKeySkipKeySelection).(bool)
return schemas.ClaudeCLI.Matches(userAgent) && skipKeySelection
}
// IsClaudeCodeRequest checks if the request is a Claude Code request.
func IsClaudeCodeRequest(ctx *schemas.BifrostContext) bool {
if userAgent, ok := ctx.Value(schemas.BifrostContextKeyUserAgent).(string); ok {
return schemas.ClaudeCLI.Matches(userAgent)
}
return false
}