diff --git a/core/providers/anthropic/requestbuilder_test.go b/core/providers/anthropic/requestbuilder_test.go index 3a12999c34..7963f6c384 100644 --- a/core/providers/anthropic/requestbuilder_test.go +++ b/core/providers/anthropic/requestbuilder_test.go @@ -125,6 +125,37 @@ func TestBuildAnthropicResponsesRequestBody_RawBodyPath(t *testing.T) { } }) + t.Run("azure_strips_claude_code_diagnostics", func(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + ctx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) + + request := &schemas.BifrostResponsesRequest{ + Provider: schemas.Azure, + Model: "claude-opus-4-7", + RawRequestBody: []byte(`{ + "model":"claude-opus-4-7", + "max_tokens":64000, + "messages":[{"role":"user","content":"hi"}], + "diagnostics":{"previous_message_id":null} + }`), + } + + result, err := BuildAnthropicResponsesRequestBody(ctx, request, AnthropicRequestBuildConfig{ + Provider: schemas.Azure, + Deployment: "my-azure-deployment", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if providerUtils.JSONFieldExists(result, "diagnostics") { + t.Fatalf("expected diagnostics to be stripped for Azure, got: %s", string(result)) + } + if providerUtils.GetJSONField(result, "model").String() != "my-azure-deployment" { + t.Fatalf("expected Azure deployment model rewrite, got: %s", string(result)) + } + }) + t.Run("adds_max_tokens_if_missing", func(t *testing.T) { ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) ctx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) diff --git a/core/providers/anthropic/types.go b/core/providers/anthropic/types.go index 10404671d9..7c196d2d51 100644 --- a/core/providers/anthropic/types.go +++ b/core/providers/anthropic/types.go @@ -135,6 +135,7 @@ type ProviderFeatureSupport struct { FileSearch bool // file_search server tool (OpenAI-only) 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 — undocumented Claude Code session-continuity field (diagnostics.previous_message_id); not in the public Messages API reference, so treated as Claude API only and stripped elsewhere (fail-closed). Azure rejects it; Bedrock/Vertex undocumented. } // ProviderFeatures maps each provider to its supported Anthropic features. @@ -153,6 +154,7 @@ var ProviderFeatures = map[schemas.ModelProvider]ProviderFeatureSupport{ FastMode: true, RedactThinking: true, TaskBudgets: true, InferenceGeo: true, EagerInputStreaming: true, AdvisorTool: true, ServiceTier: true, + Diagnostics: true, // Claude Code talks to the direct API and sends diagnostics.previous_message_id; only this provider keeps it. }, // Google Vertex AI — cite: A (overview table) and V-platform. // Notably NOT supported: MCP (MCP-excl), Skills/container.skills, diff --git a/core/providers/anthropic/utils.go b/core/providers/anthropic/utils.go index 6db61840ee..1f9c4e1b10 100644 --- a/core/providers/anthropic/utils.go +++ b/core/providers/anthropic/utils.go @@ -372,6 +372,15 @@ func StripUnsupportedFieldsFromRawBody(jsonBody []byte, provider schemas.ModelPr var err error + // diagnostics — undocumented Claude Code field; gated through the feature + // map like every other field. Only Anthropic direct keeps it (fail-closed). + if !features.Diagnostics && providerUtils.JSONFieldExists(jsonBody, "diagnostics") { + jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "diagnostics") + if err != nil { + return nil, fmt.Errorf("strip raw diagnostics: %w", err) + } + } + // speed — provider AND model gate if providerUtils.JSONFieldExists(jsonBody, "speed") { if !features.FastMode || !SupportsFastMode(model) { diff --git a/core/providers/anthropic/utils_test.go b/core/providers/anthropic/utils_test.go index 10982774de..340711d803 100644 --- a/core/providers/anthropic/utils_test.go +++ b/core/providers/anthropic/utils_test.go @@ -1479,6 +1479,31 @@ func TestNetworkConfigBetaOverridesFlow(t *testing.T) { } func TestStripUnsupportedFieldsFromRawBody(t *testing.T) { + t.Run("diagnostics_gated_via_feature_map", func(t *testing.T) { + // diagnostics is an undocumented Claude Code session-continuity field + // (diagnostics.previous_message_id). Only Anthropic direct keeps it; + // every other provider strips it fail-closed via Diagnostics=false. + const body = `{"model":"claude-opus-4-7","diagnostics":{"previous_message_id":null}}` + // Anthropic keeps it. + result, err := StripUnsupportedFieldsFromRawBody([]byte(body), schemas.Anthropic, "claude-opus-4-7") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !providerUtils.JSONFieldExists(result, "diagnostics") { + t.Errorf("expected diagnostics to be kept for Anthropic, got: %s", string(result)) + } + // Azure, Bedrock, Vertex strip it. + for _, provider := range []schemas.ModelProvider{schemas.Azure, schemas.Bedrock, schemas.Vertex} { + result, err := StripUnsupportedFieldsFromRawBody([]byte(body), provider, "claude-opus-4-7") + if err != nil { + t.Fatalf("unexpected error for %s: %v", provider, err) + } + if providerUtils.JSONFieldExists(result, "diagnostics") { + t.Errorf("expected diagnostics to be stripped for %s, got: %s", provider, string(result)) + } + } + }) + t.Run("bedrock_strips_new_request_level_fields", func(t *testing.T) { // Raw body with every new typed field. Targeting Bedrock: speed (no FastMode), // inference_geo (no InferenceGeo), mcp_servers (no MCP), container.skills diff --git a/plugins/governance/httptransportprehook_test.go b/plugins/governance/httptransportprehook_test.go index 2fae3a224c..3345efb298 100644 --- a/plugins/governance/httptransportprehook_test.go +++ b/plugins/governance/httptransportprehook_test.go @@ -65,6 +65,92 @@ func TestHTTPTransportPreHook_VirtualKeyReplicateRefinesNestedModel(t *testing.T require.Equal(t, "replicate/openai/gpt-5-nano", payload.Model) } +func TestHTTPTransportPreHook_ModelOnlyVirtualKeySetsAvailableProviders(t *testing.T) { + logger := NewMockLogger() + + openAIConfig := buildProviderConfig("openai", []string{"gpt-4o"}) + openAIConfig.Weight = nil + anthropicConfig := buildProviderConfig("anthropic", []string{"claude-3-5-sonnet"}) + anthropicConfig.Weight = nil + + virtualKey := buildVirtualKeyWithProviders( + "vk-constraint", + "sk-bf-constraint-test", + "provider-constraint-vk", + []configstoreTables.TableVirtualKeyProviderConfig{ + openAIConfig, + anthropicConfig, + }, + ) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey}, + }, nil) + require.NoError(t, err) + + plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, nil, nil, nil) + require.NoError(t, err) + defer func() { + require.NoError(t, plugin.Cleanup()) + }() + + req := schemas.AcquireHTTPRequest() + defer schemas.ReleaseHTTPRequest(req) + req.Method = "POST" + req.Path = "/v1/chat/completions" + req.Headers["Authorization"] = "Bearer sk-bf-constraint-test" + req.Headers["Content-Type"] = "application/json" + req.Body = []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"Hello!"}]}`) + + bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + resp, err := plugin.HTTPTransportPreHook(bfCtx, req) + require.NoError(t, err) + require.Nil(t, resp) + + allowedProviders, ok := bfCtx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + require.True(t, ok, "provider constraint should be set") + require.Equal(t, []schemas.ModelProvider{schemas.OpenAI}, allowedProviders) +} + +func TestHTTPTransportPreHook_ModelOnlyVirtualKeySetsEmptyAvailableProvidersWhenNoProviderAllowsModel(t *testing.T) { + logger := NewMockLogger() + + virtualKey := buildVirtualKeyWithProviders( + "vk-empty-constraint", + "sk-bf-empty-constraint-test", + "empty-provider-constraint-vk", + []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"gpt-4o"}), + }, + ) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey}, + }, nil) + require.NoError(t, err) + + plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, nil, nil, nil) + require.NoError(t, err) + defer func() { + require.NoError(t, plugin.Cleanup()) + }() + + req := schemas.AcquireHTTPRequest() + defer schemas.ReleaseHTTPRequest(req) + req.Method = "POST" + req.Path = "/v1/chat/completions" + req.Headers["Authorization"] = "Bearer sk-bf-empty-constraint-test" + req.Headers["Content-Type"] = "application/json" + req.Body = []byte(`{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"Hello!"}]}`) + + bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + resp, err := plugin.HTTPTransportPreHook(bfCtx, req) + require.NoError(t, err) + require.Nil(t, resp) + + allowedProviders, ok := bfCtx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + require.True(t, ok, "provider constraint should be set") + require.Empty(t, allowedProviders) +} + // TestHTTPTransportPreHook_GenAIRoutingRulePreservesTarget verifies that when a routing rule // matches on the /genai path, governance load balancing does not override the routing-rule target // with a provider from the VK pool (regression test for issue #2516). diff --git a/plugins/governance/main.go b/plugins/governance/main.go index 09e4ecb097..b31c1580b0 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -755,6 +755,7 @@ func (p *GovernancePlugin) loadBalanceProvider(ctx *schemas.BifrostContext, req // Get provider configs for this virtual key providerConfigs := virtualKey.ProviderConfigs if len(providerConfigs) == 0 { + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{}) ctx.AppendRoutingEngineLog(schemas.RoutingEngineGovernance, schemas.LogLevelWarn, fmt.Sprintf("No provider configs on virtual key %s for model %s, skipping load balancing", virtualKey.Name, modelStr)) // No provider configs, continue without modification return body, nil @@ -818,9 +819,12 @@ func (p *GovernancePlugin) loadBalanceProvider(ctx *schemas.BifrostContext, req } var allowedProviders []string + allowedModelProviders := make([]schemas.ModelProvider, 0, len(allowedProviderConfigs)) for _, pc := range allowedProviderConfigs { allowedProviders = append(allowedProviders, pc.Provider) + allowedModelProviders = append(allowedModelProviders, schemas.ModelProvider(pc.Provider)) } + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, allowedModelProviders) p.logger.Debug("[Governance] Allowed providers after filtering: %v", allowedProviders) ctx.AppendRoutingEngineLog(schemas.RoutingEngineGovernance, schemas.LogLevelInfo, fmt.Sprintf("Allowed providers after filtering: %v", allowedProviders)) diff --git a/transports/bifrost-http/integrations/router.go b/transports/bifrost-http/integrations/router.go index 60d6b52e68..f82349e7ec 100644 --- a/transports/bifrost-http/integrations/router.go +++ b/transports/bifrost-http/integrations/router.go @@ -792,6 +792,18 @@ func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandle skipModelCatalogProviderSelection, _ := bifrostCtx.Value(schemas.BifrostContextKeySkipModelCatalogProviderSelection).(bool) if extractedProvider == "" && !skipModelCatalogProviderSelection { availableProviders := g.handlerStore.GetProvidersForModel(extractedModel) + existingProviders, hasExistingProviders := bifrostCtx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + if hasExistingProviders { + if len(existingProviders) == 0 { + availableProviders = []schemas.ModelProvider{} + } else if len(availableProviders) == 0 { + availableProviders = existingProviders + } else { + availableProviders = slices.DeleteFunc(availableProviders, func(provider schemas.ModelProvider) bool { + return !slices.Contains(existingProviders, provider) + }) + } + } availableProvidersStrs := make([]string, len(availableProviders)) for i, p := range availableProviders { availableProvidersStrs[i] = string(p) @@ -814,6 +826,8 @@ func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandle )) } bifrostCtx.SetValue(schemas.BifrostContextKeyAvailableProviders, availableProviders) + } else if hasExistingProviders { + bifrostCtx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{}) } schemas.AppendToContextList(bifrostCtx, schemas.BifrostContextKeyRoutingEnginesUsed, schemas.RoutingEngineModelCatalog) } @@ -912,7 +926,7 @@ func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandle } // Extract and parse fallbacks from the request if present - if err := g.extractAndParseFallbacks(req, bifrostReq); err != nil { + if err := g.extractAndParseFallbacks(bifrostCtx, req, bifrostReq); err != nil { g.sendError(ctx, bifrostCtx, config.ErrorConverter, newBifrostError(err, "failed to parse fallbacks: "+err.Error())) return } diff --git a/transports/bifrost-http/integrations/router_test.go b/transports/bifrost-http/integrations/router_test.go index c96e3808d1..1bbfa99f59 100644 --- a/transports/bifrost-http/integrations/router_test.go +++ b/transports/bifrost-http/integrations/router_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/providers/anthropic" "github.com/maximhq/bifrost/core/providers/openai" "github.com/maximhq/bifrost/core/schemas" "github.com/stretchr/testify/assert" @@ -376,6 +377,95 @@ func TestOpenAIChatStructuredOutputRequestParserAndConverter(t *testing.T) { assert.Contains(t, responseFormat, "json_schema") } +func TestCreateHandler_AnthropicRouteConstrainsCatalogProvidersWhenAvailableProvidersSet(t *testing.T) { + handlerStore := &mockHandlerStore{ + availableProviders: []schemas.ModelProvider{ + schemas.Anthropic, + schemas.Azure, + schemas.Bedrock, + schemas.Vertex, + }, + } + + var capturedProviders []schemas.ModelProvider + route := RouteConfig{ + Type: RouteConfigTypeAnthropic, + Path: "/v1/messages", + Method: fasthttp.MethodPost, + GetHTTPRequestType: func(ctx *fasthttp.RequestCtx) schemas.RequestType { + return schemas.ResponsesRequest + }, + GetRequestTypeInstance: func(ctx context.Context) interface{} { + return &anthropic.AnthropicMessageRequest{} + }, + GetRequestModel: anthropicModelGetter, + PreCallback: checkAnthropicPassthrough, + RequestConverter: func(ctx *schemas.BifrostContext, req interface{}) (*schemas.BifrostRequest, error) { + capturedProviders, _ = ctx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + return nil, fmt.Errorf("stop before bifrost execution") + }, + ErrorConverter: func(ctx *schemas.BifrostContext, err *schemas.BifrostError) interface{} { + return err + }, + } + + router := NewGenericRouter(nil, handlerStore, nil, nil, nil) + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod(fasthttp.MethodPost) + ctx.SetUserValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{ + schemas.Azure, + schemas.OpenAI, + schemas.Ollama, + }) + ctx.Request.SetBodyString(`{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`) + + router.createHandler(route)(ctx) + + require.Equal(t, fasthttp.StatusInternalServerError, ctx.Response.StatusCode()) + require.Equal(t, []schemas.ModelProvider{schemas.Azure}, capturedProviders) +} + +func TestCreateHandler_AnthropicRouteKeepsCatalogProvidersWhenAvailableProvidersUnset(t *testing.T) { + handlerStore := &mockHandlerStore{ + availableProviders: []schemas.ModelProvider{ + schemas.Bedrock, + schemas.Vertex, + }, + } + + var capturedProviders []schemas.ModelProvider + route := RouteConfig{ + Type: RouteConfigTypeAnthropic, + Path: "/v1/messages", + Method: fasthttp.MethodPost, + GetHTTPRequestType: func(ctx *fasthttp.RequestCtx) schemas.RequestType { + return schemas.ResponsesRequest + }, + GetRequestTypeInstance: func(ctx context.Context) interface{} { + return &anthropic.AnthropicMessageRequest{} + }, + GetRequestModel: anthropicModelGetter, + PreCallback: checkAnthropicPassthrough, + RequestConverter: func(ctx *schemas.BifrostContext, req interface{}) (*schemas.BifrostRequest, error) { + capturedProviders, _ = ctx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + return nil, fmt.Errorf("stop before bifrost execution") + }, + ErrorConverter: func(ctx *schemas.BifrostContext, err *schemas.BifrostError) interface{} { + return err + }, + } + + router := NewGenericRouter(nil, handlerStore, nil, nil, nil) + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod(fasthttp.MethodPost) + ctx.Request.SetBodyString(`{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`) + + router.createHandler(route)(ctx) + + require.Equal(t, fasthttp.StatusInternalServerError, ctx.Response.StatusCode()) + require.Equal(t, []schemas.ModelProvider{schemas.Bedrock, schemas.Vertex}, capturedProviders) +} + func TestCreateHandler_CustomParserFailureClosesConnection(t *testing.T) { handlerStore := &mockHandlerStore{} converterCalled := false diff --git a/transports/bifrost-http/integrations/utils.go b/transports/bifrost-http/integrations/utils.go index f21360bb4b..29be18477c 100644 --- a/transports/bifrost-http/integrations/utils.go +++ b/transports/bifrost-http/integrations/utils.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "reflect" + "slices" "strconv" "strings" @@ -236,11 +237,11 @@ func (g *GenericRouter) sendError(ctx *fasthttp.RequestCtx, bifrostCtx *schemas. // Naming follows the existing `x-bf-*` request-side convention (see // `x-bf-vk`, `x-bf-key-id`, etc.). const ( - HeaderBifrostProvider = "x-bifrost-provider" - HeaderBifrostOriginalModel = "x-bifrost-original-model" - HeaderBifrostResolvedModel = "x-bifrost-resolved-model" - HeaderBifrostFallbackIndex = "x-bifrost-fallback-index" - HeaderBifrostRequestType = "x-bifrost-request-type" + HeaderBifrostProvider = "x-bifrost-provider" + HeaderBifrostOriginalModel = "x-bifrost-original-model" + HeaderBifrostResolvedModel = "x-bifrost-resolved-model" + HeaderBifrostFallbackIndex = "x-bifrost-fallback-index" + HeaderBifrostRequestType = "x-bifrost-request-type" ) // applyBifrostResponseHeaders writes both the upstream provider response @@ -335,8 +336,8 @@ func (g *GenericRouter) streamLargeResponse(ctx *fasthttp.RequestCtx, bifrostCtx return true } -// extractAndParseFallbacks extracts fallbacks from the integration request and adds them to the BifrostRequest -func (g *GenericRouter) extractAndParseFallbacks(req interface{}, bifrostReq *schemas.BifrostRequest) error { +// extractAndParseFallbacks extracts fallbacks from the integration request and adds them to the BifrostRequest. +func (g *GenericRouter) extractAndParseFallbacks(ctx *schemas.BifrostContext, req interface{}, bifrostReq *schemas.BifrostRequest) error { // Check if the request has a fallbacks field ([]string) fallbacks, err := g.extractFallbacksFromRequest(req) if err != nil { @@ -348,6 +349,11 @@ func (g *GenericRouter) extractAndParseFallbacks(req interface{}, bifrostReq *sc } provider, _, _ := bifrostReq.GetRequestFields() + var availableProviders []schemas.ModelProvider + var hasAvailableProviders bool + if ctx != nil { + availableProviders, hasAvailableProviders = ctx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + } // Parse fallbacks from strings to Fallback structs parsedFallbacks := make([]schemas.Fallback, 0, len(fallbacks)) @@ -358,6 +364,9 @@ func (g *GenericRouter) extractAndParseFallbacks(req interface{}, bifrostReq *sc // Use ParseModelString to extract provider and model provider, model := schemas.ParseModelString(fallbackStr, provider) + if hasAvailableProviders && !slices.Contains(availableProviders, provider) { + continue + } parsedFallback := schemas.Fallback{ Provider: provider, @@ -367,6 +376,7 @@ func (g *GenericRouter) extractAndParseFallbacks(req interface{}, bifrostReq *sc } if len(parsedFallbacks) == 0 { + bifrostReq.SetFallbacks(nil) return nil // No valid fallbacks found } diff --git a/transports/bifrost-http/integrations/utils_test.go b/transports/bifrost-http/integrations/utils_test.go index de0824b501..e1e1dd09d3 100644 --- a/transports/bifrost-http/integrations/utils_test.go +++ b/transports/bifrost-http/integrations/utils_test.go @@ -60,7 +60,7 @@ func TestExtractAndParseFallbacks_GeminiGenerationRequest(t *testing.T) { }, } - err := router.extractAndParseFallbacks(geminiReq, bifrostReq) + err := router.extractAndParseFallbacks(newTestBifrostContext(), geminiReq, bifrostReq) require.NoError(t, err) require.NotNil(t, bifrostReq.ResponsesRequest) @@ -69,6 +69,59 @@ func TestExtractAndParseFallbacks_GeminiGenerationRequest(t *testing.T) { assert.Equal(t, "gemini-3-flash-preview", bifrostReq.ResponsesRequest.Fallbacks[0].Model) } +func TestExtractAndParseFallbacks_FiltersByAvailableProviders(t *testing.T) { + router := newTestGenericRouter() + geminiReq := &gemini.GeminiGenerationRequest{ + Model: "gemini/gemini-3-flash-preview", + Fallbacks: []string{ + "azure/claude-opus-4-8", + "bedrock/claude-opus-4-8", + "vertex/claude-opus-4-8", + }, + } + bifrostReq := &schemas.BifrostRequest{ + ResponsesRequest: &schemas.BifrostResponsesRequest{ + Provider: schemas.Gemini, + Model: "gemini-3-flash-preview", + }, + } + ctx := newTestBifrostContext() + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{schemas.Azure}) + + err := router.extractAndParseFallbacks(ctx, geminiReq, bifrostReq) + + require.NoError(t, err) + require.NotNil(t, bifrostReq.ResponsesRequest) + require.Len(t, bifrostReq.ResponsesRequest.Fallbacks, 1) + assert.Equal(t, schemas.Azure, bifrostReq.ResponsesRequest.Fallbacks[0].Provider) + assert.Equal(t, "claude-opus-4-8", bifrostReq.ResponsesRequest.Fallbacks[0].Model) +} + +func TestExtractAndParseFallbacks_ClearsDisallowedPreparsedFallbacks(t *testing.T) { + router := newTestGenericRouter() + geminiReq := &gemini.GeminiGenerationRequest{ + Model: "gemini/gemini-3-flash-preview", + Fallbacks: []string{"bedrock/claude-opus-4-8"}, + } + bifrostReq := &schemas.BifrostRequest{ + ResponsesRequest: &schemas.BifrostResponsesRequest{ + Provider: schemas.Gemini, + Model: "gemini-3-flash-preview", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Bedrock, Model: "claude-opus-4-8"}, + }, + }, + } + ctx := newTestBifrostContext() + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{schemas.Azure}) + + err := router.extractAndParseFallbacks(ctx, geminiReq, bifrostReq) + + require.NoError(t, err) + require.NotNil(t, bifrostReq.ResponsesRequest) + require.Empty(t, bifrostReq.ResponsesRequest.Fallbacks) +} + // TestSendStreamError_PropagatesProviderStatusCode verifies that sendStreamError // sets the HTTP status code from the provider's BifrostError.StatusCode field. // All three providers (OpenAI, Anthropic, Bedrock) return actual HTTP error codes