diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index 80874ee6745..96637b5a43a 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 00000000000..0bc00796307 --- /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 31c780af170..4d6adb5a41e 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 7047f47166e..97afd60068a 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 28b07c411e2..6dca1018111 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 bb4776d4187..22594e9b03c 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 new file mode 100644 index 00000000000..c10e5deb88f --- /dev/null +++ b/plugins/governance/complexity/analyzer.go @@ -0,0 +1,203 @@ +package complexity + +import "math" + +// ComplexityAnalyzer computes complexity scores from normalized text input. +// It holds immutable tierBoundaries and matcher configuration after construction, +// so it is safe for concurrent use. +type ComplexityAnalyzer struct { + tierBoundaries TierBoundaries + matcher *compiledKeywordMatcher +} + +// 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{ + tierBoundaries: resolved.TierBoundaries, + matcher: newCompiledKeywordMatcher(keywords), + } +} + +// Analyze computes complexity scores from the normalized input. +func (a *ComplexityAnalyzer) Analyze(input ComplexityInput) *ComplexityResult { + // Select scan mask based on whether conversation history is present. + lastScanMask := lastTextBaseScanMask + if len(input.PriorUserTexts) > 0 { + lastScanMask = lastTextFullScanMask + } + + // Extract lexical signals from last user message and system prompt. + lastSignals := a.matcher.analyzeText(input.LastUserText, lastScanMask) + systemSignals := a.matcher.analyzeText(input.SystemText, systemTextScanMask) + + // Score primary message signals. + userCodeScore := scoreCount(lastSignals.codeCount, 3) + reasoningScore := scoreCount(lastSignals.reasoningCount, 2) + userTechnicalScore := scoreCount(lastSignals.technicalCount, 3) + userSimpleScore := scoreCount(lastSignals.simpleCount, 2) + outputScore := scoreOutputComplexity(lastSignals) + tokenScore := scoreTokenCount(lastSignals.wordCount) + + // System prompt provides soft lexical context for code/technical/simple signals, + // but never drives reasoning override, token count, or output complexity. + systemCodeScore := scoreCount(systemSignals.codeCount, 3) + systemTechnicalScore := scoreCount(systemSignals.technicalCount, 3) + systemSimpleScore := scoreCount(systemSignals.simpleCount, 2) + + codeScore := clamp(userCodeScore+(systemCodeScore*systemPromptAssistFactor), 0.0, 1.0) + technicalScore := clamp(userTechnicalScore+(systemTechnicalScore*systemPromptAssistFactor), 0.0, 1.0) + simpleScore := clamp(userSimpleScore+(systemSimpleScore*systemPromptAssistFactor), 0.0, 1.0) + + // Conditional simple dampener: only apply full dampener on short, low-signal asks. + wordCount := lastSignals.wordCount + effectiveSimpleWeight := simpleWeight + signalCount := 0 + if userCodeScore >= 0.3 { + signalCount++ + } + if userTechnicalScore >= 0.3 { + signalCount++ + } + if reasoningScore >= 0.3 { + signalCount++ + } + if lastSignals.simpleCount > 0 && (wordCount >= 30 || signalCount >= 2) { + effectiveSimpleWeight = 0.01 + } + + codeContribution := codeScore * codeWeight + reasoningContribution := reasoningScore * reasoningWeight + technicalContribution := technicalScore * technicalWeight + simplePenalty := -(simpleScore * effectiveSimpleWeight) + tokenContribution := tokenScore * tokenCountWeight + + // Weighted sum for last message (output complexity applied separately as a score floor). + lastMsgScore := codeContribution + + reasoningContribution + + technicalContribution + + simplePenalty + + tokenContribution + lastMsgScore = clamp(lastMsgScore, 0.0, 1.0) + + // Conversation context blending (prior user turns only). + var blended float64 + var convScore float64 + if len(input.PriorUserTexts) > 0 { + convScore = a.scoreConversationContext(input.PriorUserTexts) + lastWeight := defaultLastMessageBlendWeight + contextWeight := defaultConversationBlendWeight + if isReferentialFollowup(lastSignals, lastMsgScore, convScore, wordCount) { + lastWeight = referentialLastMessageBlendWeight + contextWeight = referentialConversationBlendWeight + } + + weightedBlend := (lastMsgScore * lastWeight) + (convScore * contextWeight) + blended = math.Max(lastMsgScore, weightedBlend) + } else { + blended = lastMsgScore + } + + // Output complexity as a score floor: strong output signals set a minimum score. + outputFloorMinScore := 0.0 + if outputScore > 0.5 { + outputFloorMinScore = outputScore * 0.5 + if blended < outputFloorMinScore { + blended = outputFloorMinScore + } + } + + finalScore := clamp(blended, 0.0, 1.0) + + // Tier classification with reasoning override. + strongCount := lastSignals.strongReasoningCount + tier := a.classifyTier(finalScore) + if strongCount >= 2 { + tier = TierReasoning + } else if strongCount >= 1 && (userCodeScore > 0.5 || userTechnicalScore > 0.5) { + tier = TierReasoning + } + + return &ComplexityResult{ + Score: finalScore, + Tier: tier, + WordCount: wordCount, + } +} + +func (a *ComplexityAnalyzer) scoreConversationContext(priorUserTexts []string) float64 { + if len(priorUserTexts) == 0 { + return 0.0 + } + + texts := priorUserTexts + if len(texts) > 10 { + texts = texts[len(texts)-10:] + } + + var weightedTotal float64 + var totalWeight float64 + lastIdx := len(texts) - 1 + for idx, text := range texts { + signals := a.matcher.analyzeText(text, contextTextScanMask) + code := scoreCount(signals.codeCount, 3) + tech := scoreCount(signals.technicalCount, 3) + reasoning := scoreCount(signals.reasoningCount, 2) + msgScore := (code*codeWeight + tech*technicalWeight + reasoning*reasoningWeight) / + (codeWeight + technicalWeight + reasoningWeight) + weight := 1.0 + if lastIdx > 0 { + weight = 1.0 + (2.0 * float64(idx) / float64(lastIdx)) + } + weightedTotal += msgScore * weight + totalWeight += weight + } + + if totalWeight == 0 { + return 0.0 + } + + return math.Min(1.0, weightedTotal/totalWeight) +} + +func isReferentialFollowup(signals textSignalCounts, lastMsgScore, convScore float64, wordCount int) bool { + if wordCount == 0 || wordCount > referentialMaxWordCount { + return false + } + if lastMsgScore >= referentialMaxStandaloneScore || convScore < referentialMinContextScore { + return false + } + if signals.taskShiftCount > 0 { + return false + } + if signals.referentialPhraseCount > 0 { + return true + } + + hasReference := signals.referentialReferenceCount > 0 + hasAction := signals.referentialActionCount > 0 + return hasReference && hasAction +} + +func (a *ComplexityAnalyzer) classifyTier(score float64) string { + switch { + case score < a.tierBoundaries.SimpleMedium: + return TierSimple + case score < a.tierBoundaries.MediumComplex: + return TierMedium + 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 new file mode 100644 index 00000000000..7fea167c90c --- /dev/null +++ b/plugins/governance/complexity/analyzer_test.go @@ -0,0 +1,785 @@ +package complexity + +import ( + "strings" + "testing" +) + +func TestAnalyze_Simple(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "What is 2+2?", + }) + + if result.Tier != "SIMPLE" { + t.Errorf("expected SIMPLE tier for 'What is 2+2?', got %s (score=%.3f)", result.Tier, result.Score) + } +} + +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() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Hello, how are you?", + }) + + if result.Tier != "SIMPLE" { + t.Errorf("expected SIMPLE tier for greeting, got %s (score=%.3f)", result.Tier, result.Score) + } +} + +func TestAnalyze_CodeRequest(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Write a Python quicksort function that handles arrays with duplicate elements", + }) + + if result.Tier != "MEDIUM" && result.Tier != "COMPLEX" { + t.Errorf("expected MEDIUM or COMPLEX tier for code request, got %s (score=%.3f)", result.Tier, result.Score) + } +} + +func TestAnalyze_Complex(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Design a distributed authentication system using Kubernetes with encryption and load balancer", + }) + + if result.Tier != "COMPLEX" && result.Tier != "REASONING" { + t.Errorf("expected COMPLEX or REASONING tier for architecture request, got %s (score=%.3f)", result.Tier, result.Score) + } +} + +func TestAnalyze_Reasoning(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Think step by step through the tradeoffs of this ML architecture and explain why one approach is better", + }) + + if result.Tier != "REASONING" { + t.Errorf("expected REASONING tier for deep reasoning request, got %s (score=%.3f)", result.Tier, result.Score) + } +} + +func TestAnalyze_OutputComplexity(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "List every AWS service and explain each one with examples", + }) + + if result.Tier == "SIMPLE" { + t.Errorf("expected non-SIMPLE tier for output-heavy request, got %s (score=%.3f)", result.Tier, result.Score) + } +} + +func TestAnalyze_ConversationContext(t *testing.T) { + a := NewComplexityAnalyzer() + + // Short follow-up with no context stays SIMPLE. + noCtx := a.Analyze(ComplexityInput{ + LastUserText: "Why?", + }) + + // Same follow-up with technical conversation history gets a higher score. + withCtx := a.Analyze(ComplexityInput{ + LastUserText: "Why?", + PriorUserTexts: []string{ + "How does the distributed authentication system handle encryption?", + "What about the kubernetes infrastructure for microservices?", + "Can you explain the concurrency model and mutex usage?", + }, + }) + + if withCtx.Score <= noCtx.Score { + t.Errorf("expected conversation context to raise score: noCtx=%.3f, withCtx=%.3f", + noCtx.Score, withCtx.Score) + } +} + +func TestAnalyze_ConversationContextDoesNotDiluteStrongLastMessage(t *testing.T) { + a := NewComplexityAnalyzer() + + lastTurnOnly := a.Analyze(ComplexityInput{ + LastUserText: "Design the target architecture for migrating our monolith checkout service to an event-driven system. Cover the event schema, consumer topology, idempotency strategy, and a phased data migration plan that maintains zero downtime.", + }) + + withCtx := a.Analyze(ComplexityInput{ + LastUserText: "Design the target architecture for migrating our monolith checkout service to an event-driven system. Cover the event schema, consumer topology, idempotency strategy, and a phased data migration plan that maintains zero downtime.", + PriorUserTexts: []string{ + "We're hitting scaling limits with our monolithic checkout service.", + "Current throughput is 500 TPS but we need 5,000 TPS by Q3.", + "We're considering event sourcing but worried about operational complexity.", + }, + }) + + if withCtx.Score < lastTurnOnly.Score { + t.Errorf("expected context-aware score to preserve or raise final score: lastOnly=%.3f, withCtx=%.3f", + lastTurnOnly.Score, withCtx.Score) + } +} + +func TestAnalyze_ReferentialFollowupLiftsShortTechnicalContinuation(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "do it", + PriorUserTexts: []string{ + "We need to refactor the retry middleware so only 429 and 408 retry.", + "Move fallback selection after request classification and keep the behavior change explicit in the PR.", + "Update the Go tests for the CEL routing rules and the governance plugin.", + }, + }) + + if result.Tier == "SIMPLE" { + t.Fatalf("expected short referential follow-up to lift above SIMPLE, got %s (score=%.3f)", result.Tier, result.Score) + } + if result.Score < simpleMediumBoundary { + t.Fatalf("expected score above SIMPLE threshold, got %.3f", result.Score) + } +} + +func TestAnalyze_ReferentialFollowupRequiresRealContext(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "do it", + }) + + if result.Tier != "SIMPLE" { + t.Fatalf("expected SIMPLE tier without prior context, got %s (score=%.3f)", result.Tier, result.Score) + } +} + +func TestAnalyze_TaskShiftFollowupDoesNotUseReferentialLift(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "translate this to spanish", + PriorUserTexts: []string{ + "We need to debug the Kubernetes deployment and fix the authentication middleware.", + "The RBAC mapping for SAML tenants is failing after the migration.", + }, + }) + + if result.Score >= mediumComplexBoundary { + t.Fatalf("expected task-shift request to stay below COMPLEX threshold, got %.3f", result.Score) + } +} + +func TestAnalyze_LimitingTaskShiftDoesNotUseReferentialLift(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "summarize it in one sentence", + PriorUserTexts: []string{ + "Design a multi-tenant billing ledger with metering, proration, credits, and invoice generation.", + "Include the data model and monthly aggregation flow.", + }, + }) + + if result.Score >= mediumComplexBoundary { + t.Fatalf("expected limiting summary request to stay below COMPLEX threshold, got %.3f", result.Score) + } +} + +func TestAnalyze_RecentContextOutweighsOlderContext(t *testing.T) { + a := NewComplexityAnalyzer() + + recentTech := a.Analyze(ComplexityInput{ + LastUserText: "do it", + PriorUserTexts: []string{ + "Hello there.", + "Thanks.", + "Design a distributed authentication system with RBAC, OIDC, and regional failover.", + }, + }) + + olderTech := a.Analyze(ComplexityInput{ + LastUserText: "do it", + PriorUserTexts: []string{ + "Design a distributed authentication system with RBAC, OIDC, and regional failover.", + "Hello there.", + "Thanks.", + }, + }) + + if recentTech.Score <= olderTech.Score { + t.Fatalf("expected more recent technical context to matter more: recent=%.3f older=%.3f", + recentTech.Score, olderTech.Score) + } +} + +func TestAnalyze_SystemPromptBoost(t *testing.T) { + a := NewComplexityAnalyzer() + + base := a.Analyze(ComplexityInput{ + LastUserText: "Review this code for issues", + }) + + boosted := a.Analyze(ComplexityInput{ + LastUserText: "Review this code for issues", + SystemText: "You are a security engineer responsible for RBAC, audit log reviews, and OIDC policy.", + }) + + if boosted.Score <= base.Score { + t.Errorf("expected system prompt to boost score: base=%.3f, boosted=%.3f", + base.Score, boosted.Score) + } +} + +func TestAnalyze_SystemPromptDampener(t *testing.T) { + a := NewComplexityAnalyzer() + + base := a.Analyze(ComplexityInput{ + LastUserText: "Explain how databases work", + }) + + dampened := a.Analyze(ComplexityInput{ + LastUserText: "Explain how databases work", + SystemText: "You are a beginner tutor. Keep answers simple, brief, and concise.", + }) + + if dampened.Score >= base.Score { + t.Errorf("expected system prompt to dampen score: base=%.3f, dampened=%.3f", + base.Score, dampened.Score) + } +} + +func TestAnalyze_SystemPromptLexicalAssistDoesNotOverPromoteSimpleWebhook(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "What is a webhook?", + SystemText: "You are responsible for RBAC, audit log controls, and OIDC integration policy.", + }) + + if result.Tier != "SIMPLE" { + t.Errorf("expected SIMPLE tier for webhook definition with technical system prompt, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_EmptyInput(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{}) + + if result.Tier != "SIMPLE" { + t.Errorf("expected SIMPLE tier for empty input, got %s", result.Tier) + } + if result.Score != 0.0 { + t.Errorf("expected 0.0 score for empty input, got %.3f", result.Score) + } +} + +func TestAnalyze_ReasoningOverrideNotTooEager(t *testing.T) { + a := NewComplexityAnalyzer() + + // Two weak reasoning markers should NOT force REASONING + result := a.Analyze(ComplexityInput{ + LastUserText: "Why does React re-render, and what if I use useMemo?", + }) + + if result.Tier == "REASONING" { + t.Errorf("expected non-REASONING tier for casual question with weak markers, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_SimpleDampenerConditional(t *testing.T) { + a := NewComplexityAnalyzer() + + // "What is" + technical term should not be over-dampened + result := a.Analyze(ComplexityInput{ + LastUserText: "What is eventual consistency in distributed systems with sharding?", + }) + + if result.Tier == "SIMPLE" { + t.Errorf("expected non-SIMPLE tier for technical 'what is' question, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_AccessVsRefreshTokens(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Explain the difference between an access token and a refresh token. When would you use short-lived vs long-lived tokens?", + }) + + if result.Tier == "SIMPLE" { + t.Errorf("expected MEDIUM or higher tier for token lifecycle question, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_OutageCustomerCommunication(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Draft a short outage notification email for our enterprise customers. Our payment processing was down for 23 minutes this morning between 09:12 and 09:35 UTC. No transactions were lost but some were delayed.", + SystemText: "You are a customer success manager for a B2B SaaS platform. You help draft professional and empathetic communications to enterprise customers.", + }) + + if result.Tier == "SIMPLE" { + t.Errorf("expected MEDIUM or higher tier for outage communication prompt, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_MultiTenantSSOArchitecture(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Design a multi-tenant authentication service for a SaaS platform on Kubernetes. Requirements: RBAC with custom roles per tenant, audit logging for all auth events, regional failover across two AWS regions, and support for both SAML 2.0 and OIDC enterprise SSO. Include the data model and the request flow for a login.", + }) + + if result.Tier != "COMPLEX" && result.Tier != "REASONING" { + t.Errorf("expected COMPLEX or REASONING tier for multi-tenant SSO architecture prompt, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_PostIncidentReconstruction(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Given this partial timeline with a 15-minute telemetry gap, reconstruct the most likely sequence of failures. Why did connection pool exhaustion happen? Why didn't the ConfigMap fix work, and what should the on-call have done instead? What might have happened during the metrics blackout that we can't directly observe? Identify the weakest assumptions in your reconstruction and flag what we'd need to verify.", + PriorUserTexts: []string{ + "The outage lasted 47 minutes and affected all US-East customers. Revenue impact was approximately $180,000.", + "Timeline: 14:03 - alerts fired for elevated 5xx rates on the API gateway. 14:15 - identified database connection pool exhaustion on the primary Postgres cluster.", + "At 14:22 the on-call attempted to scale up the connection pool via a ConfigMap change, but the change didn't take effect because our pods require a restart to pick up ConfigMap changes.", + }, + SystemText: "You are leading the post-incident review for a major production outage at a multi-region SaaS company.", + }) + + if result.Tier != "COMPLEX" && result.Tier != "REASONING" { + t.Errorf("expected COMPLEX or REASONING tier for post-incident reconstruction, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_CodingFollowupsWithTechnicalContext(t *testing.T) { + a := NewComplexityAnalyzer() + + tests := []struct { + name string + lastUserText string + prior []string + }{ + { + name: "explain_changes_for_pr", + lastUserText: "Can you explain the changes in plain English for the PR description and call out the behavior change?", + prior: []string{ + "I'm working on a Go gateway and just changed our retry middleware so it stops retrying most 4xx responses.", + "I added an allowlist so only 429 and 408 still retry, and I moved the fallback logic after the classification step.", + }, + }, + { + name: "summarize_refactor", + lastUserText: "Can you summarize the refactor for the PR in a few bullets and highlight the behavior changes?", + prior: []string{ + "I split our request parsing code into a transport-specific extractor layer and a pure analyzer package so the heuristics don't depend on raw HTTP payload shapes.", + "I also moved provider-shape branching into the governance plugin, added tests for OpenAI Responses input_text, and stopped unsupported requests from defaulting to SIMPLE.", + }, + }, + { + name: "write_commit_message", + lastUserText: "Can you write the commit message for this patch?", + prior: []string{ + "I changed the retry middleware so it stops retrying most 4xx responses.", + "I added an allowlist for retryable statuses and moved fallback selection after the classification step.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := a.Analyze(ComplexityInput{ + LastUserText: tt.lastUserText, + PriorUserTexts: tt.prior, + }) + + if result.Tier == "SIMPLE" { + t.Errorf("expected MEDIUM or higher tier for coding follow-up, got %s (score=%.3f)", + result.Tier, result.Score) + } + }) + } +} + +func TestAnalyze_GitHubActionsWorkflow(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Write a GitHub Actions workflow that detects which services changed in a PR and only runs the tests for those services.", + PriorUserTexts: []string{ + "I'm setting up CI/CD for the first time for our monorepo.", + "We use GitHub Actions and each service has its own go.mod and test suite.", + }, + }) + + if result.Tier == "SIMPLE" { + t.Errorf("expected MEDIUM or higher tier for GitHub Actions workflow request, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_BillingLedgerPipeline(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Design a usage-based billing pipeline covering metering, aggregation, proration, credits, dunning, and invoice generation. Include the data model for the ledger and the sequence flow for generating a monthly invoice.", + SystemText: "You are a staff engineer for a B2B SaaS billing platform.", + }) + + if result.Tier != "COMPLEX" && result.Tier != "REASONING" { + t.Errorf("expected COMPLEX or REASONING tier for billing ledger pipeline prompt, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_VectorDatabaseTradeoffRecommendation(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "Compare self-hosted Qdrant vs managed Pinecone for a hybrid search system serving 1,000 QPS with 50M vectors. We're in a regulated industry - no data can leave our VPC, and we need SOC 2 attestation for all data stores. Weigh the tradeoffs around data residency compliance, operational burden for a 4-person infra team, query latency at scale, cost scaling characteristics, and disaster recovery options. Recommend one and explain your reasoning.", + }) + + if result.Tier != "REASONING" { + t.Errorf("expected REASONING tier for vector database tradeoff recommendation, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestIsReferentialFollowup_GuardBranches(t *testing.T) { + tests := []struct { + name string + lastText string + lastMsgScore float64 + convScore float64 + wordCount int + expected bool + }{ + {"phrase_match_ok", "do it", 0.05, 0.30, 2, true}, + {"phrase_match_at_word_cap", "do it now please right away", 0.05, 0.30, 6, true}, + {"phrase_match_over_word_cap", "do it now please right away ok", 0.05, 0.30, 7, false}, + {"phrase_match_zero_words", "", 0.0, 0.30, 0, false}, + {"phrase_match_score_at_threshold", "do it", 0.15, 0.30, 2, false}, + {"phrase_match_score_just_below_threshold", "do it", 0.149, 0.30, 2, true}, + {"phrase_match_conv_just_below_threshold", "do it", 0.05, 0.199, 2, false}, + {"phrase_match_conv_at_threshold", "do it", 0.05, 0.20, 2, true}, + {"task_shift_blocks_phrase_match", "translate it", 0.05, 0.30, 2, false}, + {"task_shift_blocks_summarize", "summarize it", 0.05, 0.30, 2, false}, + {"task_shift_one_sentence_blocks", "rewrite it in one sentence", 0.05, 0.30, 5, false}, + {"multi_signal_fix_it", "fix it", 0.05, 0.30, 2, true}, + {"multi_signal_make_it_shorter", "make it shorter", 0.05, 0.30, 3, true}, + {"multi_signal_rewrite_it", "rewrite it", 0.05, 0.30, 2, true}, + {"multi_signal_use_that", "use that", 0.05, 0.30, 2, true}, + {"multi_signal_answer_previous", "answer the previous question", 0.05, 0.30, 4, true}, + {"action_only_no_deictic", "fix the race condition", 0.05, 0.30, 4, false}, + {"deictic_only_no_action", "this is great", 0.05, 0.30, 3, false}, + {"unrelated_short_text", "hello there friend", 0.05, 0.30, 3, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher := newCompiledKeywordMatcher(defaultFullKeywordConfig()) + signals := matcher.analyzeText(tt.lastText, lastTextFullScanMask) + got := isReferentialFollowup(signals, tt.lastMsgScore, tt.convScore, tt.wordCount) + if got != tt.expected { + t.Errorf("isReferentialFollowup(%q, last=%.3f, conv=%.3f, words=%d) = %v, want %v", + tt.lastText, tt.lastMsgScore, tt.convScore, tt.wordCount, got, tt.expected) + } + }) + } +} + +func TestAnalyze_ReferentialMultiSignalDetection(t *testing.T) { + a := NewComplexityAnalyzer() + + techPriors := []string{ + "We need to refactor the retry middleware so only 429 and 408 retry.", + "Move fallback selection after request classification and keep the behavior change explicit in the PR.", + "Update the Go tests for the CEL routing rules and the governance plugin.", + } + + tests := []struct { + name string + lastText string + }{ + {"fix_it", "fix it"}, + {"make_it_shorter", "make it shorter"}, + {"rewrite_it", "rewrite it"}, + {"do_this", "do this"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := a.Analyze(ComplexityInput{ + LastUserText: tt.lastText, + PriorUserTexts: techPriors, + }) + if result.Tier == "SIMPLE" { + t.Fatalf("expected lift above SIMPLE for %q, got %s (score=%.3f)", + tt.lastText, result.Tier, result.Score) + } + }) + } +} + +func TestAnalyze_ReferentialPhraseDoesNotHijackStrongAsk(t *testing.T) { + a := NewComplexityAnalyzer() + + result := a.Analyze(ComplexityInput{ + LastUserText: "use option 2 to design the distributed consensus algorithm with kubernetes and rbac", + PriorUserTexts: []string{ + "We need to refactor the retry middleware so only 429 and 408 retry.", + }, + }) + + if result.Tier == "SIMPLE" { + t.Fatalf("expected high-signal message to stay above SIMPLE despite referential phrase, got %s (score=%.3f)", + result.Tier, result.Score) + } +} + +func TestAnalyze_RegressionAnchors(t *testing.T) { + a := NewComplexityAnalyzer() + + techPriors := []string{ + "We need to refactor the retry middleware so only 429 and 408 retry.", + "Move fallback selection after request classification and keep the behavior change explicit in the PR.", + "Update the Go tests for the CEL routing rules and the governance plugin.", + } + + tests := []struct { + name string + lastText string + priors []string + minTier string // tier must be at least this rank (or empty for "any") + maxTier string // tier must be at most this rank (or empty for "any") + mustNotEqualTiers []string + }{ + { + name: "do_it_after_tech_thread_lifts", + lastText: "do it", + priors: techPriors, + mustNotEqualTiers: []string{"SIMPLE"}, + }, + { + name: "try_again_after_tech_thread_lifts", + lastText: "try again", + priors: techPriors, + mustNotEqualTiers: []string{"SIMPLE"}, + }, + { + name: "translate_after_tech_thread_stays_simple", + lastText: "translate this to spanish", + priors: techPriors, + maxTier: "MEDIUM", + }, + { + name: "summarize_after_tech_thread_stays_simple", + lastText: "summarize it in one sentence", + priors: techPriors, + maxTier: "MEDIUM", + }, + { + name: "do_it_with_empty_priors_stays_simple", + lastText: "do it", + priors: nil, + maxTier: "SIMPLE", + }, + { + name: "strong_arch_ask_with_smalltalk_priors_stays_strong", + lastText: "Design a fault-tolerant distributed consensus algorithm with leader election, log replication, and snapshotting; weigh the tradeoffs between Raft and Paxos and recommend a design under the constraint of WAN replication.", + priors: []string{"hi", "thanks", "ok"}, + minTier: "COMPLEX", + }, + { + name: "translate_no_priors_stays_simple", + lastText: "translate this to spanish", + priors: nil, + maxTier: "SIMPLE", + }, + } + + tierRank := map[string]int{"SIMPLE": 0, "MEDIUM": 1, "COMPLEX": 2, "REASONING": 3} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := a.Analyze(ComplexityInput{ + LastUserText: tt.lastText, + PriorUserTexts: tt.priors, + }) + + if tt.minTier != "" && tierRank[result.Tier] < tierRank[tt.minTier] { + t.Errorf("tier=%s, expected at least %s (score=%.3f)", result.Tier, tt.minTier, result.Score) + } + if tt.maxTier != "" && tierRank[result.Tier] > tierRank[tt.maxTier] { + t.Errorf("tier=%s, expected at most %s (score=%.3f)", result.Tier, tt.maxTier, result.Score) + } + for _, banned := range tt.mustNotEqualTiers { + if result.Tier == banned { + t.Errorf("tier=%s, must not equal %s (score=%.3f)", result.Tier, banned, result.Score) + } + } + }) + } +} + +func TestScoreConversationContext_RecencyDecay(t *testing.T) { + a := NewComplexityAnalyzer() + + // Empty list returns 0 without dividing by zero. + if got := a.scoreConversationContext(nil); got != 0.0 { + t.Errorf("empty priors should return 0.0, got %.3f", got) + } + + // Single prior message: lastIdx == 0, weight branch is the uniform fallback. + // Should not panic, should return a positive score for technical content. + single := a.scoreConversationContext([]string{ + "Design a distributed authentication system with kubernetes, rbac, and oidc.", + }) + if single <= 0 { + t.Errorf("expected positive score for single technical prior, got %.3f", single) + } + + // Linear decay: a strong technical message at the END of the list should + // produce a meaningfully higher score than the same message at the START. + recent := a.scoreConversationContext([]string{ + "hello", + "thanks", + "Design a distributed authentication system with kubernetes, rbac, and oidc.", + }) + older := a.scoreConversationContext([]string{ + "Design a distributed authentication system with kubernetes, rbac, and oidc.", + "hello", + "thanks", + }) + if recent <= older { + t.Errorf("expected recent strong message to score higher than older one: recent=%.3f older=%.3f", + recent, older) + } +} + +func TestContainsWord(t *testing.T) { + tests := []struct { + text string + word string + expected bool + }{ + {"write a function", "function", true}, + {"classification problem", "class", false}, // word boundary + {"the class is good", "class", true}, + {"debug the code", "debug", true}, + {"debug", "debug", true}, + {"nodebug", "debug", false}, + {"la securite est importante", "securite", true}, + {"la sécurité est importante", "sécurité", true}, + {"sécuritétest", "sécurité", false}, + {"", "test", false}, + {"write a function", "", false}, + } + + for _, tt := range tests { + got := containsWord(tt.text, tt.word) + if got != tt.expected { + t.Errorf("containsWord(%q, %q) = %v, want %v", tt.text, tt.word, got, tt.expected) + } + } +} + +func TestCountWordsNoAllocMatchesStringsFields(t *testing.T) { + tests := []string{ + "", + "hello world", + " multiple spaces here ", + "line one\nline two\tline three", + "unicode\u00a0space separated words", + } + + for _, text := range tests { + got := countWordsNoAlloc(text) + want := len(strings.Fields(text)) + if got != want { + t.Errorf("countWordsNoAlloc(%q) = %d, want %d", text, got, want) + } + } +} + +func TestKeywordMatchModeFor(t *testing.T) { + tests := []struct { + keyword string + want keywordMatchMode + }{ + {"function", matchModeWholeWord}, + {"sécurité", matchModeWholeWord}, + {"ci/cd", matchModeBoundarySubstring}, + {"root cause", matchModePlainSubstring}, + } + + for _, tt := range tests { + if got := keywordMatchModeFor(tt.keyword); got != tt.want { + t.Errorf("keywordMatchModeFor(%q) = %v, want %v", tt.keyword, got, tt.want) + } + } +} + +func TestBuildWordPresenceSet_UnicodeWords(t *testing.T) { + words := buildWordPresenceSet("la sécurité du réseau protège les données") + + if _, ok := words["sécurité"]; !ok { + t.Fatalf("expected unicode word to be preserved in presence set") + } + if _, ok := words["réseau"]; !ok { + t.Fatalf("expected second unicode word to be preserved in presence set") + } +} + +func TestAnalyze_PunctuatedKeywordStillMatches(t *testing.T) { + a := NewComplexityAnalyzer() + + signals := a.matcher.analyzeText("Please review our CI/CD pipeline and retry middleware behavior.", lastTextBaseScanMask) + if signals.codeCount == 0 { + t.Fatalf("expected punctuated keyword path to match code signals") + } +} diff --git a/plugins/governance/complexity/config.go b/plugins/governance/complexity/config.go new file mode 100644 index 00000000000..57bda9eb77c --- /dev/null +++ b/plugins/governance/complexity/config.go @@ -0,0 +1,140 @@ +// 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 (up to 10) + SystemText string // concatenated system/developer prompt text +} + +// ComplexityResult holds the computed complexity scores and tier classification. +type ComplexityResult struct { + Score float64 + Tier string + WordCount int +} + +const ( + TierSimple = "SIMPLE" + TierMedium = "MEDIUM" + TierComplex = "COMPLEX" + TierReasoning = "REASONING" +) + +const ( + simpleMediumBoundary = 0.15 + 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/keywords.go b/plugins/governance/complexity/keywords.go new file mode 100644 index 00000000000..3f5bba5b0e5 --- /dev/null +++ b/plugins/governance/complexity/keywords.go @@ -0,0 +1,131 @@ +package complexity + +// --- Dimension weights --- + +const ( + codeWeight = 0.30 + reasoningWeight = 0.25 + technicalWeight = 0.25 + simpleWeight = 0.05 // dampener, subtracted + tokenCountWeight = 0.10 + systemPromptAssistFactor = 0.25 + defaultLastMessageBlendWeight = 0.60 + defaultConversationBlendWeight = 0.40 + referentialLastMessageBlendWeight = 0.35 + referentialConversationBlendWeight = 0.65 + referentialMaxStandaloneScore = 0.15 + referentialMaxWordCount = 6 + referentialMinContextScore = 0.20 + wordPresenceSetMinBytes = 8 * 1024 + // Output complexity is applied as a score floor, not a weighted dimension +) + +// --- Keyword lists --- +// CodePresence: implementation/code syntax/workflow signals +var codeKeywords = []string{ + "function", "class", "api", "database", "algorithm", "code", "implement", + "debug", "error", "syntax", "compile", "runtime", "library", "framework", + "variable", "loop", "array", "object", "method", "interface", + "regex", "deploy", "docker", "sql", "query", "schema", "endpoint", + "refactor", "bug", "parse", "async", "webhook", "migration", + "ci/cd", "pipeline", "rest", "graphql", "test", "unit test", + "python", "javascript", "typescript", "golang", "java", "ruby", + "github actions", "monorepo", "aws cli", "config rule", "config rules", + "retry", "fallback", "middleware", "patch", "diff", "pr", "pull request", + "commit", "commit message", "behavior change", + "cel", "auto-routing", "rwmutex", "goroutine", +} + +// Reasoning markers, split into strong and weak for override logic. +var strongReasoningKeywords = []string{ + "step by step", "think through", "tradeoffs", "pros and cons", + "justify", "critique", "implications", "explain why", + "root cause analysis", "reconstruct the sequence", + "reconstruct the most likely sequence", "what should have happened instead", + "explain your reasoning", "weigh the tradeoffs", "recommend a design", +} + +var weakReasoningKeywords = []string{ + "reason", "analyze", "evaluate", "compare", "assess", "consider", + "why does", "what if", "how would", "what are the", "which approach", + "think about", "design", "most likely", "reconstruct", "verify", + "assumption", "hypothesis", "compare and contrast", "weigh the options", + "recommend one", "given these constraints", "under these constraints", +} + +// TechnicalTerms: architecture/distributed/security/infrastructure signals +var technicalKeywords = []string{ + "architecture", "distributed", "encryption", "authentication", "scalability", + "microservices", "kubernetes", "infrastructure", "protocol", "latency", + "throughput", "concurrency", "optimization", "load balancer", "caching", + "sharding", "replication", "consensus", "mutex", "deadlock", + "race condition", "api gateway", "terraform", "observability", + "access token", "refresh token", "rbac", "sso", "oidc", "saml", + "tenant", "multi-tenant", "audit log", "failover", "idempotency", + "zero downtime", "incident", "outage", "postmortem", "root cause", + "telemetry", "metrics", "configmap", "connection pool", "payment processing", + "saas", "feature flag", "operational risk", "vendor lock-in", + "s3 bucket", "misconfiguration", "remediation", "oltp", "olap", + "ledger", "metering", "aggregation", "proration", "credits", "dunning", + "invoice", "invoice generation", "double-entry", "reconciliation", + "chart of accounts", "hipaa", "quarantine workflow", "retention policy", + "audit trail", "pre-signed url", "entitlements", "seat limits", + "usage quotas", "deprovisioning", "permission drift", "role mapping", + "fraud detection", "manual review", "feedback loop", + "model serving", "a/b testing", "identity resolution", + "deterministic replay", "tamper evidence", "hash chain", + "approval workflow", "vpc", "soc 2", "data residency", + "disaster recovery", "data race", "struct copy", "hybrid search", +} + +// SimpleIndicators: signals for trivial/greeting-type requests +var simpleKeywords = []string{ + "what is", "define", "hello", "hi", "thanks", "how do i spell", + "translate", "what does", "who is", "when was", "tell me about", + "good morning", "good night", "how are you", "simple", "brief", + "short", "quick", "beginner", "basic", "concise", +} + +// --- Output complexity keywords --- + +var enumTriggers = []string{ + "list every", "list all", "enumerate all", "all possible", + "every single", "show all", "name all", "give me all", +} + +var comprehensivenessMarkers = []string{ + "comprehensive", "exhaustive", "complete list", "full list", + "in detail", "detailed breakdown", "thorough", "in-depth", +} + +var elaborationMarkers = []string{ + "and what it does", "explain each", "describe each", "for each", + "with examples", "with descriptions", "along with", +} + +var limitingQualifiers = []string{ + "briefly", "top 3", "top 5", "top 10", "in one sentence", + "quickly", "summarize", "just the", "only the", "keep it short", + "tl;dr", "tldr", +} + +var referentialPhrases = []string{ + "do it", "try again", "continue", "go ahead", "proceed", + "that one", "this one", "same thing", "again", "retry", + "yes do that", "go with that", "use option 1", "use option 2", "use option 3", + "now write it", +} + +var referentialReferenceWords = []string{ + "it", "this", "that", "same", "previous", "earlier", +} + +var referentialActionWords = []string{ + "do", "retry", "continue", "proceed", "use", "fix", + "rewrite", "shorten", "clean", "adjust", "make", "give", "answer", +} + +var taskShiftPhrases = []string{ + "translate", "summarize", "in one sentence", "one sentence", + "in spanish", "in french", "in german", "more politely", "more polite", +} diff --git a/plugins/governance/complexity/matcher.go b/plugins/governance/complexity/matcher.go new file mode 100644 index 00000000000..325721e3e19 --- /dev/null +++ b/plugins/governance/complexity/matcher.go @@ -0,0 +1,247 @@ +package complexity + +import "strings" + +type compiledKeywordMask uint16 + +const ( + maskCode compiledKeywordMask = 1 << iota + maskReasoning + maskStrongReasoning + maskTechnical + maskSimple + maskEnum + maskComprehensive + maskElaboration + maskLimiter + maskReferentialPhrase + maskReferentialReference + maskReferentialAction + maskTaskShift +) + +const ( + lastTextBaseScanMask = maskCode | maskReasoning | maskStrongReasoning | maskTechnical | maskSimple | maskEnum | maskComprehensive | maskElaboration | maskLimiter + lastTextFullScanMask = lastTextBaseScanMask | maskReferentialPhrase | maskReferentialReference | maskReferentialAction | maskTaskShift + systemTextScanMask = maskCode | maskTechnical | maskSimple + contextTextScanMask = maskCode | maskReasoning | maskTechnical +) + +type keywordMatchMode uint8 + +const ( + matchModeWholeWord keywordMatchMode = iota + matchModeBoundarySubstring + matchModePlainSubstring +) + +type compiledKeyword struct { + text string + mask compiledKeywordMask + matchMode keywordMatchMode +} + +// compiledKeywordMatcher groups keywords by match strategy so request-time +// scans can skip repeated per-keyword boundary-mode decisions. +type compiledKeywordMatcher struct { + wholeWordKeywords []compiledKeyword + boundarySubstringKeywords []compiledKeyword + plainSubstringKeywords []compiledKeyword +} + +type textSignalCounts struct { + wordCount int + codeCount int + reasoningCount int + strongReasoningCount int + technicalCount int + simpleCount int + enumCount int + comprehensiveCount int + elaborationCount int + limitingQualifierCount int + referentialPhraseCount int + referentialReferenceCount int + referentialActionCount int + taskShiftCount int +} + +func newCompiledKeywordMatcher(keywords KeywordConfig) *compiledKeywordMatcher { + entries := make(map[string]compiledKeyword) + addKeywords := func(keywords []string, mask compiledKeywordMask) { + for _, kw := range keywords { + text := strings.TrimSpace(strings.ToLower(kw)) + if text == "" { + continue + } + entry, ok := entries[text] + if !ok { + entry = compiledKeyword{ + text: text, + mask: mask, + matchMode: keywordMatchModeFor(text), + } + } else { + entry.mask |= mask + } + entries[text] = entry + } + } + + 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 { + switch entry.matchMode { + case matchModeWholeWord: + matcher.wholeWordKeywords = append(matcher.wholeWordKeywords, entry) + case matchModeBoundarySubstring: + matcher.boundarySubstringKeywords = append(matcher.boundarySubstringKeywords, entry) + case matchModePlainSubstring: + matcher.plainSubstringKeywords = append(matcher.plainSubstringKeywords, entry) + } + } + return matcher +} + +func keywordMatchModeFor(keyword string) keywordMatchMode { + if strings.Contains(keyword, " ") { + return matchModePlainSubstring + } + for _, r := range keyword { + if !isWordChar(r) { + return matchModeBoundarySubstring + } + } + return matchModeWholeWord +} + +// analyzeText lowercases once, then takes a cheaper whole-word lookup path for +// larger texts where a single tokenization pass beats repeated boundary scans. +func (m *compiledKeywordMatcher) analyzeText(text string, scanMask compiledKeywordMask) textSignalCounts { + if text == "" { + return textSignalCounts{} + } + + lowerText := strings.ToLower(text) + signals := textSignalCounts{ + wordCount: countWordsNoAlloc(text), + } + + if len(lowerText) >= wordPresenceSetMinBytes { + wordPresence := buildWordPresenceSet(lowerText) + for _, keyword := range m.wholeWordKeywords { + if keyword.mask&scanMask == 0 { + continue + } + if _, ok := wordPresence[keyword.text]; ok { + signals.addMask(keyword.mask) + } + } + } else { + for _, keyword := range m.wholeWordKeywords { + if keyword.mask&scanMask == 0 { + continue + } + if containsWord(lowerText, keyword.text) { + signals.addMask(keyword.mask) + } + } + } + for _, keyword := range m.boundarySubstringKeywords { + if keyword.mask&scanMask == 0 { + continue + } + if containsWord(lowerText, keyword.text) { + signals.addMask(keyword.mask) + } + } + for _, keyword := range m.plainSubstringKeywords { + if keyword.mask&scanMask == 0 { + continue + } + if strings.Contains(lowerText, keyword.text) { + signals.addMask(keyword.mask) + } + } + + return signals +} + +// addMask increments every scoring bucket a matched keyword contributes to. +func (s *textSignalCounts) addMask(mask compiledKeywordMask) { + if mask&maskCode != 0 { + s.codeCount++ + } + if mask&maskReasoning != 0 { + s.reasoningCount++ + } + if mask&maskStrongReasoning != 0 { + s.strongReasoningCount++ + } + if mask&maskTechnical != 0 { + s.technicalCount++ + } + if mask&maskSimple != 0 { + s.simpleCount++ + } + if mask&maskEnum != 0 { + s.enumCount++ + } + if mask&maskComprehensive != 0 { + s.comprehensiveCount++ + } + if mask&maskElaboration != 0 { + s.elaborationCount++ + } + if mask&maskLimiter != 0 { + s.limitingQualifierCount++ + } + if mask&maskReferentialPhrase != 0 { + s.referentialPhraseCount++ + } + if mask&maskReferentialReference != 0 { + s.referentialReferenceCount++ + } + if mask&maskReferentialAction != 0 { + s.referentialActionCount++ + } + if mask&maskTaskShift != 0 { + s.taskShiftCount++ + } +} + +// buildWordPresenceSet tokenizes large inputs once so whole-word matches become +// set lookups instead of repeated boundary-aware scans. +func buildWordPresenceSet(text string) map[string]struct{} { + words := make(map[string]struct{}, 64) + start := -1 + for i, r := range text { + if isWordChar(r) { + if start == -1 { + start = i + } + continue + } + if start != -1 { + words[text[start:i]] = struct{}{} + start = -1 + } + } + if start != -1 { + words[text[start:]] = struct{}{} + } + return words +} diff --git a/plugins/governance/complexity/utils.go b/plugins/governance/complexity/utils.go new file mode 100644 index 00000000000..b6fbd6c07f6 --- /dev/null +++ b/plugins/governance/complexity/utils.go @@ -0,0 +1,114 @@ +package complexity + +import ( + "math" + "strings" + "unicode" + "unicode/utf8" +) + +// containsWord checks if a word appears in text delimited by non-alphanumeric boundaries. +func containsWord(text, word string) bool { + if word == "" { + return false + } + + idx := 0 + for { + pos := strings.Index(text[idx:], word) + if pos == -1 { + return false + } + start := idx + pos + end := start + len(word) + + startOk := start == 0 || !isWordChar(lastRune(text[:start])) + endOk := end == len(text) || !isWordChar(firstRune(text[end:])) + + if startOk && endOk { + return true + } + idx = start + 1 + if idx >= len(text) { + return false + } + } +} + +func firstRune(text string) rune { + r, _ := utf8.DecodeRuneInString(text) + return r +} + +func lastRune(text string) rune { + r, _ := utf8.DecodeLastRuneInString(text) + return r +} + +func isWordChar(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' +} + +func countWordsNoAlloc(text string) int { + count := 0 + inWord := false + for _, r := range text { + if unicode.IsSpace(r) { + inWord = false + continue + } + if !inWord { + count++ + inWord = true + } + } + return count +} + +func scoreCount(count, capAt int) float64 { + if capAt <= 0 { + return 0.0 + } + return math.Min(1.0, float64(count)/float64(capAt)) +} + +func scoreOutputComplexity(signals textSignalCounts) float64 { + totalCount := signals.enumCount + signals.comprehensiveCount + signals.elaborationCount + if totalCount == 0 { + return 0.0 + } + + enumScore := math.Min(1.0, float64(signals.enumCount)) + compScore := math.Min(1.0, float64(signals.comprehensiveCount)) + elabScore := math.Min(1.0, float64(signals.elaborationCount)) + + rawScore := (enumScore * 0.4) + (compScore * 0.3) + (elabScore * 0.3) + if signals.limitingQualifierCount > 0 { + rawScore *= 0.3 + } + + return math.Min(1.0, rawScore) +} + +// scoreTokenCount scores based on word count of the text. +func scoreTokenCount(words int) float64 { + switch { + case words < 15: + return float64(words) / 15.0 * 0.3 + case words <= 400: + return 0.3 + float64(words-15)/385.0*0.4 + default: + extra := math.Min(0.3, float64(words-400)/600.0*0.3) + return 0.7 + extra + } +} + +func clamp(val, min, max float64) float64 { + if val < min { + return min + } + if val > max { + return max + } + return val +} diff --git a/plugins/governance/complexityextract.go b/plugins/governance/complexityextract.go new file mode 100644 index 00000000000..3516944ad3d --- /dev/null +++ b/plugins/governance/complexityextract.go @@ -0,0 +1,233 @@ +package governance + +import ( + "strings" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/plugins/governance/complexity" +) + +// buildComplexityInput extracts text from normalized BifrostRequest values for +// complexity_tier routing. It intentionally runs after the transport converters +// have produced Bifrost's typed request shape, so governance does not duplicate +// provider-specific raw payload parsing. +func buildComplexityInput(req *schemas.BifrostRequest) (complexity.ComplexityInput, bool) { + if req == nil { + return complexity.ComplexityInput{}, false + } + + switch req.RequestType { + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + if req.ChatRequest == nil { + return complexity.ComplexityInput{}, false + } + return extractFromChatMessages(req.ChatRequest.Input) + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + if req.TextCompletionRequest == nil { + return complexity.ComplexityInput{}, false + } + return extractFromTextCompletionRequest(req.TextCompletionRequest) + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + if req.ResponsesRequest == nil { + return complexity.ComplexityInput{}, false + } + return extractFromResponsesRequest(req.ResponsesRequest) + default: + return complexity.ComplexityInput{}, false + } +} + +// extractFromChatMessages builds a complexity input from chat messages by +// preserving system/developer context and tracking only text-only user turns. +func extractFromChatMessages(messages []schemas.ChatMessage) (complexity.ComplexityInput, bool) { + if len(messages) == 0 { + return complexity.ComplexityInput{}, false + } + + var input complexity.ComplexityInput + var userTexts []string + + for _, msg := range messages { + switch msg.Role { + case schemas.ChatMessageRoleSystem, schemas.ChatMessageRoleDeveloper: + input.SystemText = appendText(input.SystemText, extractChatText(msg.Content)) + case schemas.ChatMessageRoleUser: + text, ok := extractChatTextOnly(msg.Content) + if !ok || strings.TrimSpace(text) == "" { + return complexity.ComplexityInput{}, false + } + userTexts = append(userTexts, text) + } + } + + if len(userTexts) == 0 { + return complexity.ComplexityInput{}, false + } + + input.LastUserText = userTexts[len(userTexts)-1] + if len(userTexts) > 1 { + input.PriorUserTexts = userTexts[:len(userTexts)-1] + } + return input, true +} + +// extractFromTextCompletionRequest builds a complexity input from a single text +// completion prompt and deliberately skips batched prompt arrays. +func extractFromTextCompletionRequest(req *schemas.BifrostTextCompletionRequest) (complexity.ComplexityInput, bool) { + if req == nil || req.Input == nil || req.Input.PromptStr == nil || strings.TrimSpace(*req.Input.PromptStr) == "" { + return complexity.ComplexityInput{}, false + } + + // PromptArray represents batched completions, not one logical prompt. Do not + // synthesize a single routing input by joining unrelated batch entries. + return complexity.ComplexityInput{LastUserText: *req.Input.PromptStr}, true +} + +// extractFromResponsesRequest builds a complexity input from Responses API +// messages while combining instructions with system/developer message text. +func extractFromResponsesRequest(req *schemas.BifrostResponsesRequest) (complexity.ComplexityInput, bool) { + if req == nil || len(req.Input) == 0 { + return complexity.ComplexityInput{}, false + } + + var input complexity.ComplexityInput + if req.Params != nil && req.Params.Instructions != nil { + input.SystemText = *req.Params.Instructions + } + + var userTexts []string + for _, msg := range req.Input { + if msg.Role == nil { + continue + } + + switch *msg.Role { + case schemas.ResponsesInputMessageRoleSystem, schemas.ResponsesInputMessageRoleDeveloper: + input.SystemText = appendText(input.SystemText, extractResponsesText(msg.Content)) + case schemas.ResponsesInputMessageRoleUser: + text, ok := extractResponsesTextOnly(msg.Content) + if !ok || strings.TrimSpace(text) == "" { + return complexity.ComplexityInput{}, false + } + userTexts = append(userTexts, text) + } + } + + if len(userTexts) == 0 { + return complexity.ComplexityInput{}, false + } + + input.LastUserText = userTexts[len(userTexts)-1] + if len(userTexts) > 1 { + input.PriorUserTexts = userTexts[:len(userTexts)-1] + } + return input, true +} + +// extractChatText returns the text portions of chat content and ignores +// non-text blocks so system/developer context can still be used. +func extractChatText(content *schemas.ChatMessageContent) string { + if content == nil { + return "" + } + if content.ContentStr != nil { + return *content.ContentStr + } + + var text string + for _, block := range content.ContentBlocks { + if isChatTextBlock(block) && block.Text != nil && *block.Text != "" { + text = appendText(text, *block.Text) + } + } + return text +} + +// extractChatTextOnly returns chat content only when every block is text, +// allowing mixed-modality user prompts to opt out of complexity routing. +func extractChatTextOnly(content *schemas.ChatMessageContent) (string, bool) { + if content == nil { + return "", false + } + if content.ContentStr != nil { + return *content.ContentStr, true + } + if len(content.ContentBlocks) == 0 { + return "", false + } + + var text string + for _, block := range content.ContentBlocks { + if !isChatTextBlock(block) || block.Text == nil || *block.Text == "" { + return "", false + } + text = appendText(text, *block.Text) + } + return text, true +} + +// extractResponsesText returns the text portions of Responses content and +// ignores non-input-text blocks used by non-user context. +func extractResponsesText(content *schemas.ResponsesMessageContent) string { + if content == nil { + return "" + } + if content.ContentStr != nil { + return *content.ContentStr + } + + var text string + for _, block := range content.ContentBlocks { + if isResponsesInputTextBlock(block) && block.Text != nil && *block.Text != "" { + text = appendText(text, *block.Text) + } + } + return text +} + +// extractResponsesTextOnly returns Responses content only when every block is +// input text, avoiding synthesized prompts for mixed-modality user requests. +func extractResponsesTextOnly(content *schemas.ResponsesMessageContent) (string, bool) { + if content == nil { + return "", false + } + if content.ContentStr != nil { + return *content.ContentStr, true + } + if len(content.ContentBlocks) == 0 { + return "", false + } + + var text string + for _, block := range content.ContentBlocks { + if !isResponsesInputTextBlock(block) || block.Text == nil || *block.Text == "" { + return "", false + } + text = appendText(text, *block.Text) + } + return text, true +} + +// isChatTextBlock reports whether a chat content block is plain text, treating +// an empty type as text for compatibility with normalized request payloads. +func isChatTextBlock(block schemas.ChatContentBlock) bool { + return block.Type == "" || block.Type == schemas.ChatContentBlockTypeText +} + +// isResponsesInputTextBlock reports whether a Responses content block is input +// text, treating an empty type as text for compatibility with normalized input. +func isResponsesInputTextBlock(block schemas.ResponsesMessageContentBlock) bool { + return block.Type == "" || block.Type == schemas.ResponsesInputMessageContentBlockTypeText +} + +// appendText joins adjacent text fragments with one separating space while +// preserving empty existing or next values. +func appendText(existing, next string) string { + if next == "" { + return existing + } + if existing == "" { + return next + } + return existing + " " + next +} diff --git a/plugins/governance/complexityextract_test.go b/plugins/governance/complexityextract_test.go new file mode 100644 index 00000000000..126cda3741f --- /dev/null +++ b/plugins/governance/complexityextract_test.go @@ -0,0 +1,287 @@ +package governance + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestBuildComplexityInput_ChatTextMessages(t *testing.T) { + req := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleSystem, + Content: complexityChatString("Be concise"), + }, + { + Role: schemas.ChatMessageRoleUser, + Content: complexityChatString("Explain vector clocks"), + }, + { + Role: schemas.ChatMessageRoleAssistant, + Content: complexityChatString("Vector clocks track causal history."), + }, + { + Role: schemas.ChatMessageRoleUser, + Content: complexityChatBlocks( + complexityChatTextBlock("Compare them to Lamport clocks"), + ), + }, + }, + }, + } + + input, ok := buildComplexityInput(req) + require.True(t, ok) + assert.Equal(t, "Compare them to Lamport clocks", input.LastUserText) + assert.Equal(t, []string{"Explain vector clocks"}, input.PriorUserTexts) + assert.Equal(t, "Be concise", input.SystemText) +} + +func TestBuildComplexityInput_TextCompletionPrompt(t *testing.T) { + prompt := "Write a short summary of this changelog" + req := &schemas.BifrostRequest{ + RequestType: schemas.TextCompletionRequest, + TextCompletionRequest: &schemas.BifrostTextCompletionRequest{ + Input: &schemas.TextCompletionInput{PromptStr: &prompt}, + }, + } + + input, ok := buildComplexityInput(req) + require.True(t, ok) + assert.Equal(t, prompt, input.LastUserText) +} + +func TestBuildComplexityInput_TextCompletionPromptArraySkipped(t *testing.T) { + req := &schemas.BifrostRequest{ + RequestType: schemas.TextCompletionRequest, + TextCompletionRequest: &schemas.BifrostTextCompletionRequest{ + Input: &schemas.TextCompletionInput{ + PromptArray: []string{ + "Summarize this short changelog", + "Debug this distributed tracing timeout and propose fixes", + }, + }, + }, + } + + input, ok := buildComplexityInput(req) + require.False(t, ok) + assert.Empty(t, input.LastUserText) +} + +func TestBuildComplexityInput_ResponsesInputTextBlocks(t *testing.T) { + systemRole := schemas.ResponsesInputMessageRoleSystem + userRole := schemas.ResponsesInputMessageRoleUser + assistantRole := schemas.ResponsesInputMessageRoleAssistant + instructions := "Review carefully" + + req := &schemas.BifrostRequest{ + RequestType: schemas.ResponsesRequest, + ResponsesRequest: &schemas.BifrostResponsesRequest{ + Params: &schemas.ResponsesParameters{Instructions: &instructions}, + Input: []schemas.ResponsesMessage{ + { + Role: &systemRole, + Content: complexityResponsesString("Be concise"), + }, + { + Role: &userRole, + Content: complexityResponsesBlocks( + complexityResponsesTextBlock("I changed the retry policy and circuit breaker thresholds."), + ), + }, + { + Role: &assistantRole, + Content: complexityResponsesBlocks( + complexityResponsesOutputTextBlock("The patch retries idempotent requests and opens the breaker sooner."), + ), + }, + { + Role: &userRole, + Content: complexityResponsesBlocks( + complexityResponsesTextBlock("Can you explain the changes?"), + ), + }, + }, + }, + } + + input, ok := buildComplexityInput(req) + require.True(t, ok) + assert.Equal(t, "Can you explain the changes?", input.LastUserText) + assert.Equal(t, []string{"I changed the retry policy and circuit breaker thresholds."}, input.PriorUserTexts) + assert.Equal(t, "Review carefully Be concise", input.SystemText) +} + +func TestBuildComplexityInput_SupportsStreamingRequestTypes(t *testing.T) { + prompt := "Write a short summary of this changelog" + userRole := schemas.ResponsesInputMessageRoleUser + instructions := "Answer carefully" + + tests := []struct { + name string + req *schemas.BifrostRequest + wantLastUser string + wantSystem string + }{ + { + name: "chat_completion_stream", + req: &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionStreamRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Input: []schemas.ChatMessage{ + {Role: schemas.ChatMessageRoleSystem, Content: complexityChatString("Be concise")}, + {Role: schemas.ChatMessageRoleUser, Content: complexityChatString("Explain vector clocks")}, + }, + }, + }, + wantLastUser: "Explain vector clocks", + wantSystem: "Be concise", + }, + { + name: "text_completion_stream", + req: &schemas.BifrostRequest{ + RequestType: schemas.TextCompletionStreamRequest, + TextCompletionRequest: &schemas.BifrostTextCompletionRequest{ + Input: &schemas.TextCompletionInput{PromptStr: &prompt}, + }, + }, + wantLastUser: prompt, + }, + { + name: "responses_stream", + req: &schemas.BifrostRequest{ + RequestType: schemas.ResponsesStreamRequest, + ResponsesRequest: &schemas.BifrostResponsesRequest{ + Params: &schemas.ResponsesParameters{Instructions: &instructions}, + Input: []schemas.ResponsesMessage{ + { + Role: &userRole, + Content: complexityResponsesBlocks( + complexityResponsesTextBlock("Compare Go channels and mutexes"), + ), + }, + }, + }, + }, + wantLastUser: "Compare Go channels and mutexes", + wantSystem: "Answer carefully", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, ok := buildComplexityInput(tt.req) + require.True(t, ok) + assert.Equal(t, tt.wantLastUser, input.LastUserText) + assert.Equal(t, tt.wantSystem, input.SystemText) + }) + } +} + +func TestBuildComplexityInput_SkipsUnsupportedRequestTypesEvenWhenTextIsPresent(t *testing.T) { + userRole := schemas.ResponsesInputMessageRoleUser + req := &schemas.BifrostRequest{ + RequestType: schemas.CountTokensRequest, + CountTokensRequest: &schemas.BifrostResponsesRequest{ + Input: []schemas.ResponsesMessage{ + { + Role: &userRole, + Content: complexityResponsesBlocks( + complexityResponsesTextBlock("How many tokens is this prompt?"), + ), + }, + }, + }, + } + + input, ok := buildComplexityInput(req) + require.False(t, ok) + assert.Empty(t, input.LastUserText) +} + +func TestBuildComplexityInput_SkipsMixedModalityUserContent(t *testing.T) { + userRole := schemas.ResponsesInputMessageRoleUser + + tests := []struct { + name string + req *schemas.BifrostRequest + }{ + { + name: "chat_text_plus_image", + req: &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: complexityChatBlocks( + complexityChatTextBlock("What changed in this screenshot?"), + schemas.ChatContentBlock{Type: schemas.ChatContentBlockTypeImage}, + ), + }, + }, + }, + }, + }, + { + name: "responses_text_plus_file", + req: &schemas.BifrostRequest{ + RequestType: schemas.ResponsesRequest, + ResponsesRequest: &schemas.BifrostResponsesRequest{ + Input: []schemas.ResponsesMessage{ + { + Role: &userRole, + Content: complexityResponsesBlocks( + complexityResponsesTextBlock("Summarize this document"), + schemas.ResponsesMessageContentBlock{Type: schemas.ResponsesInputMessageContentBlockTypeFile}, + ), + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, ok := buildComplexityInput(tt.req) + require.False(t, ok) + assert.Empty(t, input.LastUserText) + }) + } +} + +func complexityChatString(text string) *schemas.ChatMessageContent { + return &schemas.ChatMessageContent{ContentStr: &text} +} + +func complexityChatBlocks(blocks ...schemas.ChatContentBlock) *schemas.ChatMessageContent { + return &schemas.ChatMessageContent{ContentBlocks: blocks} +} + +func complexityChatTextBlock(text string) schemas.ChatContentBlock { + return schemas.ChatContentBlock{Type: schemas.ChatContentBlockTypeText, Text: &text} +} + +func complexityResponsesString(text string) *schemas.ResponsesMessageContent { + return &schemas.ResponsesMessageContent{ContentStr: &text} +} + +func complexityResponsesBlocks(blocks ...schemas.ResponsesMessageContentBlock) *schemas.ResponsesMessageContent { + return &schemas.ResponsesMessageContent{ContentBlocks: blocks} +} + +func complexityResponsesTextBlock(text string) schemas.ResponsesMessageContentBlock { + return schemas.ResponsesMessageContentBlock{Type: schemas.ResponsesInputMessageContentBlockTypeText, Text: &text} +} + +func complexityResponsesOutputTextBlock(text string) schemas.ResponsesMessageContentBlock { + return schemas.ResponsesMessageContentBlock{Type: schemas.ResponsesOutputMessageContentTypeText, Text: &text} +} diff --git a/plugins/governance/main.go b/plugins/governance/main.go index 4874bcde7c6..fd76c0ab9b0 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" @@ -18,6 +19,7 @@ import ( configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" "github.com/maximhq/bifrost/framework/mcpcatalog" "github.com/maximhq/bifrost/framework/modelcatalog" + "github.com/maximhq/bifrost/plugins/governance/complexity" ) // PluginName is the name of the governance plugin @@ -84,6 +86,8 @@ type GovernancePlugin struct { requiredHeaders *[]string // pointer to live config slice; lowercased at check time isEnterprise bool disableAutoToolInject *bool + + complexityAnalyzer atomic.Pointer[complexity.ComplexityAnalyzer] } // Init initializes and returns a governance plugin instance. @@ -232,6 +236,7 @@ func Init( disableAutoToolInject: disableAutoToolInject, inMemoryStore: inMemoryStore, } + plugin.storeComplexityAnalyzerConfig(resolveAnalyzerConfigFromStoreOrArg(ctx, logger, configStore, governanceConfig)) return plugin, nil } @@ -326,6 +331,7 @@ func InitFromStore( isEnterprise: config != nil && config.IsEnterprise, disableAutoToolInject: disableAutoToolInject, } + plugin.storeComplexityAnalyzerConfig(resolveAnalyzerConfigFromStoreOrArg(ctx, logger, configStore, nil)) return plugin, nil } @@ -334,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() @@ -635,6 +687,37 @@ func (p *GovernancePlugin) applyRoutingRules(ctx *schemas.BifrostContext, req *s headers, _ := ctx.Value(schemas.BifrostContextKeyRequestHeaders).(map[string]string) queryParams, _ := ctx.Value(schemas.BifrostContextKeyRequestQuery).(map[string]string) + // Set up lazy complexity computation; only runs if a rule references complexity_tier. + var computeComplexity func() *complexity.ComplexityResult + if analyzer := p.complexityAnalyzer.Load(); analyzer != nil { + computeComplexity = func() *complexity.ComplexityResult { + input, ok := buildComplexityInput(req) + if !ok { + if p.logger != nil { + p.logger.Debug("[Governance] Complexity analysis skipped: unsupported request type") + } + ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, schemas.LogLevelInfo, "Complexity analysis skipped: no supported text-bearing input detected") + return nil + } + + result := analyzer.Analyze(input) + if p.logger != nil { + p.logger.Debug( + "[Governance] Complexity analysis details: tier=%s score=%.2f words=%d", + result.Tier, + result.Score, + result.WordCount, + ) + } + ctx.AppendRoutingEngineLog( + schemas.RoutingEngineRoutingRule, + schemas.LogLevelInfo, + fmt.Sprintf("Complexity: tier=%s score=%.2f words=%d", result.Tier, result.Score, result.WordCount), + ) + return result + } + } + routingCtx := &RoutingContext{ VirtualKey: virtualKey, Provider: provider, @@ -643,6 +726,7 @@ func (p *GovernancePlugin) applyRoutingRules(ctx *schemas.BifrostContext, req *s Headers: headers, QueryParams: queryParams, BudgetAndRateLimitStatus: p.store.GetBudgetAndRateLimitStatus(ctx, model, provider, virtualKey, nil, nil, nil), + computeComplexity: computeComplexity, } p.logger.Debug("[PreRequestHook] Built routing context: provider=%s, model=%s, requestType=%s, vk=%v", diff --git a/plugins/governance/prerequesthookcomplexity_test.go b/plugins/governance/prerequesthookcomplexity_test.go new file mode 100644 index 00000000000..b8b17e115d3 --- /dev/null +++ b/plugins/governance/prerequesthookcomplexity_test.go @@ -0,0 +1,132 @@ +package governance + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" +) + +func TestPreRequestHook_ComplexityAnalyzerFeedsCELVariable(t *testing.T) { + logger := NewMockLogger() + provider := "openai" + model := "gpt-4o-mini" + + plugin, err := Init( + context.Background(), + &Config{IsVkMandatory: boolPtr(false)}, + logger, + nil, + &configstore.GovernanceConfig{ + RoutingRules: []configstoreTables.TableRoutingRule{ + { + ID: "rule-1", + Name: "Complexity Available", + CelExpression: `complexity_tier != ""`, + Targets: []configstoreTables.TableRoutingTarget{ + {Provider: &provider, Model: &model, Weight: 1.0}, + }, + Enabled: schemas.Ptr(true), + Scope: "global", + Priority: 0, + }, + }, + }, + nil, + nil, + nil, + ) + require.NoError(t, err) + defer func() { + require.NoError(t, plugin.Cleanup()) + }() + + req := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: complexityChatString("What is a vector database?"), + }, + }, + }, + } + + bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + require.NoError(t, plugin.PreRequestHook(bfCtx, req)) + + engines, ok := bfCtx.Value(schemas.BifrostContextKeyRoutingEnginesUsed).([]string) + require.True(t, ok, "routing engines used should be tracked") + require.Contains(t, engines, schemas.RoutingEngineRoutingRule) + + providerOut, modelOut, _ := req.GetRequestFields() + require.Equal(t, schemas.OpenAI, providerOut) + require.Equal(t, "gpt-4o-mini", modelOut) +} + +func TestPreRequestHook_ComplexitySkippedWhenNoRulesReferenceIt(t *testing.T) { + logger := NewMockLogger() + provider := "openai" + model := "gpt-4o-mini" + + plugin, err := Init( + context.Background(), + &Config{IsVkMandatory: boolPtr(false)}, + logger, + nil, + &configstore.GovernanceConfig{ + RoutingRules: []configstoreTables.TableRoutingRule{ + { + ID: "rule-1", + Name: "Always match", + CelExpression: "true", + Targets: []configstoreTables.TableRoutingTarget{ + {Provider: &provider, Model: &model, Weight: 1.0}, + }, + Enabled: schemas.Ptr(true), + Scope: "global", + Priority: 0, + }, + }, + }, + nil, + nil, + nil, + ) + require.NoError(t, err) + defer func() { + require.NoError(t, plugin.Cleanup()) + }() + + req := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: complexityChatString("Hello"), + }, + }, + }, + } + + bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + require.NoError(t, plugin.PreRequestHook(bfCtx, req)) + + logs := bfCtx.GetRoutingEngineLogs() + for _, entry := range logs { + if entry.Engine == schemas.RoutingEngineRoutingRule && strings.Contains(entry.Message, "Complexity") { + t.Fatalf("expected no complexity logs when no rules reference complexity_tier, got: %s", entry.Message) + } + } +} diff --git a/plugins/governance/routing.go b/plugins/governance/routing.go index bfccd8ec4f8..0853a5b3339 100644 --- a/plugins/governance/routing.go +++ b/plugins/governance/routing.go @@ -8,8 +8,10 @@ import ( "sync" "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" "github.com/maximhq/bifrost/core/schemas" configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/plugins/governance/complexity" ) // DefaultRoutingChainMaxDepth is the default maximum depth for routing rule chain evaluation. @@ -35,14 +37,15 @@ type RoutingDecision struct { // RoutingContext holds all data needed for routing rule evaluation // Reuses existing configstore table types for VirtualKey, Team, Customer type RoutingContext struct { - VirtualKey *configstoreTables.TableVirtualKey // nil if no VK - Provider schemas.ModelProvider // Current provider - Model string // Current model - RequestType string // Normalized request type (e.g., "chat_completion", "embedding") from HTTP context - Fallbacks []string // Fallback chain: ["provider/model", ...] - Headers map[string]string // Request headers for dynamic routing - QueryParams map[string]string // Query parameters for dynamic routing - BudgetAndRateLimitStatus *BudgetAndRateLimitStatus // Budget and rate limit status by provider/model + VirtualKey *configstoreTables.TableVirtualKey // nil if no VK + Provider schemas.ModelProvider // Current provider + Model string // Current model + RequestType string // Normalized request type (e.g., "chat_completion", "embedding") from HTTP context + Fallbacks []string // Fallback chain: ["provider/model", ...] + Headers map[string]string // Request headers for dynamic routing + QueryParams map[string]string // Query parameters for dynamic routing + BudgetAndRateLimitStatus *BudgetAndRateLimitStatus // Budget and rate limit status by provider/model + computeComplexity func() *complexity.ComplexityResult // Lazy complexity computation; called at most once when a rule references "complexity_tier" } type RoutingEngine struct { @@ -122,6 +125,8 @@ func (re *RoutingEngine) EvaluateRoutingRules(ctx *schemas.BifrostContext, routi ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, schemas.LogLevelInfo, fmt.Sprintf("Scope chain: %v", scopeChainToStrings(scopeChain))) var finalDecision *RoutingDecision + var complexityResult *complexity.ComplexityResult + computeComplexity := routingCtx.computeComplexity for chainStep := 0; ; chainStep++ { // TERMINATION 4: Chain exceeded configured max depth. @@ -150,6 +155,9 @@ func (re *RoutingEngine) EvaluateRoutingRules(ctx *schemas.BifrostContext, routi ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, schemas.LogLevelError, fmt.Sprintf("Failed to extract routing variables: %v", err)) return nil, fmt.Errorf("failed to extract routing variables: %w", err) } + if complexityResult != nil { + variables["complexity_tier"] = complexityResult.Tier + } re.logger.Debug("[RoutingEngine] Chain Step: %d", chainStep) @@ -180,6 +188,17 @@ func (re *RoutingEngine) EvaluateRoutingRules(ctx *schemas.BifrostContext, routi } re.logger.Debug("[RoutingEngine] Evaluating rule: name=%s, expression=%s", rule.Name, rule.CelExpression) + referencesComplexity := celExpressionReferencesIdentifier(rule.CelExpression, "complexity_tier") + + // Lazy complexity: compute only when a rule references complexity and it hasn't been computed yet + if complexityResult == nil && computeComplexity != nil && referencesComplexity { + complexityResult = computeComplexity() + computeComplexity = nil // compute at most once + if complexityResult != nil { + variables["complexity_tier"] = complexityResult.Tier + } + } + program, err := re.store.GetRoutingProgram(ctx, rule) if err != nil { re.logger.Warn("[RoutingEngine] Failed to compile rule %s: %v", rule.Name, err) @@ -187,7 +206,12 @@ func (re *RoutingEngine) EvaluateRoutingRules(ctx *schemas.BifrostContext, routi continue } - matched, err := evaluateCELExpression(program, variables) + var unknowns []*cel.AttributePatternType + if referencesComplexity && complexityResult == nil { + unknowns = append(unknowns, cel.AttributePattern("complexity_tier")) + } + + matched, err := evaluateCELExpression(program, variables, unknowns...) if err != nil { re.logger.Warn("[RoutingEngine] Failed to evaluate rule %s: %v", rule.Name, err) ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, schemas.LogLevelError, fmt.Sprintf("Rule '%s' skipped: eval error: %v", rule.Name, err)) @@ -369,13 +393,22 @@ func buildScopeChain(virtualKey *configstoreTables.TableVirtualKey) []ScopeLevel } // evaluateCELExpression evaluates a compiled CEL program with given variables -func evaluateCELExpression(program cel.Program, variables map[string]any) (bool, error) { +func evaluateCELExpression(program cel.Program, variables map[string]any, unknowns ...*cel.AttributePatternType) (bool, error) { if program == nil { return false, fmt.Errorf("CEL program is nil") } + activation := any(variables) + if len(unknowns) > 0 { + partial, err := cel.PartialVars(variables, unknowns...) + if err != nil { + return false, fmt.Errorf("CEL partial activation error: %w", err) + } + activation = partial + } + // Evaluate the program - out, _, err := program.Eval(variables) + out, _, err := program.Eval(activation) if err != nil { // Gracefully handle "no such key" errors - when a header/param is missing, treat as non-match if strings.Contains(err.Error(), "no such key") { @@ -384,6 +417,13 @@ func evaluateCELExpression(program cel.Program, variables map[string]any) (bool, return false, fmt.Errorf("CEL evaluation error: %w", err) } + // Unknown means the expression depends on a value that is unavailable for + // this request. For routing safety, treat it as a no-match rather than + // allowing sentinels like complexity_tier == "" to leak into product logic. + if types.IsUnknown(out) { + return false, nil + } + // Convert result to boolean matched, ok := out.Value().(bool) if !ok { @@ -474,6 +514,11 @@ func extractRoutingVariables(ctx *RoutingContext) (map[string]interface{}, error variables["request"] = 0.0 } + // Placeholder only: EvaluateRoutingRules fills this lazily when a rule + // actually references complexity_tier. If complexity is unavailable, it is + // evaluated as a CEL unknown so negative predicates do not accidentally match. + variables["complexity_tier"] = "" + return variables, nil } @@ -576,5 +621,9 @@ func createCELEnvironment() (*cel.Env, error) { cel.Variable("tokens_used", cel.DoubleType), cel.Variable("request", cel.DoubleType), cel.Variable("budget_used", cel.DoubleType), + + // Complexity tier. When analysis is unavailable, evaluation marks this + // variable as CEL unknown so complexity-dependent predicates do not match. + cel.Variable("complexity_tier", cel.StringType), ) } diff --git a/plugins/governance/routingcelrefs.go b/plugins/governance/routingcelrefs.go new file mode 100644 index 00000000000..06a011533da --- /dev/null +++ b/plugins/governance/routingcelrefs.go @@ -0,0 +1,147 @@ +package governance + +import ( + "sync" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common" + celast "github.com/google/cel-go/common/ast" + "github.com/google/cel-go/parser" +) + +// Most routing variables are cheap: createCELEnvironment declares them, +// extractRoutingVariables populates them, and evaluateCELExpression passes them +// to CEL for evaluation. complexity_tier is different because populating it +// means extracting text from the request body and running the complexity +// analyzer (we dont have the value yet without these steps). Keep that work lazy by +// first checking whether a CEL rule actually references the identifier. + +// Walk the parsed CEL AST instead of using strings.Contains so string literals +// like "complexity_tier" and scoped macro variables do not accidentally trigger +// analysis. The same check is used during program compilation so only +// complexity-aware rules enable partial evaluation for the unavailable/unknown +// complexity_tier path. + +var celExpressionIdentifierRefCache sync.Map + +func celExpressionReferencesIdentifier(expr string, identifier string) bool { + if expr == "" || identifier == "" { + return false + } + + cacheKey := identifier + "\x00" + expr + if cached, ok := celExpressionIdentifierRefCache.Load(cacheKey); ok { + if result, ok := cached.(bool); ok { + return result + } + } + + result := false + p, err := parser.NewParser(parser.Macros(parser.AllMacros...)) + if err != nil { + celExpressionIdentifierRefCache.Store(cacheKey, result) + return result + } + + parsed, errs := p.Parse(common.NewTextSource(expr)) + if errs != nil && len(errs.GetErrors()) > 0 { + celExpressionIdentifierRefCache.Store(cacheKey, result) + return result + } + if parsed != nil { + result = celExprReferencesIdentifier(parsed.Expr(), identifier, nil) + } + + celExpressionIdentifierRefCache.Store(cacheKey, result) + return result +} + +func celASTReferencesIdentifier(ast *cel.Ast, identifier string) bool { + if ast == nil || ast.NativeRep() == nil || identifier == "" { + return false + } + return celExprReferencesIdentifier(ast.NativeRep().Expr(), identifier, nil) +} + +func celExprReferencesIdentifier(expr celast.Expr, identifier string, scopedIdents map[string]int) bool { + if expr == nil { + return false + } + + switch expr.Kind() { + case celast.IdentKind: + return expr.AsIdent() == identifier && scopedIdents[identifier] == 0 + case celast.CallKind: + call := expr.AsCall() + if celExprReferencesIdentifier(call.Target(), identifier, scopedIdents) { + return true + } + for _, arg := range call.Args() { + if celExprReferencesIdentifier(arg, identifier, scopedIdents) { + return true + } + } + case celast.ComprehensionKind: + comp := expr.AsComprehension() + if celExprReferencesIdentifier(comp.IterRange(), identifier, scopedIdents) { + return true + } + + scoped := addScopedCELIdentifiers(scopedIdents, comp.IterVar(), comp.IterVar2(), comp.AccuVar()) + if celExprReferencesIdentifier(comp.AccuInit(), identifier, scoped) { + return true + } + if celExprReferencesIdentifier(comp.LoopCondition(), identifier, scoped) { + return true + } + if celExprReferencesIdentifier(comp.LoopStep(), identifier, scoped) { + return true + } + if celExprReferencesIdentifier(comp.Result(), identifier, scoped) { + return true + } + case celast.ListKind: + for _, elem := range expr.AsList().Elements() { + if celExprReferencesIdentifier(elem, identifier, scopedIdents) { + return true + } + } + case celast.MapKind: + for _, entry := range expr.AsMap().Entries() { + if entry.Kind() != celast.MapEntryKind { + continue + } + mapEntry := entry.AsMapEntry() + if celExprReferencesIdentifier(mapEntry.Key(), identifier, scopedIdents) || + celExprReferencesIdentifier(mapEntry.Value(), identifier, scopedIdents) { + return true + } + } + case celast.SelectKind: + return celExprReferencesIdentifier(expr.AsSelect().Operand(), identifier, scopedIdents) + case celast.StructKind: + for _, field := range expr.AsStruct().Fields() { + if field.Kind() != celast.StructFieldKind { + continue + } + if celExprReferencesIdentifier(field.AsStructField().Value(), identifier, scopedIdents) { + return true + } + } + } + + return false +} + +func addScopedCELIdentifiers(parent map[string]int, identifiers ...string) map[string]int { + scoped := make(map[string]int, len(parent)+len(identifiers)) + for identifier, count := range parent { + scoped[identifier] = count + } + for _, identifier := range identifiers { + if identifier != "" { + scoped[identifier]++ + } + } + return scoped +} diff --git a/plugins/governance/routingcomplexity_test.go b/plugins/governance/routingcomplexity_test.go new file mode 100644 index 00000000000..b793ae32773 --- /dev/null +++ b/plugins/governance/routingcomplexity_test.go @@ -0,0 +1,385 @@ +package governance + +import ( + "context" + "testing" + "time" + + "github.com/google/cel-go/cel" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/plugins/governance/complexity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCELExpressionReferencesComplexityTierIdentifierOnly(t *testing.T) { + tests := []struct { + name string + expression string + expected bool + }{ + { + name: "direct identifier", + expression: `complexity_tier == "SIMPLE"`, + expected: true, + }, + { + name: "identifier in in-list", + expression: `complexity_tier in ["COMPLEX", "REASONING"]`, + expected: true, + }, + { + name: "string literal only", + expression: `model == "complexity_tier"`, + expected: false, + }, + { + name: "unrelated identifier containing name", + expression: `my_complexity_tier == true`, + expected: false, + }, + { + name: "map key string", + expression: `headers["complexity_tier"] == "SIMPLE"`, + expected: false, + }, + { + name: "field selection", + expression: `metadata.complexity_tier == "SIMPLE"`, + expected: false, + }, + { + name: "comprehension local shadows identifier", + expression: `["SIMPLE"].exists(complexity_tier, complexity_tier == "SIMPLE")`, + expected: false, + }, + { + name: "comprehension references outer identifier", + expression: `["SIMPLE"].exists(tier, complexity_tier == tier)`, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, celExpressionReferencesIdentifier(tt.expression, "complexity_tier")) + }) + } +} + +// TestCELComplexityTierVariable proves that CEL supports the flat complexity_tier string variable. +// This is the foundation for expressions like complexity_tier == "COMPLEX". +func TestCELComplexityTierVariable(t *testing.T) { + env, err := cel.NewEnv( + cel.Variable("complexity_tier", cel.StringType), + ) + require.NoError(t, err, "failed to create CEL environment") + + tests := []struct { + name string + expression string + variables map[string]interface{} + expected bool + }{ + { + name: "tier equals COMPLEX", + expression: `complexity_tier == "COMPLEX"`, + variables: map[string]interface{}{ + "complexity_tier": "COMPLEX", + }, + expected: true, + }, + { + name: "tier equals SIMPLE", + expression: `complexity_tier == "SIMPLE"`, + variables: map[string]interface{}{ + "complexity_tier": "SIMPLE", + }, + expected: true, + }, + { + name: "tier equals REASONING", + expression: `complexity_tier == "REASONING"`, + variables: map[string]interface{}{ + "complexity_tier": "REASONING", + }, + expected: true, + }, + { + name: "tier mismatch", + expression: `complexity_tier == "COMPLEX"`, + variables: map[string]interface{}{ + "complexity_tier": "MEDIUM", + }, + expected: false, + }, + { + name: "tier not equals", + expression: `complexity_tier != "SIMPLE"`, + variables: map[string]interface{}{ + "complexity_tier": "COMPLEX", + }, + expected: true, + }, + { + name: "tier in list", + expression: `complexity_tier in ["COMPLEX", "REASONING"]`, + variables: map[string]interface{}{ + "complexity_tier": "COMPLEX", + }, + expected: true, + }, + { + name: "tier not in list", + expression: `!(complexity_tier in ["SIMPLE", "MEDIUM"])`, + variables: map[string]interface{}{ + "complexity_tier": "COMPLEX", + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ast, issues := env.Compile(tt.expression) + require.NoError(t, issues.Err(), "compilation failed for: %s", tt.expression) + + program, err := env.Program(ast) + require.NoError(t, err, "program creation failed for: %s", tt.expression) + + out, _, err := program.Eval(tt.variables) + require.NoError(t, err, "evaluation failed for: %s", tt.expression) + + result, ok := out.Value().(bool) + assert.True(t, ok, "expected boolean result") + assert.Equal(t, tt.expected, result, "unexpected result for: %s", tt.expression) + }) + } +} + +// TestCELComplexityWithFullEnvironment tests complexity_tier alongside all existing CEL variables. +func TestCELComplexityWithFullEnvironment(t *testing.T) { + env, err := createCELEnvironment() + require.NoError(t, err, "failed to create full CEL environment") + + expression := `complexity_tier == "SIMPLE" && budget_used > 60.0` + ast, issues := env.Compile(expression) + require.NoError(t, issues.Err(), "compilation failed") + + program, err := env.Program(ast) + require.NoError(t, err, "program creation failed") + + variables := map[string]interface{}{ + "model": "gpt-4o", + "provider": "openai", + "request_type": "chat_completion", + "headers": map[string]string{}, + "params": map[string]string{}, + "virtual_key_id": "", + "virtual_key_name": "", + "team_id": "", + "team_name": "", + "customer_id": "", + "customer_name": "", + "tokens_used": 0.0, + "request": 0.0, + "budget_used": 75.0, + "complexity_tier": "SIMPLE", + } + + out, _, err := program.Eval(variables) + require.NoError(t, err, "evaluation failed") + + result, ok := out.Value().(bool) + assert.True(t, ok, "expected boolean result") + assert.True(t, result, "expected complexity_tier == SIMPLE && budget_used > 60 to match") +} + +func TestEvaluateCELExpression_ComplexityTierUnknown(t *testing.T) { + tests := []struct { + name string + expression string + budgetUsed float64 + expected bool + }{ + { + name: "not equals depends on unavailable complexity", + expression: `complexity_tier != "SIMPLE"`, + expected: false, + }, + { + name: "not in depends on unavailable complexity", + expression: `!(complexity_tier in ["SIMPLE"])`, + expected: false, + }, + { + name: "or short-circuits when non-complexity side is true", + expression: `budget_used > 90.0 || complexity_tier != "SIMPLE"`, + budgetUsed: 95.0, + expected: true, + }, + { + name: "or is no match when only unavailable complexity can decide", + expression: `budget_used > 90.0 || complexity_tier != "SIMPLE"`, + budgetUsed: 40.0, + expected: false, + }, + } + + env, err := createCELEnvironment() + require.NoError(t, err) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ast, issues := env.Compile(tt.expression) + require.NoError(t, issues.Err()) + + program, err := env.Program(ast, cel.EvalOptions(cel.OptPartialEval)) + require.NoError(t, err) + + variables := complexityRoutingVariables() + variables["budget_used"] = tt.budgetUsed + + matched, err := evaluateCELExpression(program, variables, cel.AttributePattern("complexity_tier")) + require.NoError(t, err) + assert.Equal(t, tt.expected, matched) + }) + } +} + +func TestEvaluateRoutingRules_ComplexityUnavailableNegativePredicatesDoNotMatch(t *testing.T) { + tests := []struct { + name string + expression string + }{ + { + name: "not equals", + expression: `complexity_tier != "SIMPLE"`, + }, + { + name: "not in", + expression: `!(complexity_tier in ["SIMPLE"])`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + store, err := NewLocalGovernanceStore(ctx, NewMockLogger(), nil, &configstore.GovernanceConfig{}, nil) + require.NoError(t, err) + + rule := complexityRoutingRule("complexity-unavailable-"+tt.name, tt.expression) + require.NoError(t, store.UpdateRoutingRuleInMemory(ctx, rule)) + + engine, err := NewRoutingEngine(store, NewMockLogger(), schemas.Ptr(10)) + require.NoError(t, err) + + computeCalls := 0 + decision, err := engine.EvaluateRoutingRules(schemas.NewBifrostContext(ctx, time.Now()), &RoutingContext{ + Provider: schemas.OpenAI, + Model: "gpt-4o", + RequestType: "chat_completion", + computeComplexity: func() *complexity.ComplexityResult { + computeCalls++ + return nil + }, + }) + require.NoError(t, err) + + assert.Nil(t, decision) + assert.Equal(t, 1, computeCalls) + }) + } +} + +func TestEvaluateRoutingRules_ComplexityTierLiteralDoesNotComputeComplexity(t *testing.T) { + ctx := context.Background() + store, err := NewLocalGovernanceStore(ctx, NewMockLogger(), nil, &configstore.GovernanceConfig{}, nil) + require.NoError(t, err) + + rule := complexityRoutingRule("complexity-tier-literal", `model == "complexity_tier"`) + require.NoError(t, store.UpdateRoutingRuleInMemory(ctx, rule)) + + engine, err := NewRoutingEngine(store, NewMockLogger(), schemas.Ptr(10)) + require.NoError(t, err) + + computeCalls := 0 + decision, err := engine.EvaluateRoutingRules(schemas.NewBifrostContext(ctx, time.Now()), &RoutingContext{ + Provider: schemas.OpenAI, + Model: "complexity_tier", + RequestType: "chat_completion", + computeComplexity: func() *complexity.ComplexityResult { + computeCalls++ + return &complexity.ComplexityResult{Tier: "SIMPLE"} + }, + }) + require.NoError(t, err) + require.NotNil(t, decision) + + assert.Equal(t, 0, computeCalls) + assert.Equal(t, "anthropic", decision.Provider) + assert.Equal(t, "claude-3-5-sonnet", decision.Model) +} + +func TestEvaluateRoutingRules_ComplexityNegativePredicateMatchesAvailableTier(t *testing.T) { + ctx := context.Background() + store, err := NewLocalGovernanceStore(ctx, NewMockLogger(), nil, &configstore.GovernanceConfig{}, nil) + require.NoError(t, err) + + rule := complexityRoutingRule("complexity-available-not-simple", `complexity_tier != "SIMPLE"`) + require.NoError(t, store.UpdateRoutingRuleInMemory(ctx, rule)) + + engine, err := NewRoutingEngine(store, NewMockLogger(), schemas.Ptr(10)) + require.NoError(t, err) + + decision, err := engine.EvaluateRoutingRules(schemas.NewBifrostContext(ctx, time.Now()), &RoutingContext{ + Provider: schemas.OpenAI, + Model: "gpt-4o", + RequestType: "chat_completion", + computeComplexity: func() *complexity.ComplexityResult { + return &complexity.ComplexityResult{Tier: "COMPLEX"} + }, + }) + require.NoError(t, err) + require.NotNil(t, decision) + assert.Equal(t, "anthropic", decision.Provider) + assert.Equal(t, "claude-3-5-sonnet", decision.Model) +} + +func complexityRoutingVariables() map[string]interface{} { + return map[string]interface{}{ + "model": "gpt-4o", + "provider": "openai", + "request_type": "chat_completion", + "headers": map[string]string{}, + "params": map[string]string{}, + "virtual_key_id": "", + "virtual_key_name": "", + "team_id": "", + "team_name": "", + "customer_id": "", + "customer_name": "", + "tokens_used": 0.0, + "request": 0.0, + "budget_used": 0.0, + "complexity_tier": "", + } +} + +func complexityRoutingRule(id string, expression string) *configstoreTables.TableRoutingRule { + provider := "anthropic" + model := "claude-3-5-sonnet" + return &configstoreTables.TableRoutingRule{ + ID: id, + Name: id, + Enabled: boolPtr(true), + CelExpression: expression, + Scope: "global", + Priority: 1, + Targets: []configstoreTables.TableRoutingTarget{ + {Provider: &provider, Model: &model, Weight: 1.0}, + }, + } +} diff --git a/plugins/governance/store.go b/plugins/governance/store.go index 612b7698b9b..c117d29948f 100644 --- a/plugins/governance/store.go +++ b/plugins/governance/store.go @@ -3730,8 +3730,16 @@ func (gs *LocalGovernanceStore) GetRoutingProgram(ctx context.Context, rule *con return nil, fmt.Errorf("CEL compile error: %s", issues.Err().Error()) } - // Create program - program, err := gs.routingCELEnv.Program(ast) + // Create program. Partial evaluation is only needed for complexity rules, + // where routing treats unavailable complexity_tier as unknown instead of + // leaking an empty-string sentinel. + var program cel.Program + var err error + if celASTReferencesIdentifier(ast, "complexity_tier") { + program, err = gs.routingCELEnv.Program(ast, cel.EvalOptions(cel.OptPartialEval)) + } else { + program, err = gs.routingCELEnv.Program(ast) + } if err != nil { return nil, fmt.Errorf("CEL program creation error: %w", err) } diff --git a/transports/bifrost-http/handlers/governance.go b/transports/bifrost-http/handlers/governance.go index 52dd9a98b05..54d5f3f2224 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 da6db83dac4..44f082d94fb 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 ba141725a1e..87d988fc372 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 77d06923928..9c102fe533d 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 bfca02695a1..b6e177cdbe4 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 f78b7e30073..71b02613231 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.", diff --git a/ui/app/workspace/complexity-router/layout.tsx b/ui/app/workspace/complexity-router/layout.tsx new file mode 100644 index 00000000000..bc11a82657e --- /dev/null +++ b/ui/app/workspace/complexity-router/layout.tsx @@ -0,0 +1,16 @@ +import { NoPermissionView } from "@/components/noPermissionView"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { createFileRoute } from "@tanstack/react-router"; +import ComplexityRouterPage from "./page"; + +function RouteComponent() { + const hasRoutingRulesAccess = useRbac(RbacResource.RoutingRules, RbacOperation.View); + if (!hasRoutingRulesAccess) { + return ; + } + return ; +} + +export const Route = createFileRoute("/workspace/complexity-router")({ + component: RouteComponent, +}); diff --git a/ui/app/workspace/complexity-router/page.tsx b/ui/app/workspace/complexity-router/page.tsx new file mode 100644 index 00000000000..42025e8d4ab --- /dev/null +++ b/ui/app/workspace/complexity-router/page.tsx @@ -0,0 +1,587 @@ +import FullPageLoader from "@/components/fullPageLoader"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alertDialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scrollArea"; +import { TagInput } from "@/components/ui/tagInput"; +import { getErrorMessage } from "@/lib/store"; +import { + useGetComplexityAnalyzerConfigQuery, + useResetComplexityAnalyzerConfigMutation, + useUpdateComplexityAnalyzerConfigMutation, +} from "@/lib/store/apis/governanceApi"; +import { + AnalyzerConfig, + DEFAULT_TIER_BOUNDARIES, + KEYWORD_LIST_DEFINITIONS, + KeywordListKey, + TierBoundaries, +} from "@/lib/types/complexityRouter"; +import { cn } from "@/lib/utils"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ExternalLink, LoaderCircle, RotateCcw, Save } from "lucide-react"; +import { type ChangeEvent, type ClipboardEvent, type DragEvent, type KeyboardEvent, useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +type TierBoundaryKey = keyof TierBoundaries; + +const COMPLEXITY_ROUTER_DOCS_URL = "https://docs.getbifrost.ai/features/governance/complexity-router"; + +// Four progressive shades of --primary: faintest → full +const P1 = "color-mix(in oklch, var(--primary) 30%, transparent)"; +const P2 = "color-mix(in oklch, var(--primary) 55%, transparent)"; +const P3 = "color-mix(in oklch, var(--primary) 75%, transparent)"; +const P4 = "var(--primary)"; + +const TIER_PALETTE = { + simple: { color: P1, name: "SIMPLE" }, + medium: { color: P2, name: "MEDIUM" }, + complex: { color: P3, name: "COMPLEX" }, + reasoning: { color: P4, name: "REASONING" }, +} as const; + +interface BoundaryFieldConfig { + key: TierBoundaryKey; + label: string; + description: string; + fromTier: string; + toTier: string; + fromColor: string; + toColor: string; +} + +const BOUNDARY_FIELDS: BoundaryFieldConfig[] = [ + { + key: "simple_medium", + label: "Simple → Medium", + description: "Scores at or below this are classified as SIMPLE.", + fromTier: "SIMPLE", + toTier: "MEDIUM", + fromColor: P1, + toColor: P2, + }, + { + key: "medium_complex", + label: "Medium → Complex", + description: "Scores above simple_medium and at or below this are MEDIUM.", + fromTier: "MEDIUM", + toTier: "COMPLEX", + fromColor: P2, + toColor: P3, + }, + { + key: "complex_reasoning", + label: "Complex → Reasoning", + description: "Scores above this are REASONING. Everything in between is COMPLEX.", + fromTier: "COMPLEX", + toTier: "REASONING", + fromColor: P3, + toColor: P4, + }, +]; + +const boundaryField = z.number({ error: "Enter a number between 0 and 1" }).gt(0, "Must be greater than 0").lt(1, "Must be less than 1"); + +const analyzerConfigSchema = z.object({ + tier_boundaries: z + .object({ + simple_medium: boundaryField, + medium_complex: boundaryField, + complex_reasoning: boundaryField, + }) + .superRefine((data, ctx) => { + if (Number.isFinite(data.medium_complex) && Number.isFinite(data.simple_medium) && data.medium_complex <= data.simple_medium) { + ctx.addIssue({ code: "custom", message: "Must be greater than Simple → Medium", path: ["medium_complex"] }); + } + if ( + Number.isFinite(data.complex_reasoning) && + Number.isFinite(data.medium_complex) && + data.complex_reasoning <= data.medium_complex + ) { + ctx.addIssue({ code: "custom", message: "Must be greater than Medium → Complex", path: ["complex_reasoning"] }); + } + }), + keywords: z.object({ + simple_keywords: z.array(z.string()).min(1, "Simple keywords cannot be empty"), + code_keywords: z.array(z.string()).min(1, "Code keywords cannot be empty"), + technical_keywords: z.array(z.string()).min(1, "Technical keywords cannot be empty"), + reasoning_keywords: z.array(z.string()).min(1, "Reasoning keywords cannot be empty"), + }), +}); + +const DEFAULT_FORM_VALUES: AnalyzerConfig = { + tier_boundaries: { ...DEFAULT_TIER_BOUNDARIES }, + keywords: { + code_keywords: [], + reasoning_keywords: [], + technical_keywords: [], + simple_keywords: [], + }, +}; + +function boundaryValueAsNumber(value: unknown): number { + let numericValue = Number.NaN; + if (typeof value === "number") { + numericValue = value; + } else if (typeof value === "string" && value.trim() !== "") { + numericValue = Number(value); + } + return Number.isFinite(numericValue) ? Math.max(0, numericValue) : Number.NaN; +} + +function finiteBoundaryValue(value: number | undefined, fallback: number) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function clampUnit(value: number) { + return Math.min(1, Math.max(0, value)); +} + +function testIdPart(value: string) { + return value.replace(/_/g, "-"); +} + +function preventNegativeBoundaryKey(event: KeyboardEvent) { + if (event.key === "-") { + event.preventDefault(); + } +} + +function preventNegativeBoundaryPaste(event: ClipboardEvent) { + if (/^\s*-/.test(event.clipboardData.getData("text"))) { + event.preventDefault(); + } +} + +function preventNegativeBoundaryDrop(event: DragEvent) { + if (/^\s*-/.test(event.dataTransfer.getData("text"))) { + event.preventDefault(); + } +} + +function normalizeBoundaryInput(event: ChangeEvent) { + const { value } = event.currentTarget; + if (!/^\s*-/.test(value)) return; + + const numericValue = Number(value); + event.currentTarget.value = Number.isFinite(numericValue) ? "0" : ""; +} + +function TierSpectrumBar({ boundaries }: { boundaries: TierBoundaries }) { + const sm = clampUnit(finiteBoundaryValue(boundaries?.simple_medium, DEFAULT_TIER_BOUNDARIES.simple_medium)); + const mc = clampUnit(finiteBoundaryValue(boundaries?.medium_complex, DEFAULT_TIER_BOUNDARIES.medium_complex)); + const cr = clampUnit(finiteBoundaryValue(boundaries?.complex_reasoning, DEFAULT_TIER_BOUNDARIES.complex_reasoning)); + + const segments = [ + { tier: "SIMPLE", width: Math.max(0, sm * 100), color: TIER_PALETTE.simple.color }, + { tier: "MEDIUM", width: Math.max(0, (mc - sm) * 100), color: TIER_PALETTE.medium.color }, + { tier: "COMPLEX", width: Math.max(0, (cr - mc) * 100), color: TIER_PALETTE.complex.color }, + { tier: "REASONING", width: Math.max(0, (1 - cr) * 100), color: TIER_PALETTE.reasoning.color }, + ]; + + const markers = [ + { key: "simple-medium", pos: sm, value: sm.toFixed(2) }, + { key: "medium-complex", pos: mc, value: mc.toFixed(2) }, + { key: "complex-reasoning", pos: cr, value: cr.toFixed(2) }, + ]; + + return ( +
+
+ {segments.map(({ tier, width, color }) => ( +
+ {width > 7 && ( + + {tier} + + )} +
+ ))} + {/* Boundary dividers */} + {markers.map(({ key, pos }) => ( +
+ ))} +
+ {/* Axis labels */} +
+ 0 + {markers.map(({ key, pos, value }) => ( + + {value} + + ))} + 1 +
+
+ ); +} + +export default function ComplexityRouterPage() { + const canUpdate = useRbac(RbacResource.RoutingRules, RbacOperation.Update); + const { data, isLoading, isFetching, error, refetch } = useGetComplexityAnalyzerConfigQuery(); + const [updateConfig, { isLoading: isSaving }] = useUpdateComplexityAnalyzerConfigMutation(); + const [resetConfig, { isLoading: isResetting }] = useResetComplexityAnalyzerConfigMutation(); + + const [submitError, setSubmitError] = useState(null); + const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); + + const { + register, + handleSubmit, + reset, + control, + watch, + formState: { errors, isDirty, isSubmitted }, + } = useForm({ + resolver: zodResolver(analyzerConfigSchema), + defaultValues: DEFAULT_FORM_VALUES, + mode: "onSubmit", + reValidateMode: "onChange", + }); + + const liveBoundaries = watch("tier_boundaries"); + + useEffect(() => { + if (!data || isDirty) return; + reset(data); + setSubmitError(null); + }, [data, isDirty, reset]); + + const handleDiscard = () => { + if (data) reset(data); + setSubmitError(null); + }; + + const handleRestoreDefaults = () => { + if (!canUpdate) return; + setSubmitError(null); + resetConfig() + .unwrap() + .then((defaults) => { + reset(defaults); + toast.success("Reset to defaults", { position: "top-right" }); + }) + .catch((err) => { + setSubmitError(getErrorMessage(err)); + }); + }; + + const onValid = (values: AnalyzerConfig) => { + if (!canUpdate) return; + setSubmitError(null); + updateConfig(values) + .unwrap() + .then((res) => { + reset(res); + toast.success("Configuration saved", { position: "top-right" }); + }) + .catch((err) => { + setSubmitError(getErrorMessage(err)); + }); + }; + + if (isLoading && !data) { + return ; + } + + if (error && !data) { + return ( +
+

{getErrorMessage(error)}

+ +
+ ); + } + + if (!data) { + return ( +
+

No complexity router configuration is available.

+ +
+ ); + } + + const boundaryErrors = errors.tier_boundaries; + const keywordErrors = errors.keywords; + const hasErrors = Boolean(boundaryErrors || keywordErrors); + + return ( + +
+ {/* ── Page header ── */} +
+
+

Complexity Router

+

+ Tune how incoming requests are classified into four tiers. Thresholds and keyword lists feed the{" "} + complexity_tier field that routing rules can target. +

+
+ +
+ + {/* ── Complexity Spectrum ── */} +
+
+

Complexity Spectrum

+
+ {Object.values(TIER_PALETTE).map(({ color, name }) => ( +
+
+ {name} +
+ ))} +
+
+ +
+ + {/* ── Tier Boundaries ── */} +
+

Tier Boundaries

+ +
+ {BOUNDARY_FIELDS.map(({ key, label, description, fromTier, toTier, fromColor, toColor }) => { + const fieldError = boundaryErrors?.[key]; + const inputId = `boundary-${key}`; + const errorId = `${inputId}-error`; + const { onChange, ...boundaryInputProps } = register(`tier_boundaries.${key}`, { + required: "Enter a number between 0 and 1", + setValueAs: boundaryValueAsNumber, + validate: (value) => { + if (!Number.isFinite(value)) return "Enter a number between 0 and 1"; + if (value <= 0) return "Must be greater than 0"; + if (value >= 1) return "Must be less than 1"; + const { simple_medium, medium_complex } = liveBoundaries; + if (key === "medium_complex" && Number.isFinite(simple_medium) && value <= simple_medium) { + return "Must be greater than Simple → Medium"; + } + if (key === "complex_reasoning" && Number.isFinite(medium_complex) && value <= medium_complex) { + return "Must be greater than Medium → Complex"; + } + return true; + }, + deps: + key === "simple_medium" + ? ["tier_boundaries.medium_complex"] + : key === "medium_complex" + ? ["tier_boundaries.complex_reasoning"] + : undefined, + }); + + return ( +
+ {/* Tier transition label */} +
+ + {fromTier} + + + + {toTier} + +
+ + + { + normalizeBoundaryInput(event); + onChange(event); + }} + aria-invalid={fieldError ? true : undefined} + aria-describedby={fieldError ? errorId : undefined} + className={cn( + "h-11 text-center text-lg font-mono font-medium", + fieldError && "border-destructive focus-visible:ring-destructive", + )} + {...boundaryInputProps} + /> + + {fieldError ? ( +

+ {fieldError.message} +

+ ) : ( +

{description}

+ )} +
+ ); + })} +
+
+ + {/* ── Keyword Lists ── */} +
+
+

Keyword Lists

+ + Lowercased and deduplicated on save. Each list requires at least one entry. + +
+ +
+ {KEYWORD_LIST_DEFINITIONS.map(({ key, label, description }) => { + const fieldError = keywordErrors?.[key as KeywordListKey]; + const errorId = `keywords-${key}-error`; + return ( +
+ (value.length > 0 ? true : `${label} cannot be empty`) }} + render={({ field }) => ( +
+
+ {label} + + {field.value.length} {field.value.length === 1 ? "entry" : "entries"} + +
+

{description}

+ + {fieldError && ( +

+ {fieldError.message} +

+ )} +
+ )} + /> +
+ ); + })} +
+
+ + {/* ── Submit error ── */} + {submitError && ( +
+ {submitError} +
+ )} + + {/* ── Action footer ── */} +
+ + + +
+ + + + + + Restore defaults + + This will reset all tier boundaries and keyword lists to the factory defaults. Your current configuration will be lost. This + action cannot be undone. + + + + setRestoreDialogOpen(false)} + disabled={isResetting} + > + Cancel + + { + setRestoreDialogOpen(false); + handleRestoreDefaults(); + }} + disabled={!canUpdate || isResetting} + > + Restore defaults + + + + + + ); +} diff --git a/ui/components/sidebar.tsx b/ui/components/sidebar.tsx index 0b541c9ed55..704e26203c9 100644 --- a/ui/components/sidebar.tsx +++ b/ui/components/sidebar.tsx @@ -799,6 +799,13 @@ export default function AppSidebar() { description: "Intelligent routing rules", hasAccess: hasRoutingRulesAccess, }, + { + title: "Complexity Router", + url: "/workspace/complexity-router", + icon: Settings2Icon, + description: "Complexity tier routing", + hasAccess: hasRoutingRulesAccess, + }, { title: "Pricing Overrides", url: "/workspace/custom-pricing/overrides", diff --git a/ui/lib/config/celFieldsRouting.ts b/ui/lib/config/celFieldsRouting.ts index 4c432816b6a..24a1fc0e84a 100644 --- a/ui/lib/config/celFieldsRouting.ts +++ b/ui/lib/config/celFieldsRouting.ts @@ -4,6 +4,7 @@ */ import { getProviderLabel } from "@/lib/constants/logs"; +import { COMPLEXITY_TIER_VALUES } from "@/lib/types/complexityRouter"; export interface CELFieldDefinition { name: string; @@ -111,6 +112,16 @@ export const baseRoutingFields: CELFieldDefinition[] = [ defaultOperator: ">=", description: "Check budget usage as percentage. Checked against max of model and provider configs.", }, + { + name: "complexity_tier", + label: "Complexity Tier", + placeholder: "Select complexity tier", + inputType: "select", + valueEditorType: "select", + operators: ["=", "!=", "in", "notIn"], + defaultOperator: "=", + values: COMPLEXITY_TIER_VALUES.map((tier) => ({ name: tier, label: tier.charAt(0) + tier.slice(1).toLowerCase() })), + }, { name: "params", label: "Query Parameter", diff --git a/ui/lib/store/apis/baseApi.ts b/ui/lib/store/apis/baseApi.ts index 108fe3171ae..9486fbc1204 100644 --- a/ui/lib/store/apis/baseApi.ts +++ b/ui/lib/store/apis/baseApi.ts @@ -192,6 +192,7 @@ export const baseApi = createApi({ "MCPSessions", "MCPPerUserHeaderCredentials", "FeatureFlags", + "ComplexityAnalyzerConfig", ], endpoints: () => ({}), }); diff --git a/ui/lib/store/apis/governanceApi.ts b/ui/lib/store/apis/governanceApi.ts index 40daba01e4d..45106a00db8 100644 --- a/ui/lib/store/apis/governanceApi.ts +++ b/ui/lib/store/apis/governanceApi.ts @@ -39,6 +39,7 @@ import { UpdateVirtualKeyRequest, VirtualKey, } from "@/lib/types/governance"; +import { AnalyzerConfig } from "@/lib/types/complexityRouter"; import { baseApi } from "./baseApi"; type PricingOverrideQueryArgs = { @@ -825,6 +826,32 @@ export const governanceApi = baseApi.injectEndpoints({ } }, }), + + // Complexity Analyzer Config + getComplexityAnalyzerConfig: builder.query({ + query: () => ({ + url: "/governance/complexity-analyzer-config", + method: "GET", + }), + providesTags: ["ComplexityAnalyzerConfig"], + }), + + updateComplexityAnalyzerConfig: builder.mutation({ + query: (data) => ({ + url: "/governance/complexity-analyzer-config", + method: "PUT", + body: data, + }), + invalidatesTags: ["ComplexityAnalyzerConfig"], + }), + + resetComplexityAnalyzerConfig: builder.mutation({ + query: () => ({ + url: "/governance/complexity-analyzer-config/reset", + method: "POST", + }), + invalidatesTags: ["ComplexityAnalyzerConfig"], + }), }), }); @@ -888,6 +915,11 @@ export const { useUpdateProviderGovernanceMutation, useDeleteProviderGovernanceMutation, + // Complexity Analyzer Config + useGetComplexityAnalyzerConfigQuery, + useUpdateComplexityAnalyzerConfigMutation, + useResetComplexityAnalyzerConfigMutation, + // Lazy queries useLazyGetVirtualKeysQuery, useLazyGetVirtualKeyQuery, @@ -904,4 +936,4 @@ export const { useLazyGetGovernanceHealthQuery, useLazyGetModelConfigsQuery, useLazyGetProviderGovernanceQuery, -} = governanceApi; \ No newline at end of file +} = governanceApi; diff --git a/ui/lib/types/complexityRouter.ts b/ui/lib/types/complexityRouter.ts new file mode 100644 index 00000000000..9dcf143bd5a --- /dev/null +++ b/ui/lib/types/complexityRouter.ts @@ -0,0 +1,59 @@ +/** + * Complexity Router Type Definitions + * Mirrors the AnalyzerConfig shape exchanged with /api/governance/complexity. + */ + +export interface TierBoundaries { + simple_medium: number; + medium_complex: number; + complex_reasoning: number; +} + +export interface EditableKeywordConfig { + code_keywords: string[]; + reasoning_keywords: string[]; + technical_keywords: string[]; + simple_keywords: string[]; +} + +export interface AnalyzerConfig { + tier_boundaries: TierBoundaries; + keywords: EditableKeywordConfig; +} + +export type KeywordListKey = keyof EditableKeywordConfig; + +export const COMPLEXITY_TIER_VALUES = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] as const; + +export const KEYWORD_LIST_DEFINITIONS: Array<{ + key: KeywordListKey; + label: string; + description: string; +}> = [ + { + key: "simple_keywords", + label: "Simple keywords", + description: "Phrases that bias the request toward the SIMPLE tier (greetings, trivia, small talk).", + }, + { + key: "code_keywords", + label: "Code keywords", + description: "Signals that the request involves code, debugging, or programming artifacts.", + }, + { + key: "technical_keywords", + label: "Technical keywords", + description: "Architecture, infra, and operational terms that raise the complexity score.", + }, + { + key: "reasoning_keywords", + label: "Reasoning keywords", + description: "Strong reasoning triggers. Matching these phrases can override tier selection toward the REASONING tier.", + }, +]; + +export const DEFAULT_TIER_BOUNDARIES: TierBoundaries = { + simple_medium: 0.15, + medium_complex: 0.35, + complex_reasoning: 0.6, +};