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 }