Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions core/providers/anthropic/requestbuilder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions core/providers/anthropic/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions core/providers/anthropic/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions core/providers/anthropic/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions plugins/governance/httptransportprehook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions plugins/governance/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -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))

Expand Down
16 changes: 15 additions & 1 deletion transports/bifrost-http/integrations/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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{})
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
schemas.AppendToContextList(bifrostCtx, schemas.BifrostContextKeyRoutingEnginesUsed, schemas.RoutingEngineModelCatalog)
}
Expand Down Expand Up @@ -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
}
Expand Down
90 changes: 90 additions & 0 deletions transports/bifrost-http/integrations/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading