diff --git a/core/providers/anthropic/chat.go b/core/providers/anthropic/chat.go index d67a953d4f2..16e9434d480 100644 --- a/core/providers/anthropic/chat.go +++ b/core/providers/anthropic/chat.go @@ -381,6 +381,41 @@ func ToAnthropicChatRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.Bif } } + // Fallbacks — Anthropic native server-side fallback objects arrive via + // ExtraParams["fallbacks"]. Promote them onto the typed Fallbacks field so + // they marshal natively and drive server-side-fallback beta-header injection + // (mirrors ToAnthropicResponsesRequest); Bifrost string fallbacks are not + // carried here (they travel on BifrostChatRequest.Fallbacks). + if fbVal, exists := bifrostReq.Params.ExtraParams["fallbacks"]; exists { + var natives []AnthropicNativeFallback + switch v := fbVal.(type) { + case []AnthropicNativeFallback: + natives = v + default: + if data, err := providerUtils.MarshalSorted(v); err == nil { + _ = sonic.Unmarshal(data, &natives) + } + } + if len(natives) > 0 { + delete(anthropicReq.ExtraParams, "fallbacks") + entries := make([]AnthropicFallbackEntry, len(natives)) + for i := range natives { + n := natives[i] + entries[i] = AnthropicFallbackEntry{Native: &n} + } + anthropicReq.Fallbacks = entries + } + } + + // Fallback credit token — same promotion, so the retry marshals the token + // top-level and picks up the fallback-credit beta header. + if tokenVal, exists := bifrostReq.Params.ExtraParams["fallback_credit_token"]; exists { + if token, ok := tokenVal.(string); ok && token != "" { + delete(anthropicReq.ExtraParams, "fallback_credit_token") + anthropicReq.FallbackCreditToken = &token + } + } + // TaskBudget — maps onto output_config.task_budget. If an OutputConfig // already exists (e.g. from structured outputs), attach the budget to // it; otherwise create one. diff --git a/core/providers/anthropic/requestbuilder.go b/core/providers/anthropic/requestbuilder.go index ecdabc16bc5..e5a6ed04b94 100644 --- a/core/providers/anthropic/requestbuilder.go +++ b/core/providers/anthropic/requestbuilder.go @@ -376,11 +376,21 @@ func BuildAnthropicResponsesRequestBody(ctx *schemas.BifrostContext, request *sc return nil, newErr(schemas.ErrProviderRequestMarshal, err, jsonBody) } - jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "fallbacks") + // Strip Bifrost cross-provider fallback strings, but preserve Anthropic + // native server-side fallback objects (server-side-fallback-2026-06-01). + jsonBody, err = stripBifrostFallbacksFromBody(jsonBody, cfg.Provider) if err != nil { return nil, newErr(schemas.ErrProviderRequestMarshal, err, jsonBody) } + if cfg.IsCountTokens { + // The count_tokens endpoint rejects fallback_credit_token outright. + jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "fallback_credit_token") + if err != nil { + return nil, newErr(schemas.ErrProviderRequestMarshal, err, jsonBody) + } + } + if defaults.DeleteStreamField { jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "stream") if err != nil { @@ -597,7 +607,9 @@ func BuildAnthropicChatRequestBody(ctx *schemas.BifrostContext, request *schemas return nil, newErr(schemas.ErrProviderRequestMarshal, err, jsonBody) } - jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "fallbacks") + // Strip Bifrost cross-provider fallback strings, but preserve Anthropic + // native server-side fallback objects (server-side-fallback-2026-06-01). + jsonBody, err = stripBifrostFallbacksFromBody(jsonBody, cfg.Provider) if err != nil { return nil, newErr(schemas.ErrProviderRequestMarshal, err, jsonBody) } diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go index e717776df20..abeed41e404 100644 --- a/core/providers/anthropic/responses.go +++ b/core/providers/anthropic/responses.go @@ -74,6 +74,7 @@ type AnthropicResponsesStreamState struct { MessageID *string // Message ID from message_start Model *string // Model name from message_start StopReason *string // Stop reason for the message + StopDetails *schemas.ResponsesStopDetails // Refusal stop_details (server-side fallback), carried to the final message_delta CreatedAt int // Timestamp for created_at consistency HasEmittedCreated bool // Whether we've emitted response.created HasEmittedInProgress bool // Whether we've emitted response.in_progress @@ -387,6 +388,7 @@ func AcquireAnthropicResponsesStreamState() *AnthropicResponsesStreamState { state.CurrentOutputIndex = 0 state.MessageID = nil state.StopReason = nil + state.StopDetails = nil state.Model = nil state.CreatedAt = int(time.Now().Unix()) state.HasEmittedCreated = false @@ -449,6 +451,7 @@ func (state *AnthropicResponsesStreamState) flush() { state.CurrentOutputIndex = 0 state.MessageID = nil state.StopReason = nil + state.StopDetails = nil state.Model = nil state.CreatedAt = int(time.Now().Unix()) state.HasEmittedCreated = false @@ -469,6 +472,15 @@ func isCompactionItem(item *schemas.ResponsesMessage) bool { item.Content.ContentBlocks[0].Type == schemas.ResponsesOutputMessageContentTypeCompaction } +// isFallbackItem checks if a ResponsesMessage represents a server-side fallback +// boundary item (a message with a fallback content block as its first content block). +func isFallbackItem(item *schemas.ResponsesMessage) bool { + return item != nil && item.Type != nil && + *item.Type == schemas.ResponsesMessageTypeMessage && + item.Content != nil && len(item.Content.ContentBlocks) > 0 && + item.Content.ContentBlocks[0].Type == schemas.ResponsesOutputMessageContentTypeFallback +} + // getOrCreateOutputIndex returns the output index for a given content index, creating a new one if needed func (state *AnthropicResponsesStreamState) getOrCreateOutputIndex(contentIndex *int) int { if contentIndex == nil { @@ -1006,6 +1018,55 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, // Don't emit output_item.added yet - wait for the delta with actual summary return nil, nil, false + case AnthropicContentBlockTypeFallback: + // Fallback boundary marker - no deltas follow, so emit the complete + // item (added + done) here from the start event's from/to models. + itemID := fmt.Sprintf("fb_%d", outputIndex) + state.ItemIDs[outputIndex] = itemID + if chunk.Index != nil { + state.ContentIndexToBlockType[*chunk.Index] = AnthropicContentBlockTypeFallback + } + messageType := schemas.ResponsesMessageTypeMessage + fbRole := schemas.ResponsesInputMessageRoleAssistant + fallback := &schemas.ResponsesOutputMessageContentFallback{} + if chunk.ContentBlock.From != nil { + fallback.FromModel = chunk.ContentBlock.From.Model + } + if chunk.ContentBlock.To != nil { + fallback.ToModel = chunk.ContentBlock.To.Model + } + if chunk.ContentBlock.Trigger != nil { + fallback.TriggerType = chunk.ContentBlock.Trigger.Type + fallback.TriggerCategory = chunk.ContentBlock.Trigger.Category + } + item := &schemas.ResponsesMessage{ + ID: schemas.Ptr(itemID), + Status: schemas.Ptr("completed"), + Type: &messageType, + Role: &fbRole, + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{{ + Type: schemas.ResponsesOutputMessageContentTypeFallback, + ResponsesOutputMessageContentFallback: fallback, + }}, + }, + } + return []*schemas.BifrostResponsesStreamResponse{ + { + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: item, + }, + { + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber + 1, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: item, + }, + }, nil, false case AnthropicContentBlockTypeText: // Text block - emit output_item.added with type "message" messageType := schemas.ResponsesMessageTypeMessage @@ -1925,6 +1986,11 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, delete(state.ContentIndexToBlockType, *chunk.Index) return nil, nil, false } + if blockType == AnthropicContentBlockTypeFallback { + // output_item.added + done were already emitted at content_block_start. + delete(state.ContentIndexToBlockType, *chunk.Index) + return nil, nil, false + } } } @@ -2215,6 +2281,9 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, } state.StopReason = &mapped } + if chunk.Delta.StopDetails != nil { + state.StopDetails = stopDetailsToBifrost(chunk.Delta.StopDetails) + } // Check if integration type in ctx is anthropic if ctx.Value(schemas.BifrostContextKeyIntegrationType) == "anthropic" { // Convert usage from Anthropic format to Bifrost @@ -2239,6 +2308,7 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, if stopReason != nil { response.StopReason = stopReason } + response.StopDetails = state.StopDetails if bifrostUsage != nil { response.Usage = bifrostUsage response.Speed = chunk.Usage.Speed @@ -2282,6 +2352,7 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context, if state.StopReason != nil { response.StopReason = state.StopReason } + response.StopDetails = state.StopDetails // Fold the sandbox container (delivered on the final message_delta) onto // every code_interpreter_call so response.completed carries it (mirrors the @@ -2557,6 +2628,15 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp if bifrostResp.Item.Content.ContentBlocks[0].CacheControl != nil { contentBlock.CacheControl = bifrostResp.Item.Content.ContentBlocks[0].CacheControl } + } else if isFallbackItem(bifrostResp.Item) { + contentBlock.Type = AnthropicContentBlockTypeFallback + if fb := bifrostResp.Item.Content.ContentBlocks[0].ResponsesOutputMessageContentFallback; fb != nil { + contentBlock.From = &AnthropicFallbackModel{Model: fb.FromModel} + contentBlock.To = &AnthropicFallbackModel{Model: fb.ToModel} + if fb.TriggerType != "" { + contentBlock.Trigger = &AnthropicFallbackTrigger{Type: fb.TriggerType, Category: fb.TriggerCategory} + } + } } else if bifrostResp.Item.Type != nil { switch *bifrostResp.Item.Type { case schemas.ResponsesMessageTypeMessage: @@ -3124,6 +3204,12 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp StopSequence: nil, } } + if sd := stopDetailsToAnthropic(bifrostResp.Response.StopDetails); sd != nil { + if anthropicContentDeltaEvent.Delta == nil { + anthropicContentDeltaEvent.Delta = &AnthropicStreamDelta{} + } + anthropicContentDeltaEvent.Delta.StopDetails = sd + } // Re-emit the code-execution sandbox container on the message_delta. if bifrostResp.Response.Container != nil { if anthropicContentDeltaEvent.Delta == nil { @@ -3193,6 +3279,14 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp Text: bifrostResp.Delta, } } + if bifrostResp.Response != nil { + if sd := stopDetailsToAnthropic(bifrostResp.Response.StopDetails); sd != nil { + if streamResp.Delta == nil { + streamResp.Delta = &AnthropicStreamDelta{} + } + streamResp.Delta.StopDetails = sd + } + } // Re-emit the code-execution sandbox container on message_delta (read // straight off the event — Anthropic delivers it here natively). @@ -3231,7 +3325,7 @@ func (req *AnthropicMessageRequest) ToBifrostResponsesRequest(ctx *schemas.Bifro bifrostReq := &schemas.BifrostResponsesRequest{ Provider: provider, Model: model, - Fallbacks: schemas.ParseFallbacks(req.Fallbacks), + Fallbacks: schemas.ParseFallbacks(req.bifrostFallbackModels()), } // Convert basic parameters @@ -3239,6 +3333,17 @@ func (req *AnthropicMessageRequest) ToBifrostResponsesRequest(ctx *schemas.Bifro ExtraParams: make(map[string]interface{}), } + // Anthropic native server-side fallback ("fallbacks" objects) is forwarded to + // the provider verbatim, distinct from Bifrost cross-provider fallback above. + if native := req.nativeFallbacks(); len(native) > 0 { + params.ExtraParams["fallbacks"] = native + } + + // Fallback credit token — carried verbatim so the retry can redeem it. + if req.FallbackCreditToken != nil { + params.ExtraParams["fallback_credit_token"] = *req.FallbackCreditToken + } + if req.MaxTokens > 0 { params.MaxOutputTokens = &req.MaxTokens } @@ -3712,6 +3817,36 @@ func ToAnthropicResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schema } anthropicReq.OutputConfig.TaskBudget = taskBudget } + // Anthropic native server-side fallback: rebuild the typed field so it + // marshals as native "fallbacks" objects and drives beta-header injection. + if fbVal, exists := bifrostReq.Params.ExtraParams["fallbacks"]; exists { + delete(anthropicReq.ExtraParams, "fallbacks") + var natives []AnthropicNativeFallback + switch v := fbVal.(type) { + case []AnthropicNativeFallback: + natives = v + default: + if data, err := providerUtils.MarshalSorted(v); err == nil { + _ = sonic.Unmarshal(data, &natives) + } + } + if len(natives) > 0 { + entries := make([]AnthropicFallbackEntry, len(natives)) + for i := range natives { + n := natives[i] + entries[i] = AnthropicFallbackEntry{Native: &n} + } + anthropicReq.Fallbacks = entries + } + } + // Fallback credit token: promote onto the typed field so it marshals + // top-level and drives beta-header injection. + if tokenVal, exists := bifrostReq.Params.ExtraParams["fallback_credit_token"]; exists { + delete(anthropicReq.ExtraParams, "fallback_credit_token") + if token, ok := tokenVal.(string); ok && token != "" { + anthropicReq.FallbackCreditToken = &token + } + } } // Convert tools @@ -3780,6 +3915,7 @@ func ConvertAnthropicUsageToBifrostUsage(anthropicUsage *AnthropicUsage) *schema bifrostUsage := &schemas.ResponsesResponseUsage{ Type: anthropicUsage.Type, + Model: anthropicUsage.Model, InputTokens: anthropicUsage.InputTokens, OutputTokens: anthropicUsage.OutputTokens, TotalTokens: anthropicUsage.InputTokens + anthropicUsage.OutputTokens, @@ -3841,6 +3977,7 @@ func ConvertBifrostUsageToAnthropicUsage(bifrostUsage *schemas.ResponsesResponse anthropicUsage := &AnthropicUsage{ Type: bifrostUsage.Type, + Model: bifrostUsage.Model, InputTokens: bifrostUsage.InputTokens, OutputTokens: bifrostUsage.OutputTokens, } @@ -3883,6 +4020,36 @@ func ConvertBifrostUsageToAnthropicUsage(bifrostUsage *schemas.ResponsesResponse return anthropicUsage } +// stopDetailsToBifrost converts Anthropic stop_details to the neutral form (nil-safe). +func stopDetailsToBifrost(d *AnthropicStopDetails) *schemas.ResponsesStopDetails { + if d == nil { + return nil + } + return &schemas.ResponsesStopDetails{ + Type: d.Type, + Category: d.Category, + Explanation: d.Explanation, + RecommendedModel: d.RecommendedModel, + FallbackCreditToken: d.FallbackCreditToken, + FallbackHasPrefillClaim: d.FallbackHasPrefillClaim, + } +} + +// stopDetailsToAnthropic converts neutral stop_details back to Anthropic form (nil-safe). +func stopDetailsToAnthropic(d *schemas.ResponsesStopDetails) *AnthropicStopDetails { + if d == nil { + return nil + } + return &AnthropicStopDetails{ + Type: d.Type, + Category: d.Category, + Explanation: d.Explanation, + RecommendedModel: d.RecommendedModel, + FallbackCreditToken: d.FallbackCreditToken, + FallbackHasPrefillClaim: d.FallbackHasPrefillClaim, + } +} + // ToBifrostResponsesResponse converts an Anthropic response to BifrostResponse with Responses structure func (response *AnthropicMessageResponse) ToBifrostResponsesResponse(ctx *schemas.BifrostContext) *schemas.BifrostResponsesResponse { if response == nil { @@ -3955,6 +4122,7 @@ func (response *AnthropicMessageResponse) ToBifrostResponsesResponse(ctx *schema } bifrostResp.StopReason = &mapped } + bifrostResp.StopDetails = stopDetailsToBifrost(response.StopDetails) if response.Usage != nil && response.Usage.ServiceTier != nil { mapped := MapAnthropicServiceTierToBifrost(*response.Usage.ServiceTier) @@ -4046,6 +4214,7 @@ func ToAnthropicResponsesResponse(ctx *schemas.BifrostContext, bifrostResp *sche } } } + anthropicResp.StopDetails = stopDetailsToAnthropic(bifrostResp.StopDetails) anthropicResp.Model = bifrostResp.Model @@ -5190,6 +5359,32 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex } bifrostMessages = append(bifrostMessages, bifrostMsg) } + case AnthropicContentBlockTypeFallback: + fallback := &schemas.ResponsesOutputMessageContentFallback{} + if block.From != nil { + fallback.FromModel = block.From.Model + } + if block.To != nil { + fallback.ToModel = block.To.Model + } + if block.Trigger != nil { + fallback.TriggerType = block.Trigger.Type + fallback.TriggerCategory = block.Trigger.Category + } + bifrostMessages = append(bifrostMessages, schemas.ResponsesMessage{ + ID: schemas.Ptr("fb_" + providerUtils.GetRandomString(50)), + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: role, + Status: schemas.Ptr("completed"), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeFallback, + ResponsesOutputMessageContentFallback: fallback, + }, + }, + }, + }) case AnthropicContentBlockTypeText: if block.Text != nil { var bifrostMsg schemas.ResponsesMessage @@ -7512,6 +7707,18 @@ func convertContentBlockToAnthropic(block schemas.ResponsesMessageContentBlock) CacheControl: block.CacheControl, } } + case schemas.ResponsesOutputMessageContentTypeFallback: + if fb := block.ResponsesOutputMessageContentFallback; fb != nil { + fallbackBlock := &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeFallback, + From: &AnthropicFallbackModel{Model: fb.FromModel}, + To: &AnthropicFallbackModel{Model: fb.ToModel}, + } + if fb.TriggerType != "" { + fallbackBlock.Trigger = &AnthropicFallbackTrigger{Type: fb.TriggerType, Category: fb.TriggerCategory} + } + return fallbackBlock + } case schemas.ResponsesInputMessageContentBlockTypeFile: if block.ResponsesInputMessageContentBlockFile != nil || block.FileID != nil { // Direct conversion without intermediate ChatContentBlock diff --git a/core/providers/anthropic/serversidefallback_test.go b/core/providers/anthropic/serversidefallback_test.go new file mode 100644 index 00000000000..913f9081e70 --- /dev/null +++ b/core/providers/anthropic/serversidefallback_test.go @@ -0,0 +1,1085 @@ +package anthropic + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/tidwall/gjson" +) + +// --- usage.iterations[].model --- + +func TestUsageIterationsModel_RoundTrip(t *testing.T) { + t.Parallel() + + anthropicUsage := &AnthropicUsage{ + Type: schemas.Ptr("message"), + Model: schemas.Ptr("claude-opus-4-8"), + InputTokens: 412, + OutputTokens: 264, + Iterations: []AnthropicUsage{ + {Type: schemas.Ptr("message"), Model: schemas.Ptr("claude-fable-5"), InputTokens: 535, OutputTokens: 0}, + {Type: schemas.Ptr("fallback_message"), Model: schemas.Ptr("claude-opus-4-8"), InputTokens: 412, OutputTokens: 264}, + }, + } + + // Anthropic -> Bifrost + bifrostUsage := ConvertAnthropicUsageToBifrostUsage(anthropicUsage) + if bifrostUsage.Model == nil || *bifrostUsage.Model != "claude-opus-4-8" { + t.Fatalf("top-level model = %v, want claude-opus-4-8", bifrostUsage.Model) + } + if len(bifrostUsage.Iterations) != 2 { + t.Fatalf("expected 2 iterations, got %d", len(bifrostUsage.Iterations)) + } + if m := bifrostUsage.Iterations[0].Model; m == nil || *m != "claude-fable-5" { + t.Errorf("iteration[0].model = %v, want claude-fable-5", m) + } + if m := bifrostUsage.Iterations[1].Model; m == nil || *m != "claude-opus-4-8" { + t.Errorf("iteration[1].model = %v, want claude-opus-4-8", m) + } + + // Bifrost -> Anthropic + back := ConvertBifrostUsageToAnthropicUsage(bifrostUsage) + if back.Model == nil || *back.Model != "claude-opus-4-8" { + t.Errorf("round-trip top-level model = %v, want claude-opus-4-8", back.Model) + } + if len(back.Iterations) != 2 { + t.Fatalf("round-trip expected 2 iterations, got %d", len(back.Iterations)) + } + if m := back.Iterations[0].Model; m == nil || *m != "claude-fable-5" { + t.Errorf("round-trip iteration[0].model = %v, want claude-fable-5", m) + } +} + +// --- isFallbackItem --- + +func TestIsFallbackItem(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + item *schemas.ResponsesMessage + expected bool + }{ + {name: "nil item", item: nil, expected: false}, + { + name: "message with fallback block", + item: &schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + {Type: schemas.ResponsesOutputMessageContentTypeFallback}, + }, + }, + }, + expected: true, + }, + { + name: "message with text block", + item: &schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + {Type: schemas.ResponsesOutputMessageContentTypeText, Text: schemas.Ptr("hi")}, + }, + }, + }, + expected: false, + }, + { + name: "message with nil content", + item: &schemas.ResponsesMessage{Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage)}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isFallbackItem(tt.item); got != tt.expected { + t.Errorf("isFallbackItem() = %v, want %v", got, tt.expected) + } + }) + } +} + +// --- Non-Streaming: fallback content block round-trip --- + +func TestFallbackContentBlock_NonStreamingRoundTrip(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + anthropicResp := &AnthropicMessageResponse{ + ID: "msg_fallback_test", + Type: "message", + Role: "assistant", + Model: "claude-opus-4-8", + StopReason: AnthropicStopReasonEndTurn, + Content: []AnthropicContentBlock{ + { + Type: AnthropicContentBlockTypeFallback, + From: &AnthropicFallbackModel{Model: "claude-fable-5"}, + To: &AnthropicFallbackModel{Model: "claude-opus-4-8"}, + }, + {Type: AnthropicContentBlockTypeText, Text: schemas.Ptr("Hi! How can I help you today?")}, + }, + } + + // Step 1: Anthropic -> Bifrost + bifrostResp := anthropicResp.ToBifrostResponsesResponse(ctx) + + var fb *schemas.ResponsesOutputMessageContentFallback + for _, msg := range bifrostResp.Output { + if msg.Content == nil { + continue + } + for _, block := range msg.Content.ContentBlocks { + if block.Type == schemas.ResponsesOutputMessageContentTypeFallback { + fb = block.ResponsesOutputMessageContentFallback + } + } + } + if fb == nil { + t.Fatal("fallback block not found in Bifrost output") + } + if fb.FromModel != "claude-fable-5" || fb.ToModel != "claude-opus-4-8" { + t.Fatalf("fallback from/to = %q/%q, want claude-fable-5/claude-opus-4-8", fb.FromModel, fb.ToModel) + } + + // Step 2: Bifrost -> Anthropic + result := ToAnthropicResponsesResponse(ctx, bifrostResp) + + var found bool + for _, block := range result.Content { + if block.Type == AnthropicContentBlockTypeFallback { + found = true + if block.From == nil || block.From.Model != "claude-fable-5" { + t.Errorf("result from = %v, want claude-fable-5", block.From) + } + if block.To == nil || block.To.Model != "claude-opus-4-8" { + t.Errorf("result to = %v, want claude-opus-4-8", block.To) + } + } + } + if !found { + t.Error("fallback block not found in Anthropic result") + } +} + +// --- Streaming: Anthropic -> Bifrost (inbound) --- + +func newFallbackStreamState() *AnthropicResponsesStreamState { + return &AnthropicResponsesStreamState{ + ContentIndexToOutputIndex: make(map[int]int), + ContentIndexToBlockType: make(map[int]AnthropicContentBlockType), + ToolArgumentBuffers: make(map[int]string), + MCPCallOutputIndices: make(map[int]bool), + ItemIDs: make(map[int]string), + OutputItems: make(map[int]*schemas.ResponsesMessage), + ReasoningSignatures: make(map[int]string), + TextContentIndices: make(map[int]bool), + ReasoningContentIndices: make(map[int]bool), + CompactionContentIndices: make(map[int]*schemas.CacheControl), + CurrentOutputIndex: 0, + CreatedAt: 1234567890, + HasEmittedCreated: true, + HasEmittedInProgress: true, + } +} + +func TestToBifrostResponsesStream_FallbackContentBlockStart(t *testing.T) { + t.Parallel() + + state := newFallbackStreamState() + + chunk := &AnthropicStreamEvent{ + Type: AnthropicStreamEventTypeContentBlockStart, + Index: schemas.Ptr(0), + ContentBlock: &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeFallback, + From: &AnthropicFallbackModel{Model: "claude-fable-5"}, + To: &AnthropicFallbackModel{Model: "claude-opus-4-8"}, + }, + } + + responses, err, isLast := chunk.ToBifrostResponsesStream(context.Background(), 0, state) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if isLast { + t.Error("should not be last chunk") + } + // Fallback has no deltas: added + done are both emitted here. + if len(responses) != 2 { + t.Fatalf("expected 2 responses (added+done), got %d", len(responses)) + } + if responses[0].Type != schemas.ResponsesStreamResponseTypeOutputItemAdded { + t.Errorf("response[0] = %v, want output_item.added", responses[0].Type) + } + if responses[1].Type != schemas.ResponsesStreamResponseTypeOutputItemDone { + t.Errorf("response[1] = %v, want output_item.done", responses[1].Type) + } + added := responses[0] + if added.Item == nil || added.Item.Content == nil || len(added.Item.Content.ContentBlocks) == 0 { + t.Fatal("output_item.added should have content blocks") + } + block := added.Item.Content.ContentBlocks[0] + if block.Type != schemas.ResponsesOutputMessageContentTypeFallback { + t.Fatalf("content block type = %v, want fallback", block.Type) + } + if block.ResponsesOutputMessageContentFallback == nil || + block.ResponsesOutputMessageContentFallback.FromModel != "claude-fable-5" || + block.ResponsesOutputMessageContentFallback.ToModel != "claude-opus-4-8" { + t.Errorf("fallback content = %+v, want from claude-fable-5 to claude-opus-4-8", block.ResponsesOutputMessageContentFallback) + } + if bt, ok := state.ContentIndexToBlockType[0]; !ok || bt != AnthropicContentBlockTypeFallback { + t.Error("expected fallback block type tracked in ContentIndexToBlockType") + } +} + +func TestToBifrostResponsesStream_FallbackContentBlockStop(t *testing.T) { + t.Parallel() + + state := newFallbackStreamState() + state.ContentIndexToBlockType[0] = AnthropicContentBlockTypeFallback + state.ContentIndexToOutputIndex[0] = 0 + state.ItemIDs[0] = "fb_0" + state.CurrentOutputIndex = 1 + + chunk := &AnthropicStreamEvent{ + Type: AnthropicStreamEventTypeContentBlockStop, + Index: schemas.Ptr(0), + } + + responses, err, isLast := chunk.ToBifrostResponsesStream(context.Background(), 0, state) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if isLast { + t.Error("should not be last chunk") + } + // added+done already emitted at content_block_start; stop yields nothing. + if len(responses) != 0 { + t.Errorf("expected 0 responses for fallback content_block_stop, got %d", len(responses)) + } +} + +// --- Streaming: Bifrost -> Anthropic (outbound) --- + +func fallbackStreamItem() *schemas.ResponsesMessage { + return &schemas.ResponsesMessage{ + ID: schemas.Ptr("fb_test123"), + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Status: schemas.Ptr("completed"), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeFallback, + ResponsesOutputMessageContentFallback: &schemas.ResponsesOutputMessageContentFallback{ + FromModel: "claude-fable-5", + ToModel: "claude-opus-4-8", + }, + }, + }, + }, + } +} + +func TestToAnthropicResponsesStreamResponse_FallbackOutputItemAdded(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + bifrostResp := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + OutputIndex: schemas.Ptr(0), + Item: fallbackStreamItem(), + } + + events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp) + if len(events) == 0 { + t.Fatal("expected at least 1 event") + } + start := events[0] + if start.Type != AnthropicStreamEventTypeContentBlockStart { + t.Errorf("event[0] type = %v, want content_block_start", start.Type) + } + if start.ContentBlock == nil || start.ContentBlock.Type != AnthropicContentBlockTypeFallback { + t.Fatalf("ContentBlock = %+v, want fallback", start.ContentBlock) + } + if start.ContentBlock.From == nil || start.ContentBlock.From.Model != "claude-fable-5" { + t.Errorf("from = %v, want claude-fable-5", start.ContentBlock.From) + } + if start.ContentBlock.To == nil || start.ContentBlock.To.Model != "claude-opus-4-8" { + t.Errorf("to = %v, want claude-opus-4-8", start.ContentBlock.To) + } +} + +func TestToAnthropicResponsesStreamResponse_FallbackOutputItemDone(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + // Prime the stream state so the item resolves to a block index (mirror the added event first). + added := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + OutputIndex: schemas.Ptr(0), + Item: fallbackStreamItem(), + } + ToAnthropicResponsesStreamResponse(ctx, added) + + done := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + OutputIndex: schemas.Ptr(0), + ItemID: schemas.Ptr("fb_test123"), + Item: fallbackStreamItem(), + } + events := ToAnthropicResponsesStreamResponse(ctx, done) + if len(events) != 1 { + t.Fatalf("expected 1 event for output_item.done, got %d", len(events)) + } + if events[0].Type != AnthropicStreamEventTypeContentBlockStop { + t.Errorf("event type = %v, want content_block_stop", events[0].Type) + } +} + +// --- Replay: fallback content blocks gated per provider --- + +func fallbackHistoryRequest() *AnthropicMessageRequest { + return &AnthropicMessageRequest{ + Model: "claude-opus-4-8", + MaxTokens: 64, + Messages: []AnthropicMessage{{ + Role: AnthropicMessageRoleAssistant, + Content: AnthropicContent{ContentBlocks: []AnthropicContentBlock{ + { + Type: AnthropicContentBlockTypeFallback, + From: &AnthropicFallbackModel{Model: "claude-fable-5"}, + To: &AnthropicFallbackModel{Model: "claude-opus-4-8"}, + }, + {Type: AnthropicContentBlockTypeText, Text: schemas.Ptr("Hi there")}, + }}, + }}, + } +} + +func countFallbackBlocks(req *AnthropicMessageRequest) int { + n := 0 + for _, m := range req.Messages { + for _, b := range m.Content.ContentBlocks { + if b.Type == AnthropicContentBlockTypeFallback { + n++ + } + } + } + return n +} + +func TestStripUnsupportedAnthropicFields_FallbackBlockReplay(t *testing.T) { + t.Parallel() + + t.Run("anthropic keeps the fallback block in place", func(t *testing.T) { + req := fallbackHistoryRequest() + stripUnsupportedAnthropicFields(req, schemas.Anthropic, "claude-opus-4-8") + if got := countFallbackBlocks(req); got != 1 { + t.Fatalf("expected fallback block preserved for Anthropic, got %d", got) + } + // Position matters on Anthropic - it must stay the first block. + if req.Messages[0].Content.ContentBlocks[0].Type != AnthropicContentBlockTypeFallback { + t.Error("fallback block moved from its original position") + } + }) + + for _, p := range []schemas.ModelProvider{schemas.Vertex, schemas.Bedrock, schemas.BedrockMantle, schemas.Azure} { + t.Run(string(p)+" strips the fallback block", func(t *testing.T) { + req := fallbackHistoryRequest() + stripUnsupportedAnthropicFields(req, p, "claude-opus-4-8") + if got := countFallbackBlocks(req); got != 0 { + t.Fatalf("expected fallback block stripped for %s, got %d", p, got) + } + // The surrounding conversation must survive. + blocks := req.Messages[0].Content.ContentBlocks + if len(blocks) != 1 || blocks[0].Type != AnthropicContentBlockTypeText { + t.Fatalf("expected the text block to survive, got %+v", blocks) + } + }) + } +} + +func TestStripUnsupportedFieldsFromRawBody_FallbackBlockReplay(t *testing.T) { + t.Parallel() + + raw := []byte(`{"model":"claude-opus-4-8","max_tokens":64,"messages":[{"role":"assistant","content":[{"type":"fallback","from":{"model":"claude-fable-5"},"to":{"model":"claude-opus-4-8"}},{"type":"text","text":"Hi there"}]}]}`) + + t.Run("anthropic keeps it", func(t *testing.T) { + out, err := StripUnsupportedFieldsFromRawBody(raw, schemas.Anthropic, "claude-opus-4-8") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !gjson.GetBytes(out, `messages.0.content.#(type=="fallback")`).Exists() { + t.Errorf("expected fallback block kept for Anthropic, got: %s", out) + } + }) + + for _, p := range []schemas.ModelProvider{schemas.Vertex, schemas.Bedrock, schemas.BedrockMantle} { + t.Run(string(p)+" strips it", func(t *testing.T) { + out, err := StripUnsupportedFieldsFromRawBody(raw, p, "claude-opus-4-8") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.GetBytes(out, `messages.0.content.#(type=="fallback")`).Exists() { + t.Errorf("expected fallback block stripped for %s, got: %s", p, out) + } + if !gjson.GetBytes(out, `messages.0.content.#(type=="text")`).Exists() { + t.Errorf("expected the text block to survive for %s, got: %s", p, out) + } + }) + } +} + +// --- Provider gating: native fallbacks stripped on unsupported providers --- + +func TestStripBifrostFallbacksFromBody_ProviderGating(t *testing.T) { + t.Parallel() + + nativeBody := []byte(`{"model":"claude-fable-5","fallbacks":[{"model":"claude-opus-4-8"}]}`) + + tests := []struct { + name string + provider schemas.ModelProvider + keepNative bool + }{ + {name: "anthropic keeps native", provider: schemas.Anthropic, keepNative: true}, + {name: "vertex strips native", provider: schemas.Vertex, keepNative: false}, + {name: "bedrock strips native", provider: schemas.Bedrock, keepNative: false}, + {name: "bedrock mantle strips native", provider: schemas.BedrockMantle, keepNative: false}, + {name: "azure strips native", provider: schemas.Azure, keepNative: false}, + {name: "unknown provider keeps native", provider: schemas.ModelProvider("custom-x"), keepNative: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := stripBifrostFallbacksFromBody(nativeBody, tt.provider) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + exists := gjson.GetBytes(out, "fallbacks").Exists() + if exists != tt.keepNative { + t.Errorf("fallbacks present = %v, want %v (body: %s)", exists, tt.keepNative, out) + } + }) + } + + t.Run("bifrost string fallbacks always stripped", func(t *testing.T) { + stringBody := []byte(`{"model":"claude-fable-5","fallbacks":["openai/gpt-4o"]}`) + for _, p := range []schemas.ModelProvider{schemas.Anthropic, schemas.Vertex} { + out, err := stripBifrostFallbacksFromBody(stringBody, p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.GetBytes(out, "fallbacks").Exists() { + t.Errorf("expected string fallbacks stripped for %s, got: %s", p, out) + } + } + }) +} + +func TestBuildAnthropicResponsesRequestBody_StripsNativeFallbacksOnVertex(t *testing.T) { + t.Parallel() + + rawBody := []byte(`{"model":"claude-fable-5","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"fallbacks":[{"model":"claude-opus-4-8"}]}`) + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + ctx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) + + request := &schemas.BifrostResponsesRequest{ + Provider: schemas.Vertex, + Model: "claude-fable-5", + RawRequestBody: rawBody, + } + result, bifrostErr := BuildAnthropicResponsesRequestBody(ctx, request, AnthropicRequestBuildConfig{ + Provider: schemas.Vertex, + }) + if bifrostErr != nil { + t.Fatalf("unexpected error: %v", bifrostErr) + } + if gjson.GetBytes(result, "fallbacks").Exists() { + t.Errorf("expected native fallbacks stripped for Vertex, got: %s", result) + } + // And the beta header must not be injected for an unsupported provider. + extraHeaders, _ := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string) + if slices.Contains(extraHeaders[AnthropicBetaHeader], AnthropicServerSideFallbackBetaHeader) { + t.Errorf("did not expect server-side-fallback beta header on Vertex, got %v", extraHeaders[AnthropicBetaHeader]) + } +} + +// --- Chat path: native fallbacks promotion + beta header --- + +func TestToAnthropicChatRequest_PromotesNativeFallbacks(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + // Realistic wire form: fallbacks arrive as decoded JSON in ExtraParams. + bifrostReq := &schemas.BifrostChatRequest{ + Provider: schemas.Anthropic, + Model: "claude-fable-5", + Input: []schemas.ChatMessage{{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("hi")}, + }}, + Params: &schemas.ChatParameters{ + ExtraParams: map[string]interface{}{ + "fallbacks": []interface{}{map[string]interface{}{"model": "claude-opus-4-8"}}, + }, + }, + } + + result, err := ToAnthropicChatRequest(ctx, bifrostReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + native := result.nativeFallbacks() + if len(native) != 1 || native[0].Model != "claude-opus-4-8" { + t.Fatalf("expected native fallback claude-opus-4-8 promoted to Fallbacks, got %+v", native) + } + if _, exists := result.ExtraParams["fallbacks"]; exists { + t.Error("expected fallbacks removed from ExtraParams after promotion") + } +} + +func TestBuildAnthropicChatRequestBody_NativeFallbacksInjectsBetaHeader(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + bifrostReq := &schemas.BifrostChatRequest{ + Provider: schemas.Anthropic, + Model: "claude-fable-5", + Input: []schemas.ChatMessage{{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("hi")}, + }}, + Params: &schemas.ChatParameters{ + ExtraParams: map[string]interface{}{ + "fallbacks": []interface{}{map[string]interface{}{"model": "claude-opus-4-8"}}, + }, + }, + } + + result, bifrostErr := BuildAnthropicChatRequestBody(ctx, bifrostReq, AnthropicRequestBuildConfig{ + Provider: schemas.Anthropic, + }) + if bifrostErr != nil { + t.Fatalf("unexpected error: %v", bifrostErr) + } + + fb := gjson.GetBytes(result, "fallbacks") + if !fb.IsArray() || len(fb.Array()) != 1 || fb.Array()[0].Get("model").String() != "claude-opus-4-8" { + t.Errorf("expected native fallbacks in chat body, got: %s", fb.Raw) + } + extraHeaders, _ := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string) + if !slices.Contains(extraHeaders[AnthropicBetaHeader], AnthropicServerSideFallbackBetaHeader) { + t.Errorf("expected server-side-fallback beta header injected on chat path, got %v", extraHeaders[AnthropicBetaHeader]) + } +} + +// --- stop_details --- + +func TestStopDetails_NonStreamingRoundTrip(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + anthropicResp := &AnthropicMessageResponse{ + ID: "msg_refusal", + Type: "message", + Role: "assistant", + Model: "claude-fable-5", + StopReason: AnthropicStopReasonRefusal, + StopDetails: &AnthropicStopDetails{ + Type: "refusal", + Category: schemas.Ptr("cyber"), + Explanation: schemas.Ptr("This request was declined because it could enable cyber harm."), + RecommendedModel: schemas.Ptr("claude-opus-4-8"), + }, + Content: []AnthropicContentBlock{}, + } + + // Anthropic -> Bifrost + bifrostResp := anthropicResp.ToBifrostResponsesResponse(ctx) + if bifrostResp.StopDetails == nil { + t.Fatal("expected StopDetails to be preserved on Bifrost response") + } + if bifrostResp.StopDetails.Type != "refusal" || + bifrostResp.StopDetails.Category == nil || *bifrostResp.StopDetails.Category != "cyber" || + bifrostResp.StopDetails.RecommendedModel == nil || *bifrostResp.StopDetails.RecommendedModel != "claude-opus-4-8" { + t.Fatalf("unexpected StopDetails: %+v", bifrostResp.StopDetails) + } + + // Bifrost -> Anthropic + result := ToAnthropicResponsesResponse(ctx, bifrostResp) + if result.StopDetails == nil { + t.Fatal("expected StopDetails to be re-emitted on Anthropic response") + } + if result.StopDetails.Category == nil || *result.StopDetails.Category != "cyber" || + result.StopDetails.Explanation == nil || + result.StopDetails.RecommendedModel == nil || *result.StopDetails.RecommendedModel != "claude-opus-4-8" { + t.Errorf("unexpected round-trip StopDetails: %+v", result.StopDetails) + } +} + +func TestStopDetails_AbsentOnNormalStop(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + anthropicResp := &AnthropicMessageResponse{ + ID: "msg_ok", + Type: "message", + Role: "assistant", + Model: "claude-fable-5", + StopReason: AnthropicStopReasonEndTurn, + Content: []AnthropicContentBlock{{Type: AnthropicContentBlockTypeText, Text: schemas.Ptr("hi")}}, + } + + bifrostResp := anthropicResp.ToBifrostResponsesResponse(ctx) + if bifrostResp.StopDetails != nil { + t.Errorf("expected nil StopDetails on end_turn, got %+v", bifrostResp.StopDetails) + } + if result := ToAnthropicResponsesResponse(ctx, bifrostResp); result.StopDetails != nil { + t.Errorf("expected nil StopDetails re-emitted, got %+v", result.StopDetails) + } +} + +func TestToBifrostResponsesStream_MessageDeltaStopDetails(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + ctx.SetValue(schemas.BifrostContextKeyIntegrationType, "anthropic") + + state := newFallbackStreamState() + state.Model = schemas.Ptr("claude-fable-5") + + chunk := &AnthropicStreamEvent{ + Type: AnthropicStreamEventTypeMessageDelta, + Delta: &AnthropicStreamDelta{ + StopReason: schemas.Ptr(AnthropicStopReasonRefusal), + StopDetails: &AnthropicStopDetails{ + Type: "refusal", + Category: schemas.Ptr("bio"), + }, + }, + Usage: &AnthropicUsage{InputTokens: 412, OutputTokens: 0}, + } + + responses, err, _ := chunk.ToBifrostResponsesStream(ctx, 0, state) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(responses) != 1 || responses[0].Response == nil { + t.Fatalf("expected 1 message_delta response with a Response, got %d", len(responses)) + } + sd := responses[0].Response.StopDetails + if sd == nil || sd.Type != "refusal" || sd.Category == nil || *sd.Category != "bio" { + t.Fatalf("unexpected StopDetails on stream response: %+v", sd) + } +} + +func TestToAnthropicResponsesStreamResponse_CompletedWithStopDetails(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + bifrostResp := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeCompleted, + Response: &schemas.BifrostResponsesResponse{ + ID: schemas.Ptr("resp_refusal"), + Model: "claude-fable-5", + StopReason: schemas.Ptr("refusal"), + StopDetails: &schemas.ResponsesStopDetails{ + Type: "refusal", + Explanation: schemas.Ptr("declined"), + }, + Usage: &schemas.ResponsesResponseUsage{InputTokens: 412, OutputTokens: 0}, + }, + } + + events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp) + if len(events) != 2 { + t.Fatalf("expected 2 events (message_delta + message_stop), got %d", len(events)) + } + delta := events[0] + if delta.Type != AnthropicStreamEventTypeMessageDelta || delta.Delta == nil { + t.Fatalf("event[0] = %+v, want message_delta with Delta", delta) + } + if delta.Delta.StopDetails == nil || delta.Delta.StopDetails.Type != "refusal" || + delta.Delta.StopDetails.Explanation == nil || *delta.Delta.StopDetails.Explanation != "declined" { + t.Errorf("unexpected message_delta StopDetails: %+v", delta.Delta.StopDetails) + } +} + +// --- Fallback credit (fallback-credit-2026-06-01 / -09 on AWS) --- + +func TestFallbackCredit_StopDetailsRoundTrip(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + resp := &AnthropicMessageResponse{ + ID: "msg_credit", + Type: "message", + Role: "assistant", + Model: "claude-fable-5", + Content: []AnthropicContentBlock{}, + StopReason: AnthropicStopReasonRefusal, + StopDetails: &AnthropicStopDetails{ + Type: "refusal", + Category: schemas.Ptr("cyber"), + Explanation: schemas.Ptr("declined"), + FallbackCreditToken: schemas.Ptr("tok_opaque_123"), + FallbackHasPrefillClaim: schemas.Ptr(true), + }, + Usage: &AnthropicUsage{InputTokens: 412, OutputTokens: 0}, + } + + bifrostResp := resp.ToBifrostResponsesResponse(ctx) + sd := bifrostResp.StopDetails + if sd == nil { + t.Fatal("expected StopDetails on the neutral response") + } + if sd.FallbackCreditToken == nil || *sd.FallbackCreditToken != "tok_opaque_123" { + t.Errorf("FallbackCreditToken = %v, want tok_opaque_123", sd.FallbackCreditToken) + } + if sd.FallbackHasPrefillClaim == nil || !*sd.FallbackHasPrefillClaim { + t.Errorf("FallbackHasPrefillClaim = %v, want true", sd.FallbackHasPrefillClaim) + } + + // ...and back out to the Anthropic wire form unchanged. + back := stopDetailsToAnthropic(sd) + if back.FallbackCreditToken == nil || *back.FallbackCreditToken != "tok_opaque_123" { + t.Errorf("round-tripped token = %v, want tok_opaque_123", back.FallbackCreditToken) + } + if back.FallbackHasPrefillClaim == nil || !*back.FallbackHasPrefillClaim { + t.Errorf("round-tripped prefill claim = %v, want true", back.FallbackHasPrefillClaim) + } +} + +// A false prefill claim must survive as false, not collapse into "absent" — +// the two select different retry body shapes. +func TestFallbackCredit_PrefillClaimFalsePreserved(t *testing.T) { + t.Parallel() + + sd := stopDetailsToBifrost(&AnthropicStopDetails{ + Type: "refusal", + FallbackCreditToken: schemas.Ptr("tok"), + FallbackHasPrefillClaim: schemas.Ptr(false), + }) + if sd.FallbackHasPrefillClaim == nil { + t.Fatal("prefill claim false was dropped; callers would read it as unknown") + } + if *sd.FallbackHasPrefillClaim { + t.Error("prefill claim flipped to true") + } +} + +func TestFallbackCredit_RequestRoundTrip_Responses(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + req := &AnthropicMessageRequest{ + Model: "claude-opus-4-8", + MaxTokens: 1024, + Messages: []AnthropicMessage{{Role: "user", Content: AnthropicContent{ContentStr: schemas.Ptr("hi")}}}, + FallbackCreditToken: schemas.Ptr("tok_opaque_123"), + } + + bifrostReq := req.ToBifrostResponsesRequest(ctx) + if got := bifrostReq.Params.ExtraParams["fallback_credit_token"]; got != "tok_opaque_123" { + t.Fatalf("ExtraParams[fallback_credit_token] = %v, want tok_opaque_123", got) + } + + back, err := ToAnthropicResponsesRequest(ctx, bifrostReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if back.FallbackCreditToken == nil || *back.FallbackCreditToken != "tok_opaque_123" { + t.Fatalf("FallbackCreditToken = %v, want tok_opaque_123", back.FallbackCreditToken) + } + if _, exists := back.ExtraParams["fallback_credit_token"]; exists { + t.Error("expected fallback_credit_token removed from ExtraParams after promotion") + } +} + +func TestFallbackCredit_RequestRoundTrip_Chat(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + bifrostReq := &schemas.BifrostChatRequest{ + Provider: schemas.Anthropic, + Model: "claude-opus-4-8", + Input: []schemas.ChatMessage{{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("hi")}, + }}, + Params: &schemas.ChatParameters{ + ExtraParams: map[string]interface{}{"fallback_credit_token": "tok_opaque_123"}, + }, + } + + result, err := ToAnthropicChatRequest(ctx, bifrostReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.FallbackCreditToken == nil || *result.FallbackCreditToken != "tok_opaque_123" { + t.Fatalf("FallbackCreditToken = %v, want tok_opaque_123", result.FallbackCreditToken) + } + if _, exists := result.ExtraParams["fallback_credit_token"]; exists { + t.Error("expected fallback_credit_token removed from ExtraParams after promotion") + } +} + +// The header is injected from the token's presence, and gated per provider. +// Unlike server-side fallback, fallback credit is supported nearly everywhere. +func TestFallbackCredit_BetaHeaderInjectionGating(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + provider schemas.ModelProvider + want bool + }{ + {schemas.Anthropic, true}, + {schemas.Vertex, true}, + {schemas.Bedrock, true}, + {schemas.BedrockMantle, true}, + {schemas.Azure, true}, + {schemas.DeepSeek, false}, + } { + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + req := &AnthropicMessageRequest{ + Model: "claude-opus-4-8", + MaxTokens: 16, + Messages: []AnthropicMessage{{Role: "user", Content: AnthropicContent{ContentStr: schemas.Ptr("hi")}}}, + FallbackCreditToken: schemas.Ptr("tok"), + } + if err := AddMissingBetaHeadersToContext(ctx, req, tc.provider); err != nil { + cancel() + t.Fatalf("%s: unexpected error: %v", tc.provider, err) + } + extraHeaders, _ := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string) + got := slices.Contains(extraHeaders[AnthropicBetaHeader], AnthropicFallbackCreditBetaHeader) + if got != tc.want { + t.Errorf("%s: fallback-credit beta header present = %v, want %v (headers: %v)", + tc.provider, got, tc.want, extraHeaders[AnthropicBetaHeader]) + } + cancel() + } +} + +// AWS surfaces ship the same feature under a later date; the canonical header +// must be rewritten rather than dropped or forwarded verbatim. +func TestFilterBetaHeadersForProvider_FallbackCreditVersionRewrite(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + provider schemas.ModelProvider + want []string + }{ + {schemas.Anthropic, []string{AnthropicFallbackCreditBetaHeader}}, + {schemas.Vertex, []string{AnthropicFallbackCreditBetaHeader}}, + {schemas.Azure, []string{AnthropicFallbackCreditBetaHeader}}, + {schemas.Bedrock, []string{AnthropicFallbackCreditBetaHeaderAWS}}, + {schemas.BedrockMantle, []string{AnthropicFallbackCreditBetaHeaderAWS}}, + {schemas.DeepSeek, []string{}}, + } { + got := FilterBetaHeadersForProvider([]string{AnthropicFallbackCreditBetaHeader}, tc.provider) + if !slices.Equal(got, tc.want) { + t.Errorf("%s: filtered = %v, want %v", tc.provider, got, tc.want) + } + } +} + +// The rewrite is prefix-driven, so an inbound AWS-dated header sent to the +// Claude API is normalised back to the canonical date rather than duplicated. +func TestFilterBetaHeadersForProvider_FallbackCreditRewriteIsBidirectional(t *testing.T) { + t.Parallel() + + got := FilterBetaHeadersForProvider([]string{AnthropicFallbackCreditBetaHeaderAWS}, schemas.Bedrock) + if !slices.Equal(got, []string{AnthropicFallbackCreditBetaHeaderAWS}) { + t.Errorf("Bedrock: filtered = %v, want the AWS date unchanged", got) + } + // Anthropic keeps whatever date arrived — only providers in the rewrite table + // are remapped, so a caller pinning a specific date on the Claude API is honoured. + got = FilterBetaHeadersForProvider([]string{AnthropicFallbackCreditBetaHeaderAWS}, schemas.Anthropic) + if !slices.Equal(got, []string{AnthropicFallbackCreditBetaHeaderAWS}) { + t.Errorf("Anthropic: filtered = %v, want the inbound date preserved", got) + } +} + +func TestStripFallbackCreditToken_UnsupportedProvider(t *testing.T) { + t.Parallel() + + newReq := func() *AnthropicMessageRequest { + return &AnthropicMessageRequest{ + Model: "claude-opus-4-8", + MaxTokens: 16, + Messages: []AnthropicMessage{{Role: "user", Content: AnthropicContent{ContentStr: schemas.Ptr("hi")}}}, + FallbackCreditToken: schemas.Ptr("tok"), + } + } + + t.Run("typed kept on a supported provider", func(t *testing.T) { + req := newReq() + stripUnsupportedAnthropicFields(req, schemas.Bedrock, "claude-opus-4-8") + if req.FallbackCreditToken == nil { + t.Error("expected fallback_credit_token kept on Bedrock") + } + }) + + t.Run("typed stripped on an unsupported provider", func(t *testing.T) { + req := newReq() + stripUnsupportedAnthropicFields(req, schemas.DeepSeek, "claude-opus-4-8") + if req.FallbackCreditToken != nil { + t.Error("expected fallback_credit_token stripped on DeepSeek") + } + }) + + raw := []byte(`{"model":"claude-opus-4-8","max_tokens":16,"messages":[{"role":"user","content":"hi"}],"fallback_credit_token":"tok"}`) + + t.Run("raw kept on a supported provider", func(t *testing.T) { + out, err := StripUnsupportedFieldsFromRawBody(raw, schemas.BedrockMantle, "claude-opus-4-8") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !gjson.GetBytes(out, "fallback_credit_token").Exists() { + t.Errorf("expected token kept on bedrock_mantle, got: %s", out) + } + }) + + t.Run("raw stripped on an unsupported provider", func(t *testing.T) { + out, err := StripUnsupportedFieldsFromRawBody(raw, schemas.DeepSeek, "claude-opus-4-8") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.GetBytes(out, "fallback_credit_token").Exists() { + t.Errorf("expected token stripped on DeepSeek, got: %s", out) + } + if !gjson.GetBytes(out, "messages").Exists() { + t.Errorf("strip damaged the body: %s", out) + } + }) +} + +func TestBuildAnthropicResponsesRequestBody_CountTokensStripsFallbackCreditToken(t *testing.T) { + t.Parallel() + + rawBody := []byte(`{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"fallback_credit_token":"tok"}`) + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + ctx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) + + request := &schemas.BifrostResponsesRequest{ + Provider: schemas.Anthropic, + Model: "claude-opus-4-8", + RawRequestBody: rawBody, + } + result, bifrostErr := BuildAnthropicResponsesRequestBody(ctx, request, AnthropicRequestBuildConfig{ + Provider: schemas.Anthropic, + Model: "claude-opus-4-8", + IsCountTokens: true, + }) + if bifrostErr != nil { + t.Fatalf("unexpected error: %v", bifrostErr) + } + if gjson.GetBytes(result, "fallback_credit_token").Exists() { + t.Errorf("count_tokens rejects fallback_credit_token; expected it stripped, got: %s", result) + } +} + +// --- fallback block "trigger" (live-response field, absent from the docs page) --- + +func TestFallbackBlockTrigger_RoundTrip(t *testing.T) { + t.Parallel() + + ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background()) + defer cancel() + + resp := &AnthropicMessageResponse{ + ID: "msg_011CdD6baVeAMUq34gFq1huR", + Type: "message", + Role: "assistant", + Model: "claude-opus-4-8", + Content: []AnthropicContentBlock{ + { + Type: AnthropicContentBlockTypeFallback, + From: &AnthropicFallbackModel{Model: "claude-fable-5"}, + To: &AnthropicFallbackModel{Model: "claude-opus-4-8"}, + Trigger: &AnthropicFallbackTrigger{Type: "refusal", Category: schemas.Ptr("cyber")}, + }, + {Type: AnthropicContentBlockTypeText, Text: schemas.Ptr("I can't help with this.")}, + }, + StopReason: AnthropicStopReasonEndTurn, + Usage: &AnthropicUsage{InputTokens: 31, OutputTokens: 257}, + } + + bifrostResp := resp.ToBifrostResponsesResponse(ctx) + + var fb *schemas.ResponsesOutputMessageContentFallback + for _, item := range bifrostResp.Output { + if item.Content == nil { + continue + } + for _, b := range item.Content.ContentBlocks { + if b.Type == schemas.ResponsesOutputMessageContentTypeFallback { + fb = b.ResponsesOutputMessageContentFallback + } + } + } + if fb == nil { + t.Fatal("fallback block did not survive into the neutral response") + } + if fb.FromModel != "claude-fable-5" || fb.ToModel != "claude-opus-4-8" { + t.Errorf("from/to = %q -> %q, want claude-fable-5 -> claude-opus-4-8", fb.FromModel, fb.ToModel) + } + if fb.TriggerType != "refusal" { + t.Errorf("TriggerType = %q, want refusal", fb.TriggerType) + } + if fb.TriggerCategory == nil || *fb.TriggerCategory != "cyber" { + t.Errorf("TriggerCategory = %v, want cyber", fb.TriggerCategory) + } +} + +// A fallback block with no trigger must not emit "trigger":{"type":""}. +func TestFallbackBlockTrigger_OmittedWhenAbsent(t *testing.T) { + t.Parallel() + + block := convertContentBlockToAnthropic(schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesOutputMessageContentTypeFallback, + ResponsesOutputMessageContentFallback: &schemas.ResponsesOutputMessageContentFallback{ + FromModel: "claude-fable-5", + ToModel: "claude-opus-4-8", + }, + }) + if block == nil { + t.Fatal("expected a fallback block") + } + if block.Trigger != nil { + t.Errorf("expected no trigger emitted, got %+v", block.Trigger) + } +} diff --git a/core/providers/anthropic/types.go b/core/providers/anthropic/types.go index 0dde2c7f8dd..4f91452ad03 100644 --- a/core/providers/anthropic/types.go +++ b/core/providers/anthropic/types.go @@ -66,6 +66,17 @@ const ( // on custom tools (streams input_json_delta before full args are determined). // Per Table 20: GA on Anthropic/Bedrock/Vertex, Beta on Azure. AnthropicEagerInputStreamingBetaHeader = "fine-grained-tool-streaming-2025-05-14" + // AnthropicServerSideFallbackBetaHeader is required for the native "fallbacks" + // request field (server-side refusal fallback). Anthropic API only. + AnthropicServerSideFallbackBetaHeader = "server-side-fallback-2026-06-01" + // AnthropicFallbackCreditBetaHeader is required to receive fallback_credit_token + // on a refusal and to redeem it on the retry. Unlike server-side fallback this is + // supported on every Anthropic-family surface — but AWS ships it under its own date. + AnthropicFallbackCreditBetaHeader = "fallback-credit-2026-06-01" + // AnthropicFallbackCreditBetaHeaderAWS is the same feature's header on the + // AWS-operated surfaces (Bedrock Converse and Bedrock Mantle), which are a + // release behind the Claude API. See betaHeaderProviderVersion in utils.go. + AnthropicFallbackCreditBetaHeaderAWS = "fallback-credit-2026-06-09" // AnthropicComputerUseBetaHeader is required for computer use (version-specific). // computer_20251124 (Opus 4.6, Sonnet 4.6, Opus 4.5) uses the newer beta header. @@ -92,6 +103,8 @@ const ( AnthropicContextManagementBetaHeaderPrefix = "context-management-" AnthropicCompactionBetaHeaderPrefix = "compact-" AnthropicAdvisorBetaHeaderPrefix = "advisor-tool-" + AnthropicServerSideFallbackBetaHeaderPrefix = "server-side-fallback-" + AnthropicFallbackCreditBetaHeaderPrefix = "fallback-credit-" ) // ProviderFeatureSupport defines which Anthropic features a given provider supports. @@ -144,6 +157,8 @@ type ProviderFeatureSupport struct { ImageGeneration bool // image_generation server tool (OpenAI-only) ServiceTier bool // service_tier request field — strip when false (Vertex uses headers instead) Diagnostics bool // diagnostics request field — cache diagnostics (cache-diagnosis-2026-04-07 beta, diagnostics.previous_message_id). Claude API only per docs ("not supported on Amazon Bedrock or Vertex AI"); stripped elsewhere fail-closed. Azure rejects it. + ServerSideFallback bool // native "fallbacks" request field — server-side-fallback-2026-06-01. Claude API only per docs ("not available on Amazon Bedrock, Google Cloud, or Microsoft Foundry"). + FallbackCredit bool // fallback_credit_token request field + stop_details credit fields — fallback-credit-2026-06-01 (AWS surfaces: -2026-06-09). Documented on the Claude API, Amazon Bedrock, Google Cloud and Microsoft Foundry, i.e. the inverse of ServerSideFallback. } // ProviderFeatures maps each provider to its supported Anthropic features. @@ -161,8 +176,10 @@ var ProviderFeatures = map[schemas.ModelProvider]ProviderFeatureSupport{ InterleavedThinking: true, Skills: true, ContainerBasic: true, Context1M: true, FastMode: true, RedactThinking: true, TaskBudgets: true, InferenceGeo: true, EagerInputStreaming: true, AdvisorTool: true, - ServiceTier: true, - Diagnostics: true, // cache-diagnosis-2026-04-07 — Claude API only; only this provider keeps diagnostics.previous_message_id. + ServiceTier: true, + Diagnostics: true, // cache-diagnosis-2026-04-07 — Claude API only; only this provider keeps diagnostics.previous_message_id. + ServerSideFallback: true, // server-side-fallback-2026-06-01 — Claude API only. + FallbackCredit: true, // fallback-credit-2026-06-01. }, // Google Vertex AI — cite: A (overview table) and V-platform. // Notably NOT supported: MCP (MCP-excl), Skills/container.skills, @@ -191,6 +208,7 @@ var ProviderFeatures = map[schemas.ModelProvider]ProviderFeatureSupport{ InterleavedThinking: true, // V-platform confirms; fails on non-allowlisted 4-series Context1M: true, EagerInputStreaming: true, // fine-grained-tool-streaming GA per A + FallbackCredit: true, // fallback credit is documented on Google Cloud }, // AWS Bedrock — cite: A + B-header (definitive beta-header list). // Notably NOT supported per docs: MCP, Skills, FilesAPI, WebFetch, @@ -212,12 +230,20 @@ var ProviderFeatures = map[schemas.ModelProvider]ProviderFeatureSupport{ // AdvancedToolUse intentionally OFF on Bedrock. The bundle header // (advanced-tool-use-2025-11-20) is not listed in B-header; only the // narrow tool-examples-2025-10-29 header is, gated via InputExamples above. - ServiceTier: true, // Bedrock handles service_tier via its own typed conversion + ServiceTier: true, // Bedrock handles service_tier via its own typed conversion + FallbackCredit: true, // fallback-credit-2026-06-09 (AWS date) per the Bedrock userguide }, // Bedrock Mantle — same AWS-hosted Claude models as Bedrock, reached through // the native Anthropic Messages surface (/anthropic/v1/messages) instead of // Converse. Feature support is a property of the model+cloud, so this mirrors - // schemas.Bedrock — with one deliberate exception: the *Nova flags below. + // schemas.Bedrock, with the *Nova flags below as the deliberate exception. + // + // ServerSideFallback stays OFF here. Mantle is "Claude in Amazon Bedrock", which + // documents server-side fallback under "Features not supported"; the surface that + // does support it is "Claude Platform on AWS" + // (aws-external-anthropic.{region}.api.aws), a separate Anthropic-operated + // endpoint Bifrost does not implement. FallbackCredit is a different feature and + // is supported here — see AnthropicFallbackCreditBetaHeaderAWS for the date skew. // // WebSearchNova / CodeExecNova are intentionally OFF here. They exist only to // keep web_search / code_interpreter tools so the Bedrock Converse/Responses @@ -239,6 +265,7 @@ var ProviderFeatures = map[schemas.ModelProvider]ProviderFeatureSupport{ EagerInputStreaming: true, InputExamples: true, ServiceTier: true, + FallbackCredit: true, // fallback-credit-2026-06-09 (AWS date) per the Bedrock userguide }, // Microsoft Azure AI Foundry — cite: A (most features azureAiBeta) + // Az-platform ("supports most of Claude's features"). Excluded per @@ -255,7 +282,8 @@ var ProviderFeatures = map[schemas.ModelProvider]ProviderFeatureSupport{ RedactThinking: true, EagerInputStreaming: true, // FastMode, InferenceGeo, AdvisorTool, TaskBudgets — not documented on Az-platform; leave off. - ServiceTier: true, + ServiceTier: true, + FallbackCredit: true, // fallback credit is documented on Microsoft Foundry }, schemas.DeepSeek: { WebSearch: true, @@ -411,17 +439,96 @@ type AnthropicMessageRequest struct { ContextManagement *ContextManagement `json:"context_management,omitempty"` Container *AnthropicContainer `json:"container,omitempty"` // string id OR object with skills[]; skills require skills-2025-10-02 beta Diagnostics *AnthropicDiagnostics `json:"diagnostics,omitempty"` // cache diagnostics opt-in; requires cache-diagnosis-2026-04-07 beta (Anthropic API only) + // FallbackCreditToken redeems the credit minted by a prior refusal, repricing + // the retry's cache writes. Requires the fallback-credit beta header, and is + // rejected on count_tokens. + FallbackCreditToken *string `json:"fallback_credit_token,omitempty"` // Extra params for advanced use cases ExtraParams map[string]interface{} `json:"-"` - // Bifrost specific field (only parsed when converting from Provider -> Bifrost request) - Fallbacks []string `json:"fallbacks,omitempty"` + // Fallbacks is the overloaded request-level "fallbacks" field. Its entries are + // either Bifrost cross-provider fallback strings ("provider/model") or Anthropic + // native server-side fallback objects ({"model": ...}); see AnthropicFallbackEntry. + Fallbacks []AnthropicFallbackEntry `json:"fallbacks,omitempty"` // Internal field to track whether to strip scope from cache control blocks (for Vertex + prompt caching scope) stripCacheControlScope bool `json:"-"` } +// AnthropicNativeFallback is one entry of Anthropic's native server-side fallback +// list (beta server-side-fallback-2026-06-01): a model to retry the request on when +// the primary model refuses, with optional per-attempt max_tokens/thinking overrides. +type AnthropicNativeFallback struct { + Model string `json:"model"` + MaxTokens *int `json:"max_tokens,omitempty"` + Thinking *AnthropicThinking `json:"thinking,omitempty"` +} + +// AnthropicFallbackEntry is one entry of the overloaded request-level "fallbacks" +// field, which carries two unrelated features that share the same wire key, +// disambiguated by element shape: +// - a JSON string ("provider/model") is a Bifrost cross-provider fallback; +// - a JSON object ({"model": ...}) is an Anthropic native server-side fallback. +// +// Exactly one field is set after unmarshalling. +type AnthropicFallbackEntry struct { + BifrostModel string // set when the entry is a "provider/model" string + Native *AnthropicNativeFallback // set when the entry is a native {"model": ...} object +} + +// UnmarshalJSON dispatches on the first non-space byte: '"' → Bifrost string, +// '{' → Anthropic native object. +func (e *AnthropicFallbackEntry) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return fmt.Errorf("empty fallback entry") + } + switch trimmed[0] { + case '"': + return sonic.Unmarshal(trimmed, &e.BifrostModel) + case '{': + var native AnthropicNativeFallback + if err := sonic.Unmarshal(trimmed, &native); err != nil { + return err + } + e.Native = &native + return nil + default: + return fmt.Errorf("fallback entry must be a string or object, got: %s", trimmed) + } +} + +// MarshalJSON re-emits whichever form is set. +func (e AnthropicFallbackEntry) MarshalJSON() ([]byte, error) { + if e.Native != nil { + return sonic.Marshal(e.Native) + } + return sonic.Marshal(e.BifrostModel) +} + +// bifrostFallbackModels returns the Bifrost cross-provider fallback "provider/model" strings. +func (req *AnthropicMessageRequest) bifrostFallbackModels() []string { + var out []string + for _, f := range req.Fallbacks { + if f.Native == nil && f.BifrostModel != "" { + out = append(out, f.BifrostModel) + } + } + return out +} + +// nativeFallbacks returns the Anthropic native server-side fallback entries. +func (req *AnthropicMessageRequest) nativeFallbacks() []AnthropicNativeFallback { + var out []AnthropicNativeFallback + for _, f := range req.Fallbacks { + if f.Native != nil { + out = append(out, *f.Native) + } + } + return out +} + // SetStripCacheControlScope sets the stripCacheControlScope flag func (req *AnthropicMessageRequest) SetStripCacheControlScope(strip bool) { req.stripCacheControlScope = strip @@ -951,6 +1058,7 @@ const ( AnthropicContentBlockTypeThinking AnthropicContentBlockType = "thinking" AnthropicContentBlockTypeRedactedThinking AnthropicContentBlockType = "redacted_thinking" AnthropicContentBlockTypeCompaction AnthropicContentBlockType = "compaction" + AnthropicContentBlockTypeFallback AnthropicContentBlockType = "fallback" // server-side fallback boundary marker (server-side-fallback-2026-06-01) // code_execution inner result-content discriminators (the "content" object on // a *_code_execution_tool_result block; ContentObj.Type carries these). @@ -1054,6 +1162,44 @@ type AnthropicContentBlock struct { // web_fetch_tool_result / web_fetch_result inner retrieval timestamp RetrievedAt *string `json:"retrieved_at,omitempty"` + + // fallback block — the model boundary at a server-side fallback handoff + From *AnthropicFallbackModel `json:"from,omitempty"` // declining model + To *AnthropicFallbackModel `json:"to,omitempty"` // model that continues + Trigger *AnthropicFallbackTrigger `json:"trigger,omitempty"` // why the handoff happened +} + +// AnthropicFallbackModel is the {model} object on a fallback content block's from/to fields. +type AnthropicFallbackModel struct { + Model string `json:"model"` +} + +// AnthropicFallbackTrigger is the {type, category} object on a fallback content +// block, naming why the declining model handed off. Category mirrors +// AnthropicStopDetails.Category ("cyber", "bio", ...) and is absent when the +// decline maps to no named category. Undocumented on the fallbacks page but +// present on live responses. +type AnthropicFallbackTrigger struct { + Type string `json:"type"` + Category *string `json:"category,omitempty"` +} + +// AnthropicStopDetails explains a "refusal" stop_reason. Category and Explanation +// are null when the refusal maps to no named category; RecommendedModel names a +// model to retry directly when a fallback attempt was skipped (rate limit/overload). +type AnthropicStopDetails struct { + Type string `json:"type"` + Category *string `json:"category,omitempty"` + Explanation *string `json:"explanation,omitempty"` + RecommendedModel *string `json:"recommended_model,omitempty"` + // FallbackCreditToken is the one-time credit redeemable on a manual retry + // (fallback-credit beta). Null when no credit was minted for this refusal. + FallbackCreditToken *string `json:"fallback_credit_token,omitempty"` + // FallbackHasPrefillClaim selects the retry body shape: true means append an + // assistant message echoing the refused content, false means resend unchanged. + // Absent (not false) on AWS/Google/Microsoft while the field rolls out, which + // callers must read as "unknown" and try the append shape first. + FallbackHasPrefillClaim *bool `json:"fallback_has_prefill_claim,omitempty"` } // AnthropicSource represents image or document source in Anthropic format. @@ -1598,6 +1744,7 @@ type AnthropicMessageResponse struct { Content []AnthropicContentBlock `json:"content"` Model string `json:"model"` StopReason AnthropicStopReason `json:"stop_reason,omitempty"` + StopDetails *AnthropicStopDetails `json:"stop_details,omitempty"` // refusal detail; null for every stop_reason other than "refusal" StopSequence *string `json:"stop_sequence,omitempty"` Usage *AnthropicUsage `json:"usage,omitempty"` // Container is the code-execution sandbox container, present on responses that @@ -1624,7 +1771,8 @@ type AnthropicTextResponse struct { // AnthropicUsage represents usage information in Anthropic format type AnthropicUsage struct { - Type *string `json:"type,omitempty"` + Type *string `json:"type,omitempty"` + Model *string `json:"model,omitempty"` // model that produced this (iteration) attempt; sent on usage.iterations[] for server-side fallback // Unlike OpenAI models, Anthropic (claude) models separately track cache creation and cache read tokens, and its not included in the input_tokens field. InputTokens int `json:"input_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` @@ -1694,8 +1842,9 @@ type AnthropicStreamDelta struct { PartialJSON *string `json:"partial_json,omitempty"` Thinking *string `json:"thinking,omitempty"` Signature *string `json:"signature,omitempty"` - Citation *AnthropicTextCitation `json:"citation,omitempty"` // For citations_delta - StopReason *AnthropicStopReason `json:"stop_reason,omitempty"` // only not present in "message_start" events + Citation *AnthropicTextCitation `json:"citation,omitempty"` // For citations_delta + StopReason *AnthropicStopReason `json:"stop_reason,omitempty"` // only not present in "message_start" events + StopDetails *AnthropicStopDetails `json:"stop_details,omitempty"` // refusal detail on the final message_delta; null unless stop_reason is "refusal" StopSequence *string `json:"stop_sequence"` // Container is the code-execution sandbox container, surfaced on the final // message_delta of a response that used the code execution tool. diff --git a/core/providers/anthropic/utils.go b/core/providers/anthropic/utils.go index ce6ff0e0305..2e209e41a0c 100644 --- a/core/providers/anthropic/utils.go +++ b/core/providers/anthropic/utils.go @@ -351,6 +351,33 @@ func stripUnsupportedAnthropicFields(req *AnthropicMessageRequest, provider sche } } } + // A credit token is bound to the platform that minted it, so it is meaningless + // (and rejected as an unknown field) on a provider without the feature. + if !features.FallbackCredit { + req.FallbackCreditToken = nil + } + // Server-side fallback boundary markers are Anthropic-only. Replaying history that + // contains them onto a provider without the feature (e.g. a gateway fallback from + // Anthropic to Vertex) would forward an unknown content block and 400. The marker + // carries no user content, so dropping it is lossless for the conversation. + if !features.ServerSideFallback { + for mi := range req.Messages { + blocks := req.Messages[mi].Content.ContentBlocks + if len(blocks) == 0 { + continue + } + kept := make([]AnthropicContentBlock, 0, len(blocks)) + for _, b := range blocks { + if b.Type == AnthropicContentBlockTypeFallback { + continue + } + kept = append(kept, b) + } + if len(kept) != len(blocks) { + req.Messages[mi].Content.ContentBlocks = kept + } + } + } if req.ContextManagement != nil { // Gate edits by their type — compaction vs context-editing flags. kept := make([]ContextManagementEdit, 0, len(req.ContextManagement.Edits)) @@ -474,6 +501,48 @@ func StripUnsupportedFieldsFromRawBody(jsonBody []byte, provider schemas.ModelPr } } + // fallback_credit_token — bound to the minting platform, so a provider without + // the feature rejects it as an unknown field. + if !features.FallbackCredit { + var err error + jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "fallback_credit_token") + if err != nil { + return nil, err + } + } + + // fallback content blocks — server-side fallback boundary markers replayed in + // history. Anthropic-only; forwarding them to a provider without the feature + // (e.g. a gateway fallback from Anthropic to Vertex) sends an unknown content + // block. The marker carries no user content, so dropping it is lossless. + if !features.ServerSideFallback { + if msgs := providerUtils.GetJSONField(jsonBody, "messages"); msgs.IsArray() { + for mi, msg := range msgs.Array() { + content := msg.Get("content") + if !content.IsArray() { + continue + } + kept := make([]string, 0, len(content.Array())) + dropped := false + for _, block := range content.Array() { + if block.Get("type").String() == string(AnthropicContentBlockTypeFallback) { + dropped = true + continue + } + kept = append(kept, block.Raw) + } + if !dropped { + continue + } + // Message indices are stable (only content arrays are replaced). + jsonBody, err = sjson.SetRawBytes(jsonBody, fmt.Sprintf("messages.%d.content", mi), []byte("["+strings.Join(kept, ",")+"]")) + if err != nil { + return nil, fmt.Errorf("strip raw fallback blocks: %w", err) + } + } + } + } + // mcp_servers if !features.MCP && providerUtils.JSONFieldExists(jsonBody, "mcp_servers") { jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "mcp_servers") @@ -1173,6 +1242,20 @@ func AddMissingBetaHeadersToContext(ctx *schemas.BifrostContext, req *AnthropicM headers = appendUniqueHeader(headers, AnthropicCacheDiagnosisBetaHeader) } } + // Check for native server-side fallback ("fallbacks" object entries) + if len(req.nativeFallbacks()) > 0 { + if !hasProvider || features.ServerSideFallback { + headers = appendUniqueHeader(headers, AnthropicServerSideFallbackBetaHeader) + } + } + // Check for fallback credit redemption (fallback_credit_token present). The + // canonical date is added here; FilterBetaHeadersForProvider rewrites it to the + // AWS date on Bedrock/Mantle. + if req.FallbackCreditToken != nil { + if !hasProvider || features.FallbackCredit { + headers = appendUniqueHeader(headers, AnthropicFallbackCreditBetaHeader) + } + } // 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 { @@ -1273,6 +1356,63 @@ var betaHeaderPrefixKnown = []string{ AnthropicEagerInputStreamingBetaHeaderPrefix, AnthropicAdvisorBetaHeaderPrefix, AnthropicCacheDiagnosisBetaHeaderPrefix, + AnthropicServerSideFallbackBetaHeaderPrefix, + AnthropicFallbackCreditBetaHeaderPrefix, +} + +// betaHeaderProviderVersion rewrites a beta header's version date on providers +// that ship the same feature under a different date. Keyed by provider, then by +// the known prefix the token matched. Applied in FilterBetaHeadersForProvider +// after the support check, so it covers both transports (HTTP header and the +// body-side anthropic_beta array). +var betaHeaderProviderVersion = map[schemas.ModelProvider]map[string]string{ + // AWS-operated surfaces trail the Claude API on fallback credit. + schemas.Bedrock: {AnthropicFallbackCreditBetaHeaderPrefix: AnthropicFallbackCreditBetaHeaderAWS}, + schemas.BedrockMantle: {AnthropicFallbackCreditBetaHeaderPrefix: AnthropicFallbackCreditBetaHeaderAWS}, +} + +// stripBifrostFallbacksFromBody removes Bifrost cross-provider fallback entries +// (JSON strings) from the request-level "fallbacks" array, which Anthropic does +// not understand. Anthropic native server-side fallback entries (JSON objects) +// are kept only when the target provider supports the feature; on providers that +// don't (Bedrock incl. bedrock-mantle, Vertex, Azure) they are stripped +// fail-closed, since AddMissingBetaHeadersToContext withholds the required beta +// header there and forwarding the field alone 400s with +// "fallbacks: Extra inputs are not permitted". The field is deleted entirely +// when no entries remain. +func stripBifrostFallbacksFromBody(jsonBody []byte, provider schemas.ModelProvider) ([]byte, error) { + fb := gjson.GetBytes(jsonBody, "fallbacks") + if !fb.Exists() { + return jsonBody, nil + } + if !fb.IsArray() { + return sjson.DeleteBytes(jsonBody, "fallbacks") + } + // Unknown/custom providers keep native entries, mirroring the + // "!hasProvider || feature" gating in AddMissingBetaHeadersToContext. + features, known := ProviderFeatures[provider] + keepNative := !known || features.ServerSideFallback + var native [][]byte + if keepNative { + for _, el := range fb.Array() { + if el.IsObject() { + native = append(native, []byte(el.Raw)) + } + } + } + if len(native) == 0 { + return sjson.DeleteBytes(jsonBody, "fallbacks") + } + var buf bytes.Buffer + buf.WriteByte('[') + for i, n := range native { + if i > 0 { + buf.WriteByte(',') + } + buf.Write(n) + } + buf.WriteByte(']') + return sjson.SetRawBytes(jsonBody, "fallbacks", buf.Bytes()) } // betaHeaderPrefixExists checks if any header in existing shares a known prefix with newHeader. @@ -1597,6 +1737,8 @@ var betaHeaderPrefixToFeature = map[string]func(ProviderFeatureSupport) bool{ AnthropicEagerInputStreamingBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.EagerInputStreaming }, AnthropicAdvisorBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.AdvisorTool }, AnthropicCacheDiagnosisBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.Diagnostics }, + AnthropicServerSideFallbackBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.ServerSideFallback }, + AnthropicFallbackCreditBetaHeaderPrefix: func(f ProviderFeatureSupport) bool { return f.FallbackCredit }, } // MergeBetaHeaders collects anthropic-beta values from provider ExtraHeaders and @@ -1706,6 +1848,11 @@ func FilterBetaHeadersForProvider(headers []string, provider schemas.ModelProvid if !supported { continue } + if rewrites, ok := betaHeaderProviderVersion[provider]; ok { + if replacement, ok := rewrites[matchedPrefix]; ok { + token = replacement + } + } filtered = append(filtered, token) } } diff --git a/core/providers/anthropic/utils_test.go b/core/providers/anthropic/utils_test.go index ae393a86e71..ad2cc88659a 100644 --- a/core/providers/anthropic/utils_test.go +++ b/core/providers/anthropic/utils_test.go @@ -2196,6 +2196,245 @@ func TestGetRequestBodyForResponses_RawBodyStripsFallbacks(t *testing.T) { } } +// TestAnthropicFallbackEntry_UnmarshalJSON verifies the overloaded "fallbacks" +// field disambiguates Bifrost cross-provider strings from Anthropic native objects. +func TestAnthropicFallbackEntry_UnmarshalJSON(t *testing.T) { + t.Run("string entry is a Bifrost fallback", func(t *testing.T) { + var e AnthropicFallbackEntry + if err := sonic.Unmarshal([]byte(`"openai/gpt-4o"`), &e); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.Native != nil { + t.Errorf("expected Native nil, got %+v", e.Native) + } + if e.BifrostModel != "openai/gpt-4o" { + t.Errorf("expected BifrostModel openai/gpt-4o, got %q", e.BifrostModel) + } + }) + + t.Run("object entry is a native fallback", func(t *testing.T) { + var e AnthropicFallbackEntry + if err := sonic.Unmarshal([]byte(`{"model":"claude-opus-4-8","max_tokens":512}`), &e); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.BifrostModel != "" { + t.Errorf("expected empty BifrostModel, got %q", e.BifrostModel) + } + if e.Native == nil || e.Native.Model != "claude-opus-4-8" { + t.Fatalf("expected native model claude-opus-4-8, got %+v", e.Native) + } + if e.Native.MaxTokens == nil || *e.Native.MaxTokens != 512 { + t.Errorf("expected max_tokens 512, got %+v", e.Native.MaxTokens) + } + }) + + t.Run("marshal round-trips both forms", func(t *testing.T) { + str := AnthropicFallbackEntry{BifrostModel: "anthropic/claude-sonnet-4-5"} + if data, err := sonic.Marshal(str); err != nil { + t.Fatalf("marshal string: %v", err) + } else if string(data) != `"anthropic/claude-sonnet-4-5"` { + t.Errorf("unexpected string marshal: %s", data) + } + obj := AnthropicFallbackEntry{Native: &AnthropicNativeFallback{Model: "claude-opus-4-8"}} + if data, err := sonic.Marshal(obj); err != nil { + t.Fatalf("marshal object: %v", err) + } else if !gjson.GetBytes(data, "model").Exists() { + t.Errorf("expected object marshal with model, got: %s", data) + } + }) +} + +// TestAnthropicMessageRequest_NativeFallbacksParse is the regression for the +// reported "Invalid JSON": a request carrying Anthropic's native fallbacks shape +// must parse instead of failing to unmarshal into the old []string field. +func TestAnthropicMessageRequest_NativeFallbacksParse(t *testing.T) { + body := []byte(`{"model":"claude-fable-5","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"fallbacks":[{"model":"claude-opus-4-8"}]}`) + + var req AnthropicMessageRequest + if err := sonic.Unmarshal(body, &req); err != nil { + t.Fatalf("native fallbacks must parse, got error: %v", err) + } + native := req.nativeFallbacks() + if len(native) != 1 || native[0].Model != "claude-opus-4-8" { + t.Fatalf("expected one native fallback claude-opus-4-8, got %+v", native) + } + if len(req.bifrostFallbackModels()) != 0 { + t.Errorf("expected no bifrost fallbacks, got %v", req.bifrostFallbackModels()) + } + + // Bifrost string form still parses as a cross-provider fallback. + var bifrostReq AnthropicMessageRequest + if err := sonic.Unmarshal([]byte(`{"model":"anthropic/claude-sonnet-4-5","fallbacks":["openai/gpt-4o"]}`), &bifrostReq); err != nil { + t.Fatalf("bifrost fallbacks must parse, got error: %v", err) + } + if got := bifrostReq.bifrostFallbackModels(); len(got) != 1 || got[0] != "openai/gpt-4o" { + t.Errorf("expected bifrost fallback openai/gpt-4o, got %v", got) + } + if len(bifrostReq.nativeFallbacks()) != 0 { + t.Errorf("expected no native fallbacks, got %v", bifrostReq.nativeFallbacks()) + } +} + +// TestToBifrostResponsesRequest_FallbacksRouting verifies fallbacks route by shape: +// Bifrost strings become BifrostResponsesRequest.Fallbacks; native objects are +// carried in ExtraParams for verbatim forwarding to Anthropic. +func TestToBifrostResponsesRequest_FallbacksRouting(t *testing.T) { + t.Run("native objects go to ExtraParams", func(t *testing.T) { + req := &AnthropicMessageRequest{ + Model: "claude-fable-5", + MaxTokens: 1024, + Fallbacks: []AnthropicFallbackEntry{{Native: &AnthropicNativeFallback{Model: "claude-opus-4-8"}}}, + } + out := req.ToBifrostResponsesRequest(nil) + if len(out.Fallbacks) != 0 { + t.Errorf("expected no bifrost fallbacks, got %+v", out.Fallbacks) + } + native, ok := out.Params.ExtraParams["fallbacks"].([]AnthropicNativeFallback) + if !ok || len(native) != 1 || native[0].Model != "claude-opus-4-8" { + t.Fatalf("expected native fallback in ExtraParams, got %#v", out.Params.ExtraParams["fallbacks"]) + } + }) + + t.Run("bifrost strings go to Fallbacks", func(t *testing.T) { + req := &AnthropicMessageRequest{ + Model: "anthropic/claude-sonnet-4-5", + Fallbacks: []AnthropicFallbackEntry{{BifrostModel: "openai/gpt-4o"}}, + } + out := req.ToBifrostResponsesRequest(nil) + if len(out.Fallbacks) != 1 || out.Fallbacks[0].Provider != schemas.OpenAI || out.Fallbacks[0].Model != "gpt-4o" { + t.Fatalf("expected parsed bifrost fallback openai/gpt-4o, got %+v", out.Fallbacks) + } + if _, exists := out.Params.ExtraParams["fallbacks"]; exists { + t.Errorf("expected no native fallbacks in ExtraParams") + } + }) +} + +// TestAddMissingBetaHeadersToContext_ServerSideFallback verifies the beta header +// is auto-added for native fallbacks on Anthropic and gated off on providers that +// do not support the feature. +func TestAddMissingBetaHeadersToContext_ServerSideFallback(t *testing.T) { + t.Run("anthropic adds the beta header", func(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + req := &AnthropicMessageRequest{ + Fallbacks: []AnthropicFallbackEntry{{Native: &AnthropicNativeFallback{Model: "claude-opus-4-8"}}}, + } + AddMissingBetaHeadersToContext(ctx, req, schemas.Anthropic) + extraHeaders, _ := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string) + if !slices.Contains(extraHeaders[AnthropicBetaHeader], AnthropicServerSideFallbackBetaHeader) { + t.Errorf("expected %q, got %v", AnthropicServerSideFallbackBetaHeader, extraHeaders[AnthropicBetaHeader]) + } + }) + + t.Run("vertex does not add the beta header", func(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + req := &AnthropicMessageRequest{ + Fallbacks: []AnthropicFallbackEntry{{Native: &AnthropicNativeFallback{Model: "claude-opus-4-8"}}}, + } + AddMissingBetaHeadersToContext(ctx, req, schemas.Vertex) + extraHeaders, _ := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string) + if slices.Contains(extraHeaders[AnthropicBetaHeader], AnthropicServerSideFallbackBetaHeader) { + t.Errorf("did not expect server-side-fallback header on Vertex, got %v", extraHeaders[AnthropicBetaHeader]) + } + }) + + t.Run("bifrost string fallbacks do not add the beta header", func(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + req := &AnthropicMessageRequest{ + Fallbacks: []AnthropicFallbackEntry{{BifrostModel: "openai/gpt-4o"}}, + } + AddMissingBetaHeadersToContext(ctx, req, schemas.Anthropic) + extraHeaders, _ := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string) + if slices.Contains(extraHeaders[AnthropicBetaHeader], AnthropicServerSideFallbackBetaHeader) { + t.Errorf("did not expect server-side-fallback header for bifrost fallbacks, got %v", extraHeaders[AnthropicBetaHeader]) + } + }) +} + +// TestBuildAnthropicResponsesRequestBody_NativeFallbacks covers the end-to-end +// body assembly for both the raw-passthrough and typed paths. +func TestBuildAnthropicResponsesRequestBody_NativeFallbacks(t *testing.T) { + t.Run("raw path preserves native fallbacks and injects beta header", func(t *testing.T) { + rawBody := []byte(`{"model":"claude-fable-5","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"fallbacks":[{"model":"claude-opus-4-8"}]}`) + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + ctx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) + + request := &schemas.BifrostResponsesRequest{ + Provider: schemas.Anthropic, + Model: "claude-fable-5", + RawRequestBody: rawBody, + } + result, bifrostErr := BuildAnthropicResponsesRequestBody(ctx, request, AnthropicRequestBuildConfig{ + Provider: schemas.Anthropic, + }) + if bifrostErr != nil { + t.Fatalf("unexpected error: %v", bifrostErr) + } + fb := gjson.GetBytes(result, "fallbacks") + if !fb.IsArray() || len(fb.Array()) != 1 || fb.Array()[0].Get("model").String() != "claude-opus-4-8" { + t.Errorf("expected native fallbacks preserved, got: %s", fb.Raw) + } + extraHeaders, _ := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string) + if !slices.Contains(extraHeaders[AnthropicBetaHeader], AnthropicServerSideFallbackBetaHeader) { + t.Errorf("expected beta header injected, got %v", extraHeaders[AnthropicBetaHeader]) + } + }) + + t.Run("raw path still strips bifrost string fallbacks", func(t *testing.T) { + rawBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"fallbacks":["anthropic/claude-haiku-4-5"]}`) + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + ctx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) + + request := &schemas.BifrostResponsesRequest{ + Provider: schemas.Anthropic, + Model: "claude-sonnet-4-5", + RawRequestBody: rawBody, + } + result, bifrostErr := BuildAnthropicResponsesRequestBody(ctx, request, AnthropicRequestBuildConfig{ + Provider: schemas.Anthropic, + }) + if bifrostErr != nil { + t.Fatalf("unexpected error: %v", bifrostErr) + } + if gjson.GetBytes(result, "fallbacks").Exists() { + t.Errorf("expected bifrost fallbacks stripped, got: %s", result) + } + }) + + t.Run("typed path emits native fallbacks and injects beta header", func(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + maxTokens := 1024 + request := &schemas.BifrostResponsesRequest{ + Provider: schemas.Anthropic, + Model: "claude-fable-5", + Input: []schemas.ResponsesMessage{{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("hi")}, + }}, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: &maxTokens, + ExtraParams: map[string]interface{}{ + "fallbacks": []AnthropicNativeFallback{{Model: "claude-opus-4-8"}}, + }, + }, + } + result, bifrostErr := BuildAnthropicResponsesRequestBody(ctx, request, AnthropicRequestBuildConfig{ + Provider: schemas.Anthropic, + }) + if bifrostErr != nil { + t.Fatalf("unexpected error: %v", bifrostErr) + } + fb := gjson.GetBytes(result, "fallbacks") + if !fb.IsArray() || len(fb.Array()) != 1 || fb.Array()[0].Get("model").String() != "claude-opus-4-8" { + t.Errorf("expected native fallbacks emitted, got: %s", fb.Raw) + } + extraHeaders, _ := ctx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string) + if !slices.Contains(extraHeaders[AnthropicBetaHeader], AnthropicServerSideFallbackBetaHeader) { + t.Errorf("expected beta header injected, got %v", extraHeaders[AnthropicBetaHeader]) + } + }) +} + func TestApplyMCPToolsetConfigToBifrostTool(t *testing.T) { t.Run("allowlist pattern merges correctly", func(t *testing.T) { bifrostTool := &schemas.ResponsesTool{ diff --git a/core/providers/bedrock/responses.go b/core/providers/bedrock/responses.go index fea0576cb42..865414b1e63 100644 --- a/core/providers/bedrock/responses.go +++ b/core/providers/bedrock/responses.go @@ -4501,6 +4501,11 @@ func convertBifrostResponsesMessageContentBlocksToBedrockContentBlocks(ctx conte if block.ResponsesOutputMessageContentCompaction != nil { bedrockBlock.Text = &block.ResponsesOutputMessageContentCompaction.Summary } + case schemas.ResponsesOutputMessageContentTypeFallback: + // Anthropic-only server-side fallback boundary marker; Bedrock doesn't + // support the feature. Unlike compaction it carries no user content + // (only from/to model names), so skip it entirely. + continue case schemas.ResponsesInputMessageContentBlockTypeFile: if block.ResponsesInputMessageContentBlockFile != nil { doc := &BedrockDocumentSource{ diff --git a/core/providers/cohere/responses.go b/core/providers/cohere/responses.go index 77f9eb8fad7..ca7aa30deff 100644 --- a/core/providers/cohere/responses.go +++ b/core/providers/cohere/responses.go @@ -1801,6 +1801,11 @@ func convertResponsesMessageContentBlocksToCohere(blocks []schemas.ResponsesMess Thinking: block.Text, }) } + case schemas.ResponsesOutputMessageContentTypeFallback: + // Anthropic-only server-side fallback boundary marker. Unlike compaction it + // carries no user content (only from/to model names), so drop it rather than + // rendering it as text. + continue case schemas.ResponsesOutputMessageContentTypeCompaction: // Convert compaction to text block for Cohere (compaction is Anthropic-specific) if block.ResponsesOutputMessageContentCompaction != nil { diff --git a/core/providers/gemini/responses.go b/core/providers/gemini/responses.go index 59d5f021a9b..19c7bc45758 100644 --- a/core/providers/gemini/responses.go +++ b/core/providers/gemini/responses.go @@ -3453,6 +3453,12 @@ func convertContentBlockToGeminiPart(block schemas.ResponsesMessageContentBlock, } } + case schemas.ResponsesOutputMessageContentTypeFallback: + // Anthropic-specific server-side fallback boundary marker. Unlike compaction it + // carries no user content (only from/to model names), so drop it rather than + // rendering it as text. + return nil, nil + case schemas.ResponsesInputMessageContentBlockTypeImage: if block.ResponsesInputMessageContentBlockImage != nil && block.ResponsesInputMessageContentBlockImage.ImageURL != nil { imageURL := *block.ResponsesInputMessageContentBlockImage.ImageURL diff --git a/core/providers/openai/responses.go b/core/providers/openai/responses.go index 1a0d94cbb5d..18e54c93fdf 100644 --- a/core/providers/openai/responses.go +++ b/core/providers/openai/responses.go @@ -46,26 +46,29 @@ func ToOpenAIResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.B var messages []schemas.ResponsesMessage // OpenAI models (except for gpt-oss) do not support reasoning content blocks, so we need to convert them to summaries, if there are any - // OpenAI also doesn't support compaction content blocks, so we need to convert them to text blocks + // OpenAI also doesn't support compaction content blocks, so we need to convert them to text blocks, + // nor Anthropic's server-side fallback boundary markers, which are dropped outright. messages = make([]schemas.ResponsesMessage, 0, len(bifrostReq.Input)) for _, message := range bifrostReq.Input { - // First, check if message has compaction content blocks and convert them to text + // First, check if message has compaction/fallback content blocks and rewrite them if message.Content != nil && len(message.Content.ContentBlocks) > 0 { - hasCompaction := false + needsRewrite := false for _, block := range message.Content.ContentBlocks { - if block.Type == schemas.ResponsesOutputMessageContentTypeCompaction { - hasCompaction = true + if block.Type == schemas.ResponsesOutputMessageContentTypeCompaction || + block.Type == schemas.ResponsesOutputMessageContentTypeFallback { + needsRewrite = true break } } - if hasCompaction { + if needsRewrite { // Create a new message with converted content blocks newMessage := message newContentBlocks := make([]schemas.ResponsesMessageContentBlock, 0, len(message.Content.ContentBlocks)) for _, block := range message.Content.ContentBlocks { - if block.Type == schemas.ResponsesOutputMessageContentTypeCompaction { + switch block.Type { + case schemas.ResponsesOutputMessageContentTypeCompaction: // Convert compaction block to text block if block.ResponsesOutputMessageContentCompaction != nil && block.ResponsesOutputMessageContentCompaction.Summary != "" { newContentBlocks = append(newContentBlocks, schemas.ResponsesMessageContentBlock{ @@ -74,8 +77,12 @@ func ToOpenAIResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.B }) } // If summary is empty, skip the block entirely - } else { - // Keep non-compaction blocks as-is + case schemas.ResponsesOutputMessageContentTypeFallback: + // Anthropic-only server-side fallback boundary marker. Unlike + // compaction it carries no user content (only from/to model + // names), so drop it rather than rendering it as text. + default: + // Keep every other block as-is newContentBlocks = append(newContentBlocks, block) } } @@ -87,7 +94,7 @@ func ToOpenAIResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.B } message = newMessage } else { - // If all blocks were compaction with empty summaries, skip message + // Nothing survived (empty-summary compaction and/or fallback markers) continue } } diff --git a/core/providers/openai/responses_test.go b/core/providers/openai/responses_test.go index c45e6da4117..b7f06c459a7 100644 --- a/core/providers/openai/responses_test.go +++ b/core/providers/openai/responses_test.go @@ -2225,3 +2225,75 @@ func TestToOpenAIResponsesRequest_DefaultsImageDetail(t *testing.T) { t.Errorf("caller's input was mutated: detail = %q", *original.Detail) } } + +// TestToOpenAIResponsesRequest_FallbackBlockDropped verifies that Anthropic's +// server-side fallback boundary marker never reaches OpenAI. Unlike a compaction +// block (which is promoted to text), it carries no user content, so it is dropped. +func TestToOpenAIResponsesRequest_FallbackBlockDropped(t *testing.T) { + t.Run("fallback block is dropped, surrounding content survives", func(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Model: "gpt-5.5", + Input: []schemas.ResponsesMessage{{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeFallback, + ResponsesOutputMessageContentFallback: &schemas.ResponsesOutputMessageContentFallback{ + FromModel: "claude-fable-5", + ToModel: "claude-opus-4-8", + }, + }, + {Type: schemas.ResponsesOutputMessageContentTypeText, Text: schemas.Ptr("Hi there")}, + }, + }, + }}, + } + + result := ToOpenAIResponsesRequest(nil, bifrostReq) + if result == nil || len(result.Input.OpenAIResponsesRequestInputArray) != 1 { + t.Fatalf("expected one converted input message, got %#v", result) + } + msg := result.Input.OpenAIResponsesRequestInputArray[0] + if msg.Content == nil { + t.Fatal("expected converted message to retain content") + } + for _, b := range msg.Content.ContentBlocks { + if b.Type == schemas.ResponsesOutputMessageContentTypeFallback { + t.Fatalf("fallback block leaked to OpenAI: %#v", msg.Content.ContentBlocks) + } + // The marker must not be smuggled through as text either. + if b.Text != nil && strings.Contains(*b.Text, "claude-fable-5") { + t.Fatalf("fallback marker rendered as text: %q", *b.Text) + } + } + if len(msg.Content.ContentBlocks) != 1 || msg.Content.ContentBlocks[0].Text == nil || *msg.Content.ContentBlocks[0].Text != "Hi there" { + t.Fatalf("expected only the surviving text block, got %#v", msg.Content.ContentBlocks) + } + }) + + t.Run("message with only a fallback block is skipped entirely", func(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Model: "gpt-5.5", + Input: []schemas.ResponsesMessage{{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{{ + Type: schemas.ResponsesOutputMessageContentTypeFallback, + ResponsesOutputMessageContentFallback: &schemas.ResponsesOutputMessageContentFallback{ + FromModel: "claude-fable-5", + ToModel: "claude-opus-4-8", + }, + }}, + }, + }}, + } + + result := ToOpenAIResponsesRequest(nil, bifrostReq) + if result != nil && len(result.Input.OpenAIResponsesRequestInputArray) != 0 { + t.Fatalf("expected the fallback-only message to be skipped, got %#v", result.Input.OpenAIResponsesRequestInputArray) + } + }) +} diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 72250528a6f..44328975c09 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -238,7 +238,8 @@ type BifrostResponsesResponse struct { Container *ResponsesResponseContainer `json:"container,omitempty"` // Code-execution sandbox container (Anthropic surfaces it on the response / final streaming message_delta). The neutral per-call id also lives on ResponsesCodeInterpreterToolCall.ContainerID. Status *string `json:"status,omitempty"` // completed, failed, in_progress, cancelled, queued, or incomplete StreamOptions *ResponsesStreamOptions `json:"stream_options,omitempty"` - StopReason *string `json:"stop_reason,omitempty"` // Not in OpenAI's spec, but sent by other providers + StopReason *string `json:"stop_reason,omitempty"` // Not in OpenAI's spec, but sent by other providers + StopDetails *ResponsesStopDetails `json:"stop_details,omitempty"` // Anthropic refusal detail; null unless stop_reason is "refusal" Store *bool `json:"store,omitempty"` Temperature *float64 `json:"temperature,omitempty"` Text *ResponsesTextConfig `json:"text,omitempty"` @@ -932,8 +933,25 @@ const ( ResponsesResponseIncompleteReasonContentFilter = "content_filter" ) +// ResponsesStopDetails carries Anthropic's stop_details for a "refusal" stop_reason. +// Category and Explanation are null when the refusal maps to no named category; +// RecommendedModel names a model to retry directly when a fallback attempt was skipped. +type ResponsesStopDetails struct { + Type string `json:"type"` + Category *string `json:"category,omitempty"` + Explanation *string `json:"explanation,omitempty"` + RecommendedModel *string `json:"recommended_model,omitempty"` + // FallbackCreditToken is the one-time credit redeemable on a manual retry to + // avoid re-paying cache-write rates; null when no credit was minted. + FallbackCreditToken *string `json:"fallback_credit_token,omitempty"` + // FallbackHasPrefillClaim selects the retry body shape; absent means "unknown", + // which callers must not collapse to false. + FallbackHasPrefillClaim *bool `json:"fallback_has_prefill_claim,omitempty"` +} + type ResponsesResponseUsage struct { Type *string `json:"type,omitempty"` // type field is sent by anthropic + Model *string `json:"model,omitempty"` // model that produced this (iteration) attempt; sent on iterations[] for Anthropic server-side fallback InputTokens int `json:"input_tokens"` // Number of input tokens (prompt tokens + cached tokens) InputTokensDetails *ResponsesResponseInputTokens `json:"input_tokens_details"` // Detailed breakdown of input tokens OutputTokens int `json:"output_tokens"` // Number of output tokens (completion tokens + reasoning tokens) @@ -1296,6 +1314,10 @@ const ( ResponsesOutputMessageContentTypeRenderedContent ResponsesMessageContentBlockType = "rendered_content" ResponsesOutputMessageContentTypeCompaction ResponsesMessageContentBlockType = "compaction" + + // ResponsesOutputMessageContentTypeFallback marks a server-side fallback handoff + // boundary in the output (Anthropic server-side-fallback-2026-06-01). + ResponsesOutputMessageContentTypeFallback ResponsesMessageContentBlockType = "fallback" ) // ResponsesMessageContentBlock represents different types of content (text, image, file, audio) @@ -1317,6 +1339,7 @@ type ResponsesMessageContentBlock struct { *ResponsesOutputMessageContentRefusal // Model refusal to answer *ResponsesOutputMessageContentRenderedContent // Rendered content from search entry point *ResponsesOutputMessageContentCompaction // Compaction content from the model + *ResponsesOutputMessageContentFallback // Server-side fallback handoff boundary (from/to model) // Not in OpenAI's schemas, but sent by a few providers (Anthropic, Bedrock are some of them) CacheControl *CacheControl `json:"cache_control,omitempty"` @@ -1329,6 +1352,17 @@ type ResponsesMessageContentBlock struct { type ResponsesOutputMessageContentCompaction struct { Summary string `json:"summary,omitempty"` // The compaction summary text } + +// ResponsesOutputMessageContentFallback carries the model boundary of a server-side +// fallback handoff (Anthropic's fallback content block: from/to model). +type ResponsesOutputMessageContentFallback struct { + FromModel string `json:"from_model,omitempty"` // model that declined + ToModel string `json:"to_model,omitempty"` // model that continues + // TriggerType names why the handoff happened (e.g. "refusal"); TriggerCategory + // is the policy area ("cyber", "bio", ...), absent when unnamed. + TriggerType string `json:"trigger_type,omitempty"` + TriggerCategory *string `json:"trigger_category,omitempty"` +} type ResponsesOutputMessageContentRenderedContent struct { RenderedContent string `json:"rendered_content"` // HTML/styled content from search entry point } diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json index 7feca671547..a7cd21f77fd 100644 --- a/tests/e2e/api/collections/provider-harness.json +++ b/tests/e2e/api/collections/provider-harness.json @@ -40731,6 +40731,317 @@ ] } ] + }, + { + "name": "26. Anthropic Native Server-Side Fallback (fallbacks shape overload)", + "description": "Pins the overloaded request-level `fallbacks` wire key on /anthropic/v1/messages.\nTwo unrelated features share the key and are disambiguated by element shape:\n - array of STRINGS (\"provider/model\") = Bifrost cross-provider gateway fallback.\n - array of OBJECTS ([{\"model\": \"...\"}]) = Anthropic native server-side fallback\n (beta server-side-fallback-2026-06-01, refusal fallback).\n\nRegressions pinned:\n 1. Object-form fallbacks used to fail Bifrost-side unmarshalling with 'Invalid JSON'\n because AnthropicMessageRequest.Fallbacks was typed []string. It is now a shape\n tolerant union, so both forms parse.\n 2. Native (object) fallbacks are Anthropic-only. Forwarding them to Vertex, Bedrock,\n Bedrock Mantle or Azure produced a live 400 'fallbacks: Extra inputs are not\n permitted', because AddMissingBetaHeadersToContext correctly withholds the beta\n header there while the field was still forwarded. Bifrost now strips native\n entries fail-closed for providers whose ProviderFeatures.ServerSideFallback is\n false. Note bedrock_mantle is NOT supported: it is Bedrock's native Anthropic\n surface, but feature availability still follows Amazon Bedrock.\n 3. Bifrost string-form fallbacks must keep working and must never leak to a provider.\n\nBoth the Claude Code passthrough path (claude-cli User-Agent -> raw request body) and\nthe normalized path are covered, since the strip runs on the shared egress tail.\n\nNot covered here: the `fallback` content block and stop_reason 'refusal'. Those only\noccur when a safety classifier actually declines a request, which is non-deterministic\nand inappropriate to provoke in a live sweep; they are covered by Go unit tests in\ncore/providers/anthropic/serversidefallback_test.go.", + "item": [ + { + "name": "vertex/claude-opus-4-7 · native object fallbacks stripped for unsupported provider", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"vertex/claude-opus-4-7\",\n \"max_tokens\": 64,\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }],\n \"fallbacks\": [{ \"model\": \"claude-opus-4-8\" }]\n}" + }, + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Infra noise (auth / rate limit / upstream 5xx) skips so it does not false-fail.", + "// 400 is deliberately NOT guarded here - a 400 IS the regression signature for this folder.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var raw = pm.response.text() || '';", + "pm.test('vertex/claude-opus-4-7: native fallbacks parsed and stripped, no provider rejection', function () {", + " // Original report: object-form fallbacks failed to unmarshal into []string -> 'Invalid JSON'.", + " pm.expect(raw.toLowerCase().indexOf('invalid json'), 'Bifrost failed to parse the fallbacks field: ' + raw).to.equal(-1);", + " // Live 400 signature when the field reaches a target that does not accept it.", + " pm.expect(raw.indexOf('Extra inputs are not permitted'), 'fallbacks reached a target that rejects it: ' + raw).to.equal(-1);", + " // The provider does not support server-side fallback, so Bifrost must strip the", + " // native entries before egress (the beta header is withheld there too).", + " pm.expect(pm.response.code, 'request failed: ' + raw).to.be.below(400);", + "});" + ] + } + } + ] + }, + { + "name": "vertex/claude-opus-4-7 · native object fallbacks stripped on Claude Code passthrough (claude-cli UA)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "User-Agent", + "value": "claude-cli/1.0" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"vertex/claude-opus-4-7\",\n \"max_tokens\": 64,\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }],\n \"fallbacks\": [{ \"model\": \"claude-opus-4-8\" }]\n}" + }, + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Infra noise (auth / rate limit / upstream 5xx) skips so it does not false-fail.", + "// 400 is deliberately NOT guarded here - a 400 IS the regression signature for this folder.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var raw = pm.response.text() || '';", + "pm.test('vertex/claude-opus-4-7 Claude Code passthrough: native fallbacks stripped from the raw body', function () {", + " // Original report: object-form fallbacks failed to unmarshal into []string -> 'Invalid JSON'.", + " pm.expect(raw.toLowerCase().indexOf('invalid json'), 'Bifrost failed to parse the fallbacks field: ' + raw).to.equal(-1);", + " // Live 400 signature when the field reaches a target that does not accept it.", + " pm.expect(raw.indexOf('Extra inputs are not permitted'), 'fallbacks reached a target that rejects it: ' + raw).to.equal(-1);", + " // A claude-cli User-Agent puts Bifrost on the raw-request-body passthrough path.", + " // If newman strips the custom User-Agent this still exercises the normalized path;", + " // either way the native fallbacks must not reach Vertex.", + " pm.expect(pm.response.code, 'request failed: ' + raw).to.be.below(400);", + "});" + ] + } + } + ] + }, + { + "name": "bedrock/global.anthropic.claude-opus-4-7 · native object fallbacks stripped for unsupported provider", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-opus-4-7\",\n \"max_tokens\": 64,\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }],\n \"fallbacks\": [{ \"model\": \"claude-opus-4-8\" }]\n}" + }, + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Infra noise (auth / rate limit / upstream 5xx) skips so it does not false-fail.", + "// 400 is deliberately NOT guarded here - a 400 IS the regression signature for this folder.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var raw = pm.response.text() || '';", + "pm.test('bedrock/global.anthropic.claude-opus-4-7: native fallbacks parsed and stripped', function () {", + " // Original report: object-form fallbacks failed to unmarshal into []string -> 'Invalid JSON'.", + " pm.expect(raw.toLowerCase().indexOf('invalid json'), 'Bifrost failed to parse the fallbacks field: ' + raw).to.equal(-1);", + " // Live 400 signature when the field reaches a target that does not accept it.", + " pm.expect(raw.indexOf('Extra inputs are not permitted'), 'fallbacks reached a target that rejects it: ' + raw).to.equal(-1);", + " // The provider does not support server-side fallback, so Bifrost must strip the", + " // native entries before egress (the beta header is withheld there too).", + " pm.expect(pm.response.code, 'request failed: ' + raw).to.be.below(400);", + "});" + ] + } + } + ] + }, + { + "name": "bedrock_mantle/anthropic.claude-opus-4-8 · native object fallbacks stripped (mantle is not supported)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock_mantle/anthropic.claude-opus-4-8\",\n \"max_tokens\": 64,\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }],\n \"fallbacks\": [{ \"model\": \"claude-opus-4-8\" }]\n}" + }, + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Infra noise (auth / rate limit / upstream 5xx) skips so it does not false-fail.", + "// 400 is deliberately NOT guarded here - a 400 IS the regression signature for this folder.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var raw = pm.response.text() || '';", + "pm.test('bedrock_mantle/anthropic.claude-opus-4-8: native fallbacks stripped (Bedrock feature set)', function () {", + " // Original report: object-form fallbacks failed to unmarshal into []string -> 'Invalid JSON'.", + " pm.expect(raw.toLowerCase().indexOf('invalid json'), 'Bifrost failed to parse the fallbacks field: ' + raw).to.equal(-1);", + " // Live 400 signature when the field reaches a target that does not accept it.", + " pm.expect(raw.indexOf('Extra inputs are not permitted'), 'fallbacks reached a target that rejects it: ' + raw).to.equal(-1);", + " // bedrock_mantle reaches Claude through the native Anthropic Messages surface, but", + " // server-side fallback follows Amazon Bedrock availability, i.e. unsupported.", + " pm.expect(pm.response.code, 'request failed: ' + raw).to.be.below(400);", + "});" + ] + } + } + ] + }, + { + "name": "anthropic/claude-opus-4-7 · Bifrost string fallbacks are not leaked to the provider", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"anthropic/claude-opus-4-7\",\n \"max_tokens\": 64,\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }],\n \"fallbacks\": [\"anthropic/claude-sonnet-4-6\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Infra noise (auth / rate limit / upstream 5xx) skips so it does not false-fail.", + "// 400 is deliberately NOT guarded here - a 400 IS the regression signature for this folder.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var raw = pm.response.text() || '';", + "pm.test('anthropic/claude-opus-4-7: Bifrost string fallbacks consumed by the gateway, not forwarded', function () {", + " // Original report: object-form fallbacks failed to unmarshal into []string -> 'Invalid JSON'.", + " pm.expect(raw.toLowerCase().indexOf('invalid json'), 'Bifrost failed to parse the fallbacks field: ' + raw).to.equal(-1);", + " // Live 400 signature when the field reaches a target that does not accept it.", + " pm.expect(raw.indexOf('Extra inputs are not permitted'), 'fallbacks reached a target that rejects it: ' + raw).to.equal(-1);", + " // String entries are Bifrost gateway failover targets; Anthropic never sees them.", + " pm.expect(pm.response.code, 'request failed: ' + raw).to.be.below(400);", + "});" + ] + } + } + ] + }, + { + "name": "anthropic/claude-fable-5 · native object fallbacks forwarded with server-side-fallback beta", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"anthropic/claude-fable-5\",\n \"max_tokens\": 64,\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }],\n \"fallbacks\": [{ \"model\": \"claude-opus-4-8\" }]\n}" + }, + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Infra noise (auth / rate limit / upstream 5xx) skips so it does not false-fail.", + "// 400 is deliberately NOT guarded here - a 400 IS the regression signature for this folder.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var raw = pm.response.text() || '';", + "pm.test('anthropic/claude-fable-5: native fallbacks accepted (beta header auto-injected)', function () {", + " // Original report: object-form fallbacks failed to unmarshal into []string -> 'Invalid JSON'.", + " pm.expect(raw.toLowerCase().indexOf('invalid json'), 'Bifrost failed to parse the fallbacks field: ' + raw).to.equal(-1);", + " // Live 400 signature when the field reaches a target that does not accept it.", + " pm.expect(raw.indexOf('Extra inputs are not permitted'), 'fallbacks reached a target that rejects it: ' + raw).to.equal(-1);", + " // Anthropic is the only provider with ServerSideFallback=true, so the field is", + " // forwarded and Bifrost auto-injects server-side-fallback-2026-06-01. A rejection", + " // of the field itself would mean the beta header was not injected.", + " pm.expect(pm.response.code, 'request failed: ' + raw).to.be.below(400);", + "});" + ] + } + } + ] + } + ] } ] } \ No newline at end of file