From d7fc48a1e15463d83d91cd65e93e526383d81e1e Mon Sep 17 00:00:00 2001 From: Madhu Shantan Date: Sun, 24 May 2026 15:03:36 +0530 Subject: [PATCH] complexity router : add complexity analyzer config DB and API changes --- framework/configstore/clientconfig.go | 21 +-- framework/configstore/complexityconfig.go | 130 ++++++++++++++ framework/configstore/rdb.go | 71 ++++++-- framework/configstore/rdb_test.go | 120 +++++++++++++ framework/configstore/store.go | 3 + framework/configstore/tables/config.go | 6 +- plugins/governance/complexity/analyzer.go | 28 ++- .../governance/complexity/analyzer_test.go | 34 +++- plugins/governance/complexity/config.go | 112 +++++++++++- plugins/governance/complexity/matcher.go | 28 +-- plugins/governance/main.go | 55 +++++- .../bifrost-http/handlers/governance.go | 95 +++++++++++ .../bifrost-http/handlers/governance_test.go | 159 ++++++++++++++++++ transports/bifrost-http/lib/config.go | 30 ++++ transports/bifrost-http/lib/config_test.go | 56 ++++++ transports/bifrost-http/server/server.go | 17 ++ transports/config.schema.json | 83 +++++++++ 17 files changed, 999 insertions(+), 49 deletions(-) create mode 100644 framework/configstore/complexityconfig.go diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index 80874ee674..96637b5a43 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -1415,14 +1415,15 @@ type ConfigMap map[schemas.ModelProvider]ProviderConfig // GovernanceConfig contains governance entities loaded from the config store or // reconciled from config.json. type GovernanceConfig struct { - VirtualKeys []tables.TableVirtualKey `json:"virtual_keys"` - Teams []tables.TableTeam `json:"teams"` - Customers []tables.TableCustomer `json:"customers"` - Budgets []tables.TableBudget `json:"budgets"` - RateLimits []tables.TableRateLimit `json:"rate_limits"` - ModelConfigs []tables.TableModelConfig `json:"model_configs"` - Providers []tables.TableProvider `json:"providers"` - RoutingRules []tables.TableRoutingRule `json:"routing_rules"` - PricingOverrides []tables.TablePricingOverride `json:"pricing_overrides,omitempty"` - AuthConfig *AuthConfig `json:"auth_config,omitempty"` + VirtualKeys []tables.TableVirtualKey `json:"virtual_keys"` + Teams []tables.TableTeam `json:"teams"` + Customers []tables.TableCustomer `json:"customers"` + Budgets []tables.TableBudget `json:"budgets"` + RateLimits []tables.TableRateLimit `json:"rate_limits"` + ModelConfigs []tables.TableModelConfig `json:"model_configs"` + Providers []tables.TableProvider `json:"providers"` + RoutingRules []tables.TableRoutingRule `json:"routing_rules"` + PricingOverrides []tables.TablePricingOverride `json:"pricing_overrides,omitempty"` + AuthConfig *AuthConfig `json:"auth_config,omitempty"` + ComplexityAnalyzerConfig *ComplexityAnalyzerConfig `json:"complexity_analyzer_config,omitempty"` } diff --git a/framework/configstore/complexityconfig.go b/framework/configstore/complexityconfig.go new file mode 100644 index 0000000000..0bc0079630 --- /dev/null +++ b/framework/configstore/complexityconfig.go @@ -0,0 +1,130 @@ +package configstore + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// ComplexityTierBoundaries defines score thresholds for complexity tier classification. +type ComplexityTierBoundaries struct { + SimpleMedium float64 `json:"simple_medium"` + MediumComplex float64 `json:"medium_complex"` + ComplexReasoning float64 `json:"complex_reasoning"` +} + +// Validate checks that tier boundaries are ordered and inside the analyzer score range. +func (b *ComplexityTierBoundaries) Validate() error { + if b == nil { + return nil + } + if !(0 < b.SimpleMedium && + b.SimpleMedium < b.MediumComplex && + b.MediumComplex < b.ComplexReasoning && + b.ComplexReasoning < 1) { + return fmt.Errorf( + "tier boundaries must satisfy 0 < simple_medium (%.4f) < medium_complex (%.4f) < complex_reasoning (%.4f) < 1", + b.SimpleMedium, b.MediumComplex, b.ComplexReasoning, + ) + } + return nil +} + +// ComplexityEditableKeywordConfig contains the user-editable keyword lists. +type ComplexityEditableKeywordConfig struct { + CodeKeywords []string `json:"code_keywords"` + ReasoningKeywords []string `json:"reasoning_keywords"` + TechnicalKeywords []string `json:"technical_keywords"` + SimpleKeywords []string `json:"simple_keywords"` +} + +// ComplexityAnalyzerConfig is the persisted runtime configuration for the complexity analyzer. +type ComplexityAnalyzerConfig struct { + TierBoundaries ComplexityTierBoundaries `json:"tier_boundaries"` + Keywords ComplexityEditableKeywordConfig `json:"keywords"` +} + +// Validate checks that the config is internally consistent. +func (c *ComplexityAnalyzerConfig) Validate() error { + if c == nil { + return nil + } + if err := c.TierBoundaries.Validate(); err != nil { + return err + } + + var missing []string + if len(c.Keywords.CodeKeywords) == 0 { + missing = append(missing, "code_keywords") + } + if len(c.Keywords.ReasoningKeywords) == 0 { + missing = append(missing, "reasoning_keywords") + } + if len(c.Keywords.TechnicalKeywords) == 0 { + missing = append(missing, "technical_keywords") + } + if len(c.Keywords.SimpleKeywords) == 0 { + missing = append(missing, "simple_keywords") + } + if len(missing) > 0 { + return fmt.Errorf("keyword lists must be non-empty: %s", strings.Join(missing, ", ")) + } + return nil +} + +// Normalized returns a canonical copy suitable for persistence and runtime use. +func (c *ComplexityAnalyzerConfig) Normalized() ComplexityAnalyzerConfig { + if c == nil { + return ComplexityAnalyzerConfig{} + } + return ComplexityAnalyzerConfig{ + TierBoundaries: c.TierBoundaries, + Keywords: ComplexityEditableKeywordConfig{ + CodeKeywords: normalizeComplexityKeywordList(c.Keywords.CodeKeywords), + ReasoningKeywords: normalizeComplexityKeywordList(c.Keywords.ReasoningKeywords), + TechnicalKeywords: normalizeComplexityKeywordList(c.Keywords.TechnicalKeywords), + SimpleKeywords: normalizeComplexityKeywordList(c.Keywords.SimpleKeywords), + }, + } +} + +// DecodeComplexityAnalyzerConfig decodes raw JSON into a normalized, validated config. +func DecodeComplexityAnalyzerConfig(data []byte) (*ComplexityAnalyzerConfig, error) { + if len(data) == 0 { + return nil, nil + } + + var cfg ComplexityAnalyzerConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal complexity analyzer config: %w", err) + } + + normalized := cfg.Normalized() + if err := normalized.Validate(); err != nil { + return nil, fmt.Errorf("invalid complexity analyzer config: %w", err) + } + return &normalized, nil +} + +func normalizeComplexityKeywordList(values []string) []string { + if len(values) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + sort.Strings(out) + return out +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 31c780af17..4d6adb5a41 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -4810,6 +4810,7 @@ func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceCo return nil, nil } var authConfig *AuthConfig + var complexityAnalyzerConfig *ComplexityAnalyzerConfig if len(governanceConfigs) > 0 { // Checking if username and password is present var username *string @@ -4826,6 +4827,18 @@ func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceCo isEnabled = entry.Value == "true" case tables.ConfigDisableAuthOnInferenceKey: disableAuthOnInference = entry.Value == "true" + case tables.ConfigComplexityAnalyzerConfigKey: + if strings.TrimSpace(entry.Value) == "" { + continue + } + decoded, err := DecodeComplexityAnalyzerConfig([]byte(entry.Value)) + if err != nil { + if s.logger != nil { + s.logger.Warn("failed to load complexity analyzer config from governance_config: %v", err) + } + continue + } + complexityAnalyzerConfig = decoded } } if username != nil && password != nil { @@ -4838,19 +4851,57 @@ func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceCo } } return &GovernanceConfig{ - VirtualKeys: virtualKeys, - Teams: teams, - Customers: customers, - Budgets: budgets, - RateLimits: rateLimits, - ModelConfigs: modelConfigs, - Providers: providers, - RoutingRules: routingRules, - PricingOverrides: pricingOverrides, - AuthConfig: authConfig, + VirtualKeys: virtualKeys, + Teams: teams, + Customers: customers, + Budgets: budgets, + RateLimits: rateLimits, + ModelConfigs: modelConfigs, + Providers: providers, + RoutingRules: routingRules, + PricingOverrides: pricingOverrides, + AuthConfig: authConfig, + ComplexityAnalyzerConfig: complexityAnalyzerConfig, }, nil } +// GetComplexityAnalyzerConfig retrieves the typed complexity analyzer config. +func (s *RDBConfigStore) GetComplexityAnalyzerConfig(ctx context.Context) (*ComplexityAnalyzerConfig, error) { + configEntry, err := s.GetConfig(ctx, tables.ConfigComplexityAnalyzerConfigKey) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, nil + } + return nil, err + } + if configEntry == nil || strings.TrimSpace(configEntry.Value) == "" { + return nil, nil + } + return DecodeComplexityAnalyzerConfig([]byte(configEntry.Value)) +} + +// UpdateComplexityAnalyzerConfig normalizes, validates, and persists the typed analyzer config. +func (s *RDBConfigStore) UpdateComplexityAnalyzerConfig(ctx context.Context, config *ComplexityAnalyzerConfig, tx ...*gorm.DB) error { + if config == nil { + return fmt.Errorf("complexity analyzer config is nil") + } + + normalized := config.Normalized() + if err := normalized.Validate(); err != nil { + return err + } + + raw, err := json.Marshal(normalized) + if err != nil { + return fmt.Errorf("failed to marshal complexity analyzer config: %w", err) + } + + return s.UpdateConfig(ctx, &tables.TableGovernanceConfig{ + Key: tables.ConfigComplexityAnalyzerConfigKey, + Value: string(raw), + }, tx...) +} + // GetAuthConfig retrieves the auth configuration from the database. func (s *RDBConfigStore) GetAuthConfig(ctx context.Context) (*AuthConfig, error) { var username *string diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go index 7047f47166..97afd60068 100644 --- a/framework/configstore/rdb_test.go +++ b/framework/configstore/rdb_test.go @@ -29,6 +29,10 @@ func setupRDBTestStore(t *testing.T) *RDBConfigStore { &tables.TableKey{}, &tables.TableBudget{}, &tables.TableRateLimit{}, + &tables.TableModelConfig{}, + &tables.TableRoutingRule{}, + &tables.TableRoutingTarget{}, + &tables.TablePricingOverride{}, &tables.TableVirtualKey{}, &tables.TableVirtualKeyProviderConfig{}, &tables.TableVirtualKeyProviderConfigKey{}, @@ -36,6 +40,7 @@ func setupRDBTestStore(t *testing.T) *RDBConfigStore { &tables.TableCustomer{}, &tables.TableTeam{}, &tables.TableClientConfig{}, + &tables.TableGovernanceConfig{}, &tables.TablePlugin{}, &tables.TableMCPClient{}, &tables.TableVirtualKeyMCPConfig{}, @@ -65,6 +70,121 @@ func setupRDBTestStore(t *testing.T) *RDBConfigStore { return s } +func testComplexityAnalyzerConfig() *ComplexityAnalyzerConfig { + return &ComplexityAnalyzerConfig{ + TierBoundaries: ComplexityTierBoundaries{ + SimpleMedium: 0.10, + MediumComplex: 0.30, + ComplexReasoning: 0.70, + }, + Keywords: ComplexityEditableKeywordConfig{ + CodeKeywords: []string{" Function ", "api", "API"}, + ReasoningKeywords: []string{"tradeoffs"}, + TechnicalKeywords: []string{"latency"}, + SimpleKeywords: []string{"hello"}, + }, + } +} + +func TestRDBConfigStore_ComplexityAnalyzerConfigRoundTrip(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + require.NoError(t, store.UpdateComplexityAnalyzerConfig(ctx, testComplexityAnalyzerConfig())) + + got, err := store.GetComplexityAnalyzerConfig(ctx) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, ComplexityTierBoundaries{ + SimpleMedium: 0.10, + MediumComplex: 0.30, + ComplexReasoning: 0.70, + }, got.TierBoundaries) + assert.Equal(t, []string{"api", "function"}, got.Keywords.CodeKeywords) +} + +func TestRDBConfigStore_GetGovernanceConfigIncludesComplexityAnalyzerConfig(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + require.NoError(t, store.UpdateComplexityAnalyzerConfig(ctx, testComplexityAnalyzerConfig())) + + governanceConfig, err := store.GetGovernanceConfig(ctx) + require.NoError(t, err) + require.NotNil(t, governanceConfig) + require.NotNil(t, governanceConfig.ComplexityAnalyzerConfig) + assert.Equal(t, 0.70, governanceConfig.ComplexityAnalyzerConfig.TierBoundaries.ComplexReasoning) +} + +func TestRDBConfigStore_UpdateComplexityAnalyzerConfigRejectsInvalidConfig(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + tests := []struct { + name string + mutate func(*ComplexityAnalyzerConfig) + }{ + { + name: "simple medium below minimum", + mutate: func(cfg *ComplexityAnalyzerConfig) { + cfg.TierBoundaries.SimpleMedium = -0.1 + }, + }, + { + name: "medium complex at minimum", + mutate: func(cfg *ComplexityAnalyzerConfig) { + cfg.TierBoundaries.MediumComplex = 0 + }, + }, + { + name: "complex reasoning at maximum", + mutate: func(cfg *ComplexityAnalyzerConfig) { + cfg.TierBoundaries.ComplexReasoning = 1.0 + }, + }, + { + name: "boundaries out of order", + mutate: func(cfg *ComplexityAnalyzerConfig) { + cfg.TierBoundaries.ComplexReasoning = cfg.TierBoundaries.MediumComplex - 0.1 + }, + }, + { + name: "empty code keywords", + mutate: func(cfg *ComplexityAnalyzerConfig) { + cfg.Keywords.CodeKeywords = nil + }, + }, + { + name: "empty reasoning keywords", + mutate: func(cfg *ComplexityAnalyzerConfig) { + cfg.Keywords.ReasoningKeywords = nil + }, + }, + { + name: "empty technical keywords", + mutate: func(cfg *ComplexityAnalyzerConfig) { + cfg.Keywords.TechnicalKeywords = nil + }, + }, + { + name: "empty simple keywords", + mutate: func(cfg *ComplexityAnalyzerConfig) { + cfg.Keywords.SimpleKeywords = nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + invalid := testComplexityAnalyzerConfig() + tt.mutate(invalid) + + err := store.UpdateComplexityAnalyzerConfig(ctx, invalid) + require.Error(t, err) + }) + } +} + // ============================================================================= // Provider and Key Tests // ============================================================================= diff --git a/framework/configstore/store.go b/framework/configstore/store.go index 28b07c411e..6dca101811 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -177,6 +177,9 @@ type ConfigStore interface { // Config CRUD GetConfig(ctx context.Context, key string) (*tables.TableGovernanceConfig, error) UpdateConfig(ctx context.Context, config *tables.TableGovernanceConfig, tx ...*gorm.DB) error + GetComplexityAnalyzerConfig(ctx context.Context) (*ComplexityAnalyzerConfig, error) + // UpdateComplexityAnalyzerConfig persists the normalized analyzer config. + UpdateComplexityAnalyzerConfig(ctx context.Context, config *ComplexityAnalyzerConfig, tx ...*gorm.DB) error // Plugins CRUD GetPlugins(ctx context.Context) ([]*tables.TablePlugin, error) diff --git a/framework/configstore/tables/config.go b/framework/configstore/tables/config.go index bb4776d418..22594e9b03 100644 --- a/framework/configstore/tables/config.go +++ b/framework/configstore/tables/config.go @@ -8,8 +8,10 @@ const ( ConfigIsAuthEnabledKey = "is_auth_enabled" ConfigDisableAuthOnInferenceKey = "disable_auth_on_inference" ConfigProxyKey = "proxy_config" - ConfigRestartRequiredKey = "restart_required" - ConfigHeaderFilterKey = "header_filter_config" + // ConfigComplexityAnalyzerConfigKey stores the persisted analyzer config JSON. + ConfigComplexityAnalyzerConfigKey = "complexity_analyzer_config" + ConfigRestartRequiredKey = "restart_required" + ConfigHeaderFilterKey = "header_filter_config" ) // Keys for the ClientConfig.MetadataJSON blob. diff --git a/plugins/governance/complexity/analyzer.go b/plugins/governance/complexity/analyzer.go index bbae10db74..c10e5deb88 100644 --- a/plugins/governance/complexity/analyzer.go +++ b/plugins/governance/complexity/analyzer.go @@ -3,15 +3,29 @@ package complexity import "math" // ComplexityAnalyzer computes complexity scores from normalized text input. -// It is stateless and safe for concurrent use. +// It holds immutable tierBoundaries and matcher configuration after construction, +// so it is safe for concurrent use. type ComplexityAnalyzer struct { - matcher *compiledKeywordMatcher + tierBoundaries TierBoundaries + matcher *compiledKeywordMatcher } -// NewComplexityAnalyzer creates a stateless analyzer with built-in defaults. +// NewComplexityAnalyzer creates an analyzer with built-in defaults. func NewComplexityAnalyzer() *ComplexityAnalyzer { + return NewComplexityAnalyzerWithConfig(nil) +} + +// NewComplexityAnalyzerWithConfig creates an analyzer with runtime config. +func NewComplexityAnalyzerWithConfig(config *AnalyzerConfig) *ComplexityAnalyzer { + resolved, err := ValidateAndNormalize(config) + if err != nil || resolved == nil { + defaults := DefaultAnalyzerConfig() + resolved = &defaults + } + keywords := mergeEditableKeywordsOntoDefaults(resolved.Keywords) return &ComplexityAnalyzer{ - matcher: newCompiledKeywordMatcher(), + tierBoundaries: resolved.TierBoundaries, + matcher: newCompiledKeywordMatcher(keywords), } } @@ -177,11 +191,11 @@ func isReferentialFollowup(signals textSignalCounts, lastMsgScore, convScore flo func (a *ComplexityAnalyzer) classifyTier(score float64) string { switch { - case score < simpleMediumBoundary: + case score < a.tierBoundaries.SimpleMedium: return TierSimple - case score < mediumComplexBoundary: + case score < a.tierBoundaries.MediumComplex: return TierMedium - case score < complexReasoningBoundary: + case score < a.tierBoundaries.ComplexReasoning: return TierComplex default: return TierReasoning diff --git a/plugins/governance/complexity/analyzer_test.go b/plugins/governance/complexity/analyzer_test.go index ffee0a8fec..7fea167c90 100644 --- a/plugins/governance/complexity/analyzer_test.go +++ b/plugins/governance/complexity/analyzer_test.go @@ -17,6 +17,38 @@ func TestAnalyze_Simple(t *testing.T) { } } +func TestAnalyze_CustomTierBoundaries(t *testing.T) { + defaultAnalyzer := NewComplexityAnalyzer() + cfg := DefaultAnalyzerConfig() + cfg.TierBoundaries = TierBoundaries{ + SimpleMedium: 0.05, + MediumComplex: 0.10, + ComplexReasoning: 0.20, + } + customAnalyzer := NewComplexityAnalyzerWithConfig(&cfg) + + if got := defaultAnalyzer.classifyTier(0.18); got != TierMedium { + t.Fatalf("default boundary classified 0.18 as %s, want %s", got, TierMedium) + } + if got := customAnalyzer.classifyTier(0.18); got != TierComplex { + t.Fatalf("custom boundary classified 0.18 as %s, want %s", got, TierComplex) + } +} + +func TestAnalyze_CustomReasoningKeywordsAffectOverride(t *testing.T) { + cfg := DefaultAnalyzerConfig() + cfg.Keywords.ReasoningKeywords = []string{"deepmagic"} + a := NewComplexityAnalyzerWithConfig(&cfg) + + result := a.Analyze(ComplexityInput{ + LastUserText: "deepmagic api function", + }) + + if result.Tier != TierReasoning { + t.Fatalf("expected custom reasoning keyword to promote tier to %s, got %s (score=%.3f)", TierReasoning, result.Tier, result.Score) + } +} + func TestAnalyze_Hello(t *testing.T) { a := NewComplexityAnalyzer() @@ -489,7 +521,7 @@ func TestIsReferentialFollowup_GuardBranches(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - matcher := newCompiledKeywordMatcher() + matcher := newCompiledKeywordMatcher(defaultFullKeywordConfig()) signals := matcher.analyzeText(tt.lastText, lastTextFullScanMask) got := isReferentialFollowup(signals, tt.lastMsgScore, tt.convScore, tt.wordCount) if got != tt.expected { diff --git a/plugins/governance/complexity/config.go b/plugins/governance/complexity/config.go index 8bf0fdcdc1..57bda9eb77 100644 --- a/plugins/governance/complexity/config.go +++ b/plugins/governance/complexity/config.go @@ -1,11 +1,13 @@ // Package complexity provides request-complexity scoring for governance routing. package complexity +import "github.com/maximhq/bifrost/framework/configstore" + // ComplexityInput is the normalized input for the analyzer. // The caller is responsible for extracting text from request payloads. type ComplexityInput struct { LastUserText string // last user message text - PriorUserTexts []string // previous user message texts + PriorUserTexts []string // previous user message texts (up to 10) SystemText string // concatenated system/developer prompt text } @@ -28,3 +30,111 @@ const ( mediumComplexBoundary = 0.35 complexReasoningBoundary = 0.60 ) + +// TierBoundaries defines the score thresholds for tier classification. +type TierBoundaries = configstore.ComplexityTierBoundaries + +// EditableKeywordConfig is the user-facing subset of analyzer keyword lists. +type EditableKeywordConfig = configstore.ComplexityEditableKeywordConfig + +// AnalyzerConfig is the runtime configuration for the complexity analyzer. +type AnalyzerConfig = configstore.ComplexityAnalyzerConfig + +// KeywordConfig is the full internal keyword set used by the compiled matcher. +type KeywordConfig struct { + CodeKeywords []string + StrongReasoningKeywords []string + WeakReasoningKeywords []string + TechnicalKeywords []string + SimpleKeywords []string + EnumTriggers []string + ComprehensivenessMarkers []string + ElaborationMarkers []string + LimitingQualifiers []string + ReferentialPhrases []string + ReferentialReferenceWords []string + ReferentialActionWords []string + TaskShiftPhrases []string +} + +// DefaultTierBoundaries returns the built-in classification thresholds. +func DefaultTierBoundaries() TierBoundaries { + return TierBoundaries{ + SimpleMedium: simpleMediumBoundary, + MediumComplex: mediumComplexBoundary, + ComplexReasoning: complexReasoningBoundary, + } +} + +// DefaultEditableKeywordConfig returns the user-visible default keyword lists. +func DefaultEditableKeywordConfig() EditableKeywordConfig { + return EditableKeywordConfig{ + CodeKeywords: cloneStringSlice(codeKeywords), + ReasoningKeywords: cloneStringSlice(strongReasoningKeywords), + TechnicalKeywords: cloneStringSlice(technicalKeywords), + SimpleKeywords: cloneStringSlice(simpleKeywords), + } +} + +// DefaultAnalyzerConfig returns the built-in analyzer config. +func DefaultAnalyzerConfig() AnalyzerConfig { + return AnalyzerConfig{ + TierBoundaries: DefaultTierBoundaries(), + Keywords: DefaultEditableKeywordConfig(), + } +} + +// ValidateAndNormalize normalizes and validates analyzer config. +func ValidateAndNormalize(cfg *AnalyzerConfig) (*AnalyzerConfig, error) { + if cfg == nil { + defaults := DefaultAnalyzerConfig() + return &defaults, nil + } + normalized := cfg.Normalized() + if err := normalized.Validate(); err != nil { + return nil, err + } + return &normalized, nil +} + +func mergeEditableKeywordsOntoDefaults(editable EditableKeywordConfig) KeywordConfig { + keywords := defaultFullKeywordConfig() + if len(editable.CodeKeywords) > 0 { + keywords.CodeKeywords = cloneStringSlice(editable.CodeKeywords) + } + if len(editable.ReasoningKeywords) > 0 { + keywords.StrongReasoningKeywords = cloneStringSlice(editable.ReasoningKeywords) + } + if len(editable.TechnicalKeywords) > 0 { + keywords.TechnicalKeywords = cloneStringSlice(editable.TechnicalKeywords) + } + if len(editable.SimpleKeywords) > 0 { + keywords.SimpleKeywords = cloneStringSlice(editable.SimpleKeywords) + } + return keywords +} + +func defaultFullKeywordConfig() KeywordConfig { + return KeywordConfig{ + CodeKeywords: cloneStringSlice(codeKeywords), + StrongReasoningKeywords: cloneStringSlice(strongReasoningKeywords), + WeakReasoningKeywords: cloneStringSlice(weakReasoningKeywords), + TechnicalKeywords: cloneStringSlice(technicalKeywords), + SimpleKeywords: cloneStringSlice(simpleKeywords), + EnumTriggers: cloneStringSlice(enumTriggers), + ComprehensivenessMarkers: cloneStringSlice(comprehensivenessMarkers), + ElaborationMarkers: cloneStringSlice(elaborationMarkers), + LimitingQualifiers: cloneStringSlice(limitingQualifiers), + ReferentialPhrases: cloneStringSlice(referentialPhrases), + ReferentialReferenceWords: cloneStringSlice(referentialReferenceWords), + ReferentialActionWords: cloneStringSlice(referentialActionWords), + TaskShiftPhrases: cloneStringSlice(taskShiftPhrases), + } +} + +func cloneStringSlice(values []string) []string { + if len(values) == 0 { + return nil + } + return append([]string(nil), values...) +} diff --git a/plugins/governance/complexity/matcher.go b/plugins/governance/complexity/matcher.go index 449ac7b6bb..325721e3e1 100644 --- a/plugins/governance/complexity/matcher.go +++ b/plugins/governance/complexity/matcher.go @@ -66,7 +66,7 @@ type textSignalCounts struct { taskShiftCount int } -func newCompiledKeywordMatcher() *compiledKeywordMatcher { +func newCompiledKeywordMatcher(keywords KeywordConfig) *compiledKeywordMatcher { entries := make(map[string]compiledKeyword) addKeywords := func(keywords []string, mask compiledKeywordMask) { for _, kw := range keywords { @@ -88,19 +88,19 @@ func newCompiledKeywordMatcher() *compiledKeywordMatcher { } } - addKeywords(codeKeywords, maskCode) - addKeywords(strongReasoningKeywords, maskReasoning|maskStrongReasoning) - addKeywords(weakReasoningKeywords, maskReasoning) - addKeywords(technicalKeywords, maskTechnical) - addKeywords(simpleKeywords, maskSimple) - addKeywords(enumTriggers, maskEnum) - addKeywords(comprehensivenessMarkers, maskComprehensive) - addKeywords(elaborationMarkers, maskElaboration) - addKeywords(limitingQualifiers, maskLimiter) - addKeywords(referentialPhrases, maskReferentialPhrase) - addKeywords(referentialReferenceWords, maskReferentialReference) - addKeywords(referentialActionWords, maskReferentialAction) - addKeywords(taskShiftPhrases, maskTaskShift) + addKeywords(keywords.CodeKeywords, maskCode) + addKeywords(keywords.StrongReasoningKeywords, maskReasoning|maskStrongReasoning) + addKeywords(keywords.WeakReasoningKeywords, maskReasoning) + addKeywords(keywords.TechnicalKeywords, maskTechnical) + addKeywords(keywords.SimpleKeywords, maskSimple) + addKeywords(keywords.EnumTriggers, maskEnum) + addKeywords(keywords.ComprehensivenessMarkers, maskComprehensive) + addKeywords(keywords.ElaborationMarkers, maskElaboration) + addKeywords(keywords.LimitingQualifiers, maskLimiter) + addKeywords(keywords.ReferentialPhrases, maskReferentialPhrase) + addKeywords(keywords.ReferentialReferenceWords, maskReferentialReference) + addKeywords(keywords.ReferentialActionWords, maskReferentialAction) + addKeywords(keywords.TaskShiftPhrases, maskTaskShift) matcher := &compiledKeywordMatcher{} for _, entry := range entries { diff --git a/plugins/governance/main.go b/plugins/governance/main.go index 191ae4bf9a..fd76c0ab9b 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -9,6 +9,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -86,7 +87,7 @@ type GovernancePlugin struct { isEnterprise bool disableAutoToolInject *bool - complexityAnalyzer *complexity.ComplexityAnalyzer + complexityAnalyzer atomic.Pointer[complexity.ComplexityAnalyzer] } // Init initializes and returns a governance plugin instance. @@ -235,7 +236,7 @@ func Init( disableAutoToolInject: disableAutoToolInject, inMemoryStore: inMemoryStore, } - plugin.complexityAnalyzer = complexity.NewComplexityAnalyzer() + plugin.storeComplexityAnalyzerConfig(resolveAnalyzerConfigFromStoreOrArg(ctx, logger, configStore, governanceConfig)) return plugin, nil } @@ -330,7 +331,7 @@ func InitFromStore( isEnterprise: config != nil && config.IsEnterprise, disableAutoToolInject: disableAutoToolInject, } - plugin.complexityAnalyzer = complexity.NewComplexityAnalyzer() + plugin.storeComplexityAnalyzerConfig(resolveAnalyzerConfigFromStoreOrArg(ctx, logger, configStore, nil)) return plugin, nil } @@ -339,6 +340,52 @@ func (p *GovernancePlugin) GetName() string { return PluginName } +// ReloadComplexityAnalyzerConfig swaps the analyzer used by complexity_tier routing. +func (p *GovernancePlugin) ReloadComplexityAnalyzerConfig(config *complexity.AnalyzerConfig) { + p.storeComplexityAnalyzerConfig(config) +} + +func (p *GovernancePlugin) storeComplexityAnalyzerConfig(config *complexity.AnalyzerConfig) { + resolved, err := complexity.ValidateAndNormalize(config) + if err != nil { + if p.logger != nil { + p.logger.Warn("invalid complexity analyzer config, using defaults: %v", err) + } + defaults := complexity.DefaultAnalyzerConfig() + resolved = &defaults + } + p.complexityAnalyzer.Store(complexity.NewComplexityAnalyzerWithConfig(resolved)) +} + +func resolveAnalyzerConfigFromStoreOrArg( + ctx context.Context, + logger schemas.Logger, + configStore configstore.ConfigStore, + governanceConfig *configstore.GovernanceConfig, +) *complexity.AnalyzerConfig { + if governanceConfig != nil && governanceConfig.ComplexityAnalyzerConfig != nil { + cfg, err := complexity.ValidateAndNormalize(governanceConfig.ComplexityAnalyzerConfig) + if err != nil { + if logger != nil { + logger.Warn("invalid complexity analyzer config from governance config: %v", err) + } + } else if cfg != nil { + return cfg + } + } + if configStore != nil { + cfg, err := configStore.GetComplexityAnalyzerConfig(ctx) + if err != nil { + if logger != nil { + logger.Warn("failed to load complexity analyzer config from store: %v", err) + } + } else if cfg != nil { + return cfg + } + } + return nil +} + // UpdateEnforceAuthOnInference updates the enforce auth on inference config func (p *GovernancePlugin) UpdateEnforceAuthOnInference(enforceAuthOnInference bool) { p.cfgMutex.Lock() @@ -642,7 +689,7 @@ func (p *GovernancePlugin) applyRoutingRules(ctx *schemas.BifrostContext, req *s // Set up lazy complexity computation; only runs if a rule references complexity_tier. var computeComplexity func() *complexity.ComplexityResult - if analyzer := p.complexityAnalyzer; analyzer != nil { + if analyzer := p.complexityAnalyzer.Load(); analyzer != nil { computeComplexity = func() *complexity.ComplexityResult { input, ok := buildComplexityInput(req) if !ok { diff --git a/transports/bifrost-http/handlers/governance.go b/transports/bifrost-http/handlers/governance.go index 52dd9a98b0..54d5f3f222 100644 --- a/transports/bifrost-http/handlers/governance.go +++ b/transports/bifrost-http/handlers/governance.go @@ -3,10 +3,12 @@ package handlers import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "math" "sort" "strconv" @@ -23,6 +25,7 @@ import ( configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" "github.com/maximhq/bifrost/framework/modelcatalog" "github.com/maximhq/bifrost/plugins/governance" + "github.com/maximhq/bifrost/plugins/governance/complexity" "github.com/maximhq/bifrost/transports/bifrost-http/lib" "github.com/valyala/fasthttp" "gorm.io/gorm" @@ -56,6 +59,12 @@ type GovernanceManager interface { DeletePricingOverride(ctx context.Context, id string) error } +type complexityAnalyzerConfigReloader interface { + // HTTP server bridge signature: BifrostHTTPServer implements this and adapts + // to the governance plugin's in-memory ReloadComplexityAnalyzerConfig(config). + ReloadComplexityAnalyzerConfig(ctx context.Context, config *complexity.AnalyzerConfig) error +} + // GovernanceHandler manages HTTP requests for governance operations // ScopeNameResolver returns the human-readable name for a non-global model // config scope target (e.g. a virtual key's Name given its ID). The second @@ -951,6 +960,10 @@ type UpdateProviderGovernanceRequest struct { // RegisterRoutes registers all governance-related routes for the new hierarchical system func (h *GovernanceHandler) RegisterRoutes(r *router.Router, middlewares ...schemas.BifrostHTTPMiddleware) { + r.GET("/api/governance/complexity-analyzer-config", lib.ChainMiddlewares(h.getComplexityAnalyzerConfig, middlewares...)) + r.PUT("/api/governance/complexity-analyzer-config", lib.ChainMiddlewares(h.updateComplexityAnalyzerConfig, middlewares...)) + r.POST("/api/governance/complexity-analyzer-config/reset", lib.ChainMiddlewares(h.resetComplexityAnalyzerConfig, middlewares...)) + // Virtual Key CRUD operations r.GET("/api/governance/virtual-keys", lib.ChainMiddlewares(h.getVirtualKeys, middlewares...)) r.POST("/api/governance/virtual-keys", lib.ChainMiddlewares(h.createVirtualKey, middlewares...)) @@ -1008,6 +1021,88 @@ func (h *GovernanceHandler) RegisterRoutes(r *router.Router, middlewares ...sche r.GET("/api/governance/virtual-keys/quota", h.getVirtualKeyQuota) } +func (h *GovernanceHandler) getComplexityAnalyzerConfig(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + SendError(ctx, fasthttp.StatusServiceUnavailable, "config store not available") + return + } + + cfg, err := h.configStore.GetComplexityAnalyzerConfig(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to get complexity analyzer config: %v", err)) + return + } + if cfg == nil { + defaults := complexity.DefaultAnalyzerConfig() + SendJSON(ctx, defaults) + return + } + SendJSON(ctx, cfg) +} + +func (h *GovernanceHandler) updateComplexityAnalyzerConfig(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + SendError(ctx, fasthttp.StatusServiceUnavailable, "config store not available") + return + } + + var payload complexity.AnalyzerConfig + decoder := json.NewDecoder(bytes.NewReader(ctx.PostBody())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&payload); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("invalid request format: %v", err)) + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + SendError(ctx, fasthttp.StatusBadRequest, "invalid request format: multiple JSON values") + return + } + + normalized, err := complexity.ValidateAndNormalize(&payload) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + return + } + + if err := h.configStore.UpdateComplexityAnalyzerConfig(ctx, normalized); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to update complexity analyzer config: %v", err)) + return + } + if err := h.reloadComplexityAnalyzerConfig(ctx, normalized); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to reload complexity analyzer config in memory: %v, please restart bifrost to sync with the database", err)) + return + } + + SendJSON(ctx, normalized) +} + +func (h *GovernanceHandler) resetComplexityAnalyzerConfig(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + SendError(ctx, fasthttp.StatusServiceUnavailable, "config store not available") + return + } + + defaults := complexity.DefaultAnalyzerConfig() + if err := h.configStore.UpdateComplexityAnalyzerConfig(ctx, &defaults); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to reset complexity analyzer config: %v", err)) + return + } + if err := h.reloadComplexityAnalyzerConfig(ctx, &defaults); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to reload complexity analyzer config in memory: %v, please restart bifrost to sync with the database", err)) + return + } + + SendJSON(ctx, defaults) +} + +func (h *GovernanceHandler) reloadComplexityAnalyzerConfig(ctx context.Context, config *complexity.AnalyzerConfig) error { + reloader, ok := h.governanceManager.(complexityAnalyzerConfigReloader) + if !ok { + return fmt.Errorf("governance manager does not support complexity analyzer config reload") + } + return reloader.ReloadComplexityAnalyzerConfig(ctx, config) +} + // Virtual Key CRUD Operations // getVirtualKeys handles GET /api/governance/virtual-keys - Get all virtual keys with relationships diff --git a/transports/bifrost-http/handlers/governance_test.go b/transports/bifrost-http/handlers/governance_test.go index da6db83dac..44f082d94f 100644 --- a/transports/bifrost-http/handlers/governance_test.go +++ b/transports/bifrost-http/handlers/governance_test.go @@ -15,6 +15,7 @@ import ( "github.com/maximhq/bifrost/framework/configstore" configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" "github.com/maximhq/bifrost/plugins/governance" + "github.com/maximhq/bifrost/plugins/governance/complexity" "github.com/valyala/fasthttp" "gorm.io/gorm" ) @@ -124,6 +125,164 @@ func (m *mockRotateGovernanceManager) ReloadVirtualKey(ctx context.Context, id s return m.store.GetVirtualKey(ctx, id) } +type mockComplexityGovernanceManager struct { + GovernanceManager + reloadedConfig *complexity.AnalyzerConfig + reloadCalls int + reloadErr error +} + +func (m *mockComplexityGovernanceManager) ReloadComplexityAnalyzerConfig(_ context.Context, config *complexity.AnalyzerConfig) error { + m.reloadCalls++ + m.reloadedConfig = config + return m.reloadErr +} + +func testComplexityAnalyzerPayload(t *testing.T, cfg complexity.AnalyzerConfig) string { + t.Helper() + body, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal complexity analyzer config: %v", err) + } + return string(body) +} + +func TestComplexityAnalyzerConfigGetReturnsDefaultsWhenUnset(t *testing.T) { + SetLogger(&mockLogger{}) + store := setupPricingOverrideHandlerStore(t) + handler := &GovernanceHandler{ + configStore: store, + governanceManager: &mockComplexityGovernanceManager{}, + } + + ctx := newTestRequestCtx("") + handler.getComplexityAnalyzerConfig(ctx) + + if ctx.Response.StatusCode() != fasthttp.StatusOK { + t.Fatalf("expected status 200, got %d: %s", ctx.Response.StatusCode(), string(ctx.Response.Body())) + } + var resp complexity.AnalyzerConfig + if err := json.Unmarshal(ctx.Response.Body(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp.TierBoundaries != complexity.DefaultTierBoundaries() { + t.Fatalf("expected default boundaries, got %+v", resp.TierBoundaries) + } + if len(resp.Keywords.CodeKeywords) == 0 { + t.Fatalf("expected default code keywords") + } +} + +func TestComplexityAnalyzerConfigPutPersistsAndReloads(t *testing.T) { + SetLogger(&mockLogger{}) + store := setupPricingOverrideHandlerStore(t) + manager := &mockComplexityGovernanceManager{} + handler := &GovernanceHandler{ + configStore: store, + governanceManager: manager, + } + + cfg := complexity.DefaultAnalyzerConfig() + cfg.TierBoundaries.SimpleMedium = 0.12 + cfg.TierBoundaries.MediumComplex = 0.34 + cfg.TierBoundaries.ComplexReasoning = 0.78 + cfg.Keywords.CodeKeywords = []string{" Function ", "api", "API"} + + ctx := newTestRequestCtx(testComplexityAnalyzerPayload(t, cfg)) + handler.updateComplexityAnalyzerConfig(ctx) + + if ctx.Response.StatusCode() != fasthttp.StatusOK { + t.Fatalf("expected status 200, got %d: %s", ctx.Response.StatusCode(), string(ctx.Response.Body())) + } + if manager.reloadCalls != 1 { + t.Fatalf("expected one reload, got %d", manager.reloadCalls) + } + if manager.reloadedConfig == nil || manager.reloadedConfig.TierBoundaries.ComplexReasoning != 0.78 { + t.Fatalf("expected reload with normalized config, got %+v", manager.reloadedConfig) + } + + stored, err := store.GetComplexityAnalyzerConfig(context.Background()) + if err != nil { + t.Fatalf("get stored config: %v", err) + } + if stored == nil || len(stored.Keywords.CodeKeywords) != 2 || stored.Keywords.CodeKeywords[0] != "api" { + t.Fatalf("expected normalized stored keywords, got %+v", stored) + } +} + +func TestComplexityAnalyzerConfigPutRejectsInvalidPayloads(t *testing.T) { + SetLogger(&mockLogger{}) + store := setupPricingOverrideHandlerStore(t) + handler := &GovernanceHandler{ + configStore: store, + governanceManager: &mockComplexityGovernanceManager{}, + } + + valid := complexity.DefaultAnalyzerConfig() + validBody := testComplexityAnalyzerPayload(t, valid) + invalidBoundaries := valid + invalidBoundaries.TierBoundaries.MediumComplex = invalidBoundaries.TierBoundaries.SimpleMedium + emptyKeywords := valid + emptyKeywords.Keywords.CodeKeywords = nil + + tests := []struct { + name string + body string + want string + }{ + {name: "unknown field", body: strings.TrimSuffix(validBody, "}") + `,"extra":true}`, want: "unknown field"}, + {name: "multiple json values", body: validBody + `{}`, want: "multiple JSON values"}, + {name: "invalid boundaries", body: testComplexityAnalyzerPayload(t, invalidBoundaries), want: "tier boundaries"}, + {name: "empty keywords", body: testComplexityAnalyzerPayload(t, emptyKeywords), want: "keyword lists must be non-empty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := newTestRequestCtx(tt.body) + handler.updateComplexityAnalyzerConfig(ctx) + if ctx.Response.StatusCode() != fasthttp.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", ctx.Response.StatusCode(), string(ctx.Response.Body())) + } + if !strings.Contains(string(ctx.Response.Body()), tt.want) { + t.Fatalf("expected response to contain %q, got %s", tt.want, string(ctx.Response.Body())) + } + }) + } +} + +func TestComplexityAnalyzerConfigResetPersistsDefaultsAndReloads(t *testing.T) { + SetLogger(&mockLogger{}) + store := setupPricingOverrideHandlerStore(t) + manager := &mockComplexityGovernanceManager{} + handler := &GovernanceHandler{ + configStore: store, + governanceManager: manager, + } + + custom := complexity.DefaultAnalyzerConfig() + custom.TierBoundaries.ComplexReasoning = 0.80 + if err := store.UpdateComplexityAnalyzerConfig(context.Background(), &custom); err != nil { + t.Fatalf("seed custom config: %v", err) + } + + ctx := newTestRequestCtx("") + handler.resetComplexityAnalyzerConfig(ctx) + + if ctx.Response.StatusCode() != fasthttp.StatusOK { + t.Fatalf("expected status 200, got %d: %s", ctx.Response.StatusCode(), string(ctx.Response.Body())) + } + if manager.reloadCalls != 1 { + t.Fatalf("expected one reload, got %d", manager.reloadCalls) + } + stored, err := store.GetComplexityAnalyzerConfig(context.Background()) + if err != nil { + t.Fatalf("get stored config: %v", err) + } + if stored == nil || stored.TierBoundaries != complexity.DefaultTierBoundaries() { + t.Fatalf("expected stored defaults, got %+v", stored) + } +} + func TestApplyVirtualKeyOwnershipUpdatePreservesOmittedAssociation(t *testing.T) { teamID := "team-1" customerID := "customer-1" diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index ba141725a1..87d988fc37 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -41,6 +41,7 @@ import ( "github.com/maximhq/bifrost/framework/vectorstore" "github.com/maximhq/bifrost/plugins/compat" "github.com/maximhq/bifrost/plugins/governance" + "github.com/maximhq/bifrost/plugins/governance/complexity" "github.com/maximhq/bifrost/plugins/logging" "github.com/maximhq/bifrost/plugins/maxim" "github.com/maximhq/bifrost/plugins/otel" @@ -2363,6 +2364,23 @@ func mergeGovernanceConfig(ctx context.Context, config *Config, configData *Conf logger.Fatal("failed to sync governance config: %v", err) } } + + // File config stays authoritative for analyzer tuning when present. + if configData.Governance.ComplexityAnalyzerConfig != nil { + normalized, err := complexity.ValidateAndNormalize(configData.Governance.ComplexityAnalyzerConfig) + if err != nil { + logger.Error("invalid complexity analyzer config in config file: %v", err) + } else if normalized != nil { + current := config.GovernanceConfig.ComplexityAnalyzerConfig + config.GovernanceConfig.ComplexityAnalyzerConfig = normalized + if config.ConfigStore != nil && (current == nil || !reflect.DeepEqual(current, normalized)) { + if err := config.ConfigStore.UpdateComplexityAnalyzerConfig(ctx, normalized); err != nil { + logger.Warn("failed to sync complexity analyzer config from config file: %v", err) + } + } + } + } + // Sync pricing overrides into the model catalog in one batch to avoid // rebuilding the lookup map on every iteration. if config.ModelCatalog != nil { @@ -3407,6 +3425,18 @@ func createGovernanceConfigInStore(ctx context.Context, config *Config) { } } + if config.GovernanceConfig.ComplexityAnalyzerConfig != nil { + normalized, err := complexity.ValidateAndNormalize(config.GovernanceConfig.ComplexityAnalyzerConfig) + if err != nil { + logger.Warn("invalid complexity analyzer config in config file: %v", err) + } else if normalized != nil { + config.GovernanceConfig.ComplexityAnalyzerConfig = normalized + if err := config.ConfigStore.UpdateComplexityAnalyzerConfig(ctx, normalized, tx); err != nil { + return fmt.Errorf("failed to create complexity analyzer config: %w", err) + } + } + } + return nil }); err != nil { logger.Warn("failed to update governance config: %v", err) diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index 77d0692392..9c102fe533 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -943,6 +943,21 @@ func (m *MockConfigStore) UpdateConfig(ctx context.Context, config *tables.Table return nil } +func (m *MockConfigStore) GetComplexityAnalyzerConfig(ctx context.Context) (*configstore.ComplexityAnalyzerConfig, error) { + if m.governanceConfig == nil { + return nil, nil + } + return m.governanceConfig.ComplexityAnalyzerConfig, nil +} + +func (m *MockConfigStore) UpdateComplexityAnalyzerConfig(ctx context.Context, config *configstore.ComplexityAnalyzerConfig, tx ...*gorm.DB) error { + if m.governanceConfig == nil { + m.governanceConfig = &configstore.GovernanceConfig{} + } + m.governanceConfig.ComplexityAnalyzerConfig = config + return nil +} + // Plugins func (m *MockConfigStore) GetPlugins(ctx context.Context) ([]*tables.TablePlugin, error) { return m.plugins, nil @@ -1406,6 +1421,44 @@ func (m *MockConfigStore) DeleteRoutingRule(ctx context.Context, id string, tx . return nil } +func TestMergeGovernanceConfig_SyncsComplexityAnalyzerConfig(t *testing.T) { + initTestLogger() + + store := NewMockConfigStore() + dbGovernance := &configstore.GovernanceConfig{} + config := &Config{ + ConfigStore: store, + GovernanceConfig: dbGovernance, + } + fileConfig := &configstore.ComplexityAnalyzerConfig{ + TierBoundaries: configstore.ComplexityTierBoundaries{ + SimpleMedium: 0.11, + MediumComplex: 0.33, + ComplexReasoning: 0.77, + }, + Keywords: configstore.ComplexityEditableKeywordConfig{ + CodeKeywords: []string{" Function ", "api", "API"}, + ReasoningKeywords: []string{"tradeoffs"}, + TechnicalKeywords: []string{"latency"}, + SimpleKeywords: []string{"hello"}, + }, + } + configData := &ConfigData{ + Governance: &configstore.GovernanceConfig{ + ComplexityAnalyzerConfig: fileConfig, + }, + } + + mergeGovernanceConfig(context.Background(), config, configData, dbGovernance) + + stored, err := store.GetComplexityAnalyzerConfig(context.Background()) + require.NoError(t, err) + require.NotNil(t, stored) + require.Equal(t, 0.77, stored.TierBoundaries.ComplexReasoning) + require.Equal(t, []string{"api", "function"}, stored.Keywords.CodeKeywords) + require.Equal(t, stored, config.GovernanceConfig.ComplexityAnalyzerConfig) +} + // Prompt Repository - Folders func (m *MockConfigStore) GetFolders(ctx context.Context) ([]tables.TableFolder, error) { return nil, nil @@ -15944,6 +15997,9 @@ func getSchemaTypeMappings() []schemaTypeMapping { {"governance.virtual_keys.provider_configs", reflect.TypeOf(tables.TableVirtualKeyProviderConfig{}), true}, {"governance.virtual_keys.mcp_configs", reflect.TypeOf(tables.TableVirtualKeyMCPConfig{}), true}, {"governance.auth_config", reflect.TypeOf(configstore.AuthConfig{}), false}, + {"governance.complexity_analyzer_config", reflect.TypeOf(configstore.ComplexityAnalyzerConfig{}), false}, + {"governance.complexity_analyzer_config.tier_boundaries", reflect.TypeOf(configstore.ComplexityTierBoundaries{}), false}, + {"governance.complexity_analyzer_config.keywords", reflect.TypeOf(configstore.ComplexityEditableKeywordConfig{}), false}, // Plugins {"plugins", reflect.TypeOf(schemas.PluginConfig{}), true}, diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index bfca02695a..b6e177cdbe 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -26,6 +26,7 @@ import ( "github.com/maximhq/bifrost/framework/temptoken" "github.com/maximhq/bifrost/framework/tracing" "github.com/maximhq/bifrost/plugins/governance" + "github.com/maximhq/bifrost/plugins/governance/complexity" "github.com/maximhq/bifrost/plugins/logging" "github.com/maximhq/bifrost/plugins/prompts" "github.com/maximhq/bifrost/plugins/semanticcache" @@ -757,6 +758,22 @@ func (s *BifrostHTTPServer) GetGovernanceData(ctx context.Context) *governance.G return governancePlugin.GetGovernanceStore().GetGovernanceData(ctx) } +// ReloadComplexityAnalyzerConfig reloads the complexity analyzer config into the governance plugin. +func (s *BifrostHTTPServer) ReloadComplexityAnalyzerConfig(ctx context.Context, config *complexity.AnalyzerConfig) error { + governancePlugin, err := s.getGovernancePlugin() + if err != nil { + return fmt.Errorf("governance plugin not found: %w", err) + } + reloader, ok := governancePlugin.(interface { + ReloadComplexityAnalyzerConfig(config *complexity.AnalyzerConfig) + }) + if !ok { + return fmt.Errorf("governance plugin does not support complexity analyzer config reload") + } + reloader.ReloadComplexityAnalyzerConfig(config) + return nil +} + // ReloadRoutingRule reloads a routing rule from the database into the governance store func (s *BifrostHTTPServer) ReloadRoutingRule(ctx context.Context, id string) error { governancePluginName := governance.PluginName diff --git a/transports/config.schema.json b/transports/config.schema.json index f78b7e3007..71b0261323 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -746,6 +746,9 @@ "auth_config": { "$ref": "#/$defs/auth_config" }, + "complexity_analyzer_config": { + "$ref": "#/$defs/complexity_analyzer_config" + }, "model_configs": { "type": "array", "description": "Per-model rate limit and budget configurations", @@ -2256,6 +2259,86 @@ }, "additionalProperties": false }, + "complexity_tier_boundaries": { + "type": "object", + "description": "Score thresholds used to classify complexity_tier values", + "properties": { + "simple_medium": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 1, + "description": "Scores below this threshold are SIMPLE" + }, + "medium_complex": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 1, + "description": "Scores below this threshold and at or above simple_medium are MEDIUM" + }, + "complex_reasoning": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 1, + "description": "Scores below this threshold and at or above medium_complex are COMPLEX" + } + }, + "required": ["simple_medium", "medium_complex", "complex_reasoning"], + "additionalProperties": false + }, + "complexity_analyzer_keywords": { + "type": "object", + "description": "User-editable keyword lists for complexity analysis", + "properties": { + "code_keywords": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "reasoning_keywords": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "technical_keywords": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "simple_keywords": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["code_keywords", "reasoning_keywords", "technical_keywords", "simple_keywords"], + "additionalProperties": false + }, + "complexity_analyzer_config": { + "type": "object", + "description": "Runtime configuration for complexity_tier CEL routing", + "properties": { + "tier_boundaries": { + "$ref": "#/$defs/complexity_tier_boundaries" + }, + "keywords": { + "$ref": "#/$defs/complexity_analyzer_keywords" + } + }, + "required": ["tier_boundaries", "keywords"], + "additionalProperties": false + }, "auth_config": { "type": "object", "description": "Authentication configuration. Deprecated: Use governance.auth_config instead.",