From d166de7c2782f411e6ca53542ecc7f796c527d64 Mon Sep 17 00:00:00 2001 From: Dan Piths <85949566+danpiths@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:01:51 +0530 Subject: [PATCH] feat: add support for per-model and per-provider level budgeting and rate limiting in governance plugin --- framework/configstore/clientconfig.go | 14 +- framework/configstore/migrations.go | 116 ++- framework/configstore/rdb.go | 218 ++++- framework/configstore/store.go | 13 + framework/configstore/tables/modelconfig.go | 59 ++ framework/configstore/tables/provider.go | 18 + plugins/governance/main.go | 48 +- plugins/governance/resolver.go | 120 ++- plugins/governance/store.go | 860 +++++++++++++++++++- plugins/governance/tracker.go | 39 +- ui/package-lock.json | 114 +-- 11 files changed, 1457 insertions(+), 162 deletions(-) create mode 100644 framework/configstore/tables/modelconfig.go diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index 28c580ef58..46288f575d 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -757,10 +757,12 @@ type AuthConfig struct { type ConfigMap map[schemas.ModelProvider]ProviderConfig 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"` - 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"` + AuthConfig *AuthConfig `json:"auth_config,omitempty"` } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index f2a29ddcbc..57ba72a7f7 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -142,6 +142,12 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddDistributedLocksTable(ctx, db); err != nil { return err } + if err := migrationAddModelConfigTable(ctx, db); err != nil { + return err + } + if err := migrationAddProviderGovernanceColumns(ctx, db); err != nil { + return err + } return nil } @@ -1215,7 +1221,6 @@ func migrationAddEnabledColumnToKeyTable(ctx context.Context, db *gorm.DB) error if err := mg.AddColumn(&tables.TableKey{}, "enabled"); err != nil { return fmt.Errorf("failed to add enabled column: %w", err) } - } // Set default = true for existing rows if err := tx.Exec("UPDATE config_keys SET enabled = TRUE WHERE enabled IS NULL").Error; err != nil { @@ -2308,3 +2313,112 @@ func migrationAddDistributedLocksTable(ctx context.Context, db *gorm.DB) error { } return nil } + +// migrationAddModelConfigTable adds the governance_model_configs table +func migrationAddModelConfigTable(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_model_config_table", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasTable(&tables.TableModelConfig{}) { + if err := migrator.CreateTable(&tables.TableModelConfig{}); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropTable(&tables.TableModelConfig{}); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running add model config table migration: %s", err.Error()) + } + return nil +} + +// migrationAddProviderGovernanceColumns adds budget_id and rate_limit_id columns to config_providers table +func migrationAddProviderGovernanceColumns(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_provider_governance_columns", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + provider := &tables.TableProvider{} + + // Add budget_id column if it doesn't exist + if !migrator.HasColumn(provider, "budget_id") { + if err := migrator.AddColumn(provider, "budget_id"); err != nil { + return fmt.Errorf("failed to add budget_id column: %w", err) + } + } + // Create index for budget_id (outside HasColumn to handle reruns where column exists but index doesn't) + if !migrator.HasIndex(provider, "idx_provider_budget") { + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_budget ON config_providers (budget_id)").Error; err != nil { + return fmt.Errorf("failed to create budget_id index: %w", err) + } + } + + // Add rate_limit_id column if it doesn't exist + if !migrator.HasColumn(provider, "rate_limit_id") { + if err := migrator.AddColumn(provider, "rate_limit_id"); err != nil { + return fmt.Errorf("failed to add rate_limit_id column: %w", err) + } + } + // Create index for rate_limit_id (outside HasColumn to handle reruns where column exists but index doesn't) + if !migrator.HasIndex(provider, "idx_provider_rate_limit") { + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_rate_limit ON config_providers (rate_limit_id)").Error; err != nil { + return fmt.Errorf("failed to create rate_limit_id index: %w", err) + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + provider := &tables.TableProvider{} + + // Drop indexes first + if migrator.HasIndex(provider, "idx_provider_rate_limit") { + if err := tx.Exec("DROP INDEX IF EXISTS idx_provider_rate_limit").Error; err != nil { + return fmt.Errorf("failed to drop rate_limit_id index: %w", err) + } + } + + if migrator.HasIndex(provider, "idx_provider_budget") { + if err := tx.Exec("DROP INDEX IF EXISTS idx_provider_budget").Error; err != nil { + return fmt.Errorf("failed to drop budget_id index: %w", err) + } + } + + // Drop rate_limit_id column if it exists + if migrator.HasColumn(provider, "rate_limit_id") { + if err := migrator.DropColumn(provider, "rate_limit_id"); err != nil { + return fmt.Errorf("failed to drop rate_limit_id column: %w", err) + } + } + + // Drop budget_id column if it exists + if migrator.HasColumn(provider, "budget_id") { + if err := migrator.DropColumn(provider, "budget_id"); err != nil { + return fmt.Errorf("failed to drop budget_id column: %w", err) + } + } + + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running add provider governance columns migration: %s", err.Error()) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 7339320154..6e53a5282f 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -605,6 +605,9 @@ func (s *RDBConfigStore) DeleteProvider(ctx context.Context, provider schemas.Mo return err } + // Store the budget and rate limit IDs before deleting + budgetID := dbProvider.BudgetID + rateLimitID := dbProvider.RateLimitID // Delete the provider first (keys will be deleted due to CASCADE constraint) if err := txDB.WithContext(ctx).Delete(&dbProvider).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -613,6 +616,19 @@ func (s *RDBConfigStore) DeleteProvider(ctx context.Context, provider schemas.Mo return err } + // Delete the budget if it exists + if budgetID != nil { + if err := txDB.WithContext(ctx).Delete(&tables.TableBudget{}, "id = ?", *budgetID).Error; err != nil { + return err + } + } + // Delete the rate limit if it exists + if rateLimitID != nil { + if err := txDB.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id = ?", *rateLimitID).Error; err != nil { + return err + } + } + return nil } @@ -699,6 +715,27 @@ func (s *RDBConfigStore) GetProviderConfig(ctx context.Context, provider schemas }, nil } +// GetProviders retrieves all providers from the database with their governance relationships. +func (s *RDBConfigStore) GetProviders(ctx context.Context) ([]tables.TableProvider, error) { + var providers []tables.TableProvider + if err := s.db.WithContext(ctx).Preload("Budget").Preload("RateLimit").Find(&providers).Error; err != nil { + return nil, err + } + return providers, nil +} + +// GetProviderByName retrieves a provider by name from the database with governance relationships. +func (s *RDBConfigStore) GetProviderByName(ctx context.Context, name string) (*tables.TableProvider, error) { + var provider tables.TableProvider + if err := s.db.WithContext(ctx).Preload("Budget").Preload("RateLimit").Where("name = ?", name).First(&provider).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &provider, nil +} + // GetMCPConfig retrieves the MCP configuration from the database. func (s *RDBConfigStore) GetMCPConfig(ctx context.Context) (*schemas.MCPConfig, error) { var dbMCPClients []tables.TableMCPClient @@ -1971,6 +2008,163 @@ func (s *RDBConfigStore) UpdateBudget(ctx context.Context, budget *tables.TableB return nil } +// UpdateBudgetUsage updates only the current_usage field of a budget. +// Uses SkipHooks to avoid triggering BeforeSave validation since we're only updating usage. +func (s *RDBConfigStore) UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64) error { + result := s.db.WithContext(ctx). + Session(&gorm.Session{SkipHooks: true}). + Model(&tables.TableBudget{}). + Where("id = ?", id). + Update("current_usage", currentUsage) + if result.Error != nil { + return s.parseGormError(result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +// UpdateRateLimitUsage updates only the usage fields of a rate limit. +// Uses SkipHooks to avoid triggering BeforeSave validation since we're only updating usage. +func (s *RDBConfigStore) UpdateRateLimitUsage(ctx context.Context, id string, tokenCurrentUsage int64, requestCurrentUsage int64) error { + result := s.db.WithContext(ctx). + Session(&gorm.Session{SkipHooks: true}). + Model(&tables.TableRateLimit{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "token_current_usage": tokenCurrentUsage, + "request_current_usage": requestCurrentUsage, + }) + if result.Error != nil { + return s.parseGormError(result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +// GetModelConfigs retrieves all model configs from the database. +func (s *RDBConfigStore) GetModelConfigs(ctx context.Context) ([]tables.TableModelConfig, error) { + var modelConfigs []tables.TableModelConfig + if err := s.db.WithContext(ctx).Preload("Budget").Preload("RateLimit").Find(&modelConfigs).Error; err != nil { + return nil, err + } + return modelConfigs, nil +} + +// GetModelConfig retrieves a specific model config from the database by model name and optional provider. +func (s *RDBConfigStore) GetModelConfig(ctx context.Context, modelName string, provider *string) (*tables.TableModelConfig, error) { + var modelConfig tables.TableModelConfig + query := s.db.WithContext(ctx).Where("model_name = ?", modelName) + if provider != nil { + query = query.Where("provider = ?", *provider) + } else { + query = query.Where("provider IS NULL") + } + if err := query.Preload("Budget").Preload("RateLimit").First(&modelConfig).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &modelConfig, nil +} + +// GetModelConfigByID retrieves a specific model config from the database by ID. +func (s *RDBConfigStore) GetModelConfigByID(ctx context.Context, id string) (*tables.TableModelConfig, error) { + var modelConfig tables.TableModelConfig + if err := s.db.WithContext(ctx).Preload("Budget").Preload("RateLimit").First(&modelConfig, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &modelConfig, nil +} + +// CreateModelConfig creates a new model config in the database. +func (s *RDBConfigStore) CreateModelConfig(ctx context.Context, modelConfig *tables.TableModelConfig, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + if err := txDB.WithContext(ctx).Create(modelConfig).Error; err != nil { + return s.parseGormError(err) + } + return nil +} + +// UpdateModelConfig updates a model config in the database. +func (s *RDBConfigStore) UpdateModelConfig(ctx context.Context, modelConfig *tables.TableModelConfig, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + if err := txDB.WithContext(ctx).Save(modelConfig).Error; err != nil { + return s.parseGormError(err) + } + return nil +} + +// UpdateModelConfigs updates multiple model configs in the database. +func (s *RDBConfigStore) UpdateModelConfigs(ctx context.Context, modelConfigs []*tables.TableModelConfig, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + for _, mc := range modelConfigs { + if err := txDB.WithContext(ctx).Save(mc).Error; err != nil { + return s.parseGormError(err) + } + } + return nil +} + +// DeleteModelConfig deletes a model config from the database. +func (s *RDBConfigStore) DeleteModelConfig(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // First fetch the model config to get budget and rate limit IDs + var modelConfig tables.TableModelConfig + if err := tx.First(&modelConfig, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + // Store the budget and rate limit IDs before deleting + budgetID := modelConfig.BudgetID + rateLimitID := modelConfig.RateLimitID + // Delete the model config first + if err := tx.Delete(&tables.TableModelConfig{}, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return s.parseGormError(err) + } + // Delete the budget if it exists + if budgetID != nil { + if err := tx.Delete(&tables.TableBudget{}, "id = ?", *budgetID).Error; err != nil { + return err + } + } + // Delete the rate limit if it exists + if rateLimitID != nil { + if err := tx.Delete(&tables.TableRateLimit{}, "id = ?", *rateLimitID).Error; err != nil { + return err + } + } + return nil + }) +} + // GetGovernanceConfig retrieves the governance configuration from the database. func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceConfig, error) { var virtualKeys []tables.TableVirtualKey @@ -1978,6 +2172,8 @@ func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceCo var customers []tables.TableCustomer var budgets []tables.TableBudget var rateLimits []tables.TableRateLimit + var modelConfigs []tables.TableModelConfig + var providers []tables.TableProvider var governanceConfigs []tables.TableGovernanceConfig if err := s.db.WithContext(ctx). @@ -2000,12 +2196,18 @@ func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceCo if err := s.db.WithContext(ctx).Find(&rateLimits).Error; err != nil { return nil, err } + if err := s.db.WithContext(ctx).Find(&modelConfigs).Error; err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).Find(&providers).Error; err != nil { + return nil, err + } // Fetching governance config for username and password if err := s.db.WithContext(ctx).Find(&governanceConfigs).Error; err != nil { return nil, err } // Check if any config is present - if len(virtualKeys) == 0 && len(teams) == 0 && len(customers) == 0 && len(budgets) == 0 && len(rateLimits) == 0 && len(governanceConfigs) == 0 { + if len(virtualKeys) == 0 && len(teams) == 0 && len(customers) == 0 && len(budgets) == 0 && len(rateLimits) == 0 && len(modelConfigs) == 0 && len(providers) == 0 && len(governanceConfigs) == 0 { return nil, nil } var authConfig *AuthConfig @@ -2033,12 +2235,14 @@ func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceCo } } return &GovernanceConfig{ - VirtualKeys: virtualKeys, - Teams: teams, - Customers: customers, - Budgets: budgets, - RateLimits: rateLimits, - AuthConfig: authConfig, + VirtualKeys: virtualKeys, + Teams: teams, + Customers: customers, + Budgets: budgets, + RateLimits: rateLimits, + ModelConfigs: modelConfigs, + Providers: providers, + AuthConfig: authConfig, }, nil } diff --git a/framework/configstore/store.go b/framework/configstore/store.go index 1f2906c4ce..b1a7cbc791 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -34,6 +34,8 @@ type ConfigStore interface { DeleteProvider(ctx context.Context, provider schemas.ModelProvider, tx ...*gorm.DB) error GetProvidersConfig(ctx context.Context) (map[schemas.ModelProvider]ProviderConfig, error) GetProviderConfig(ctx context.Context, provider schemas.ModelProvider) (*ProviderConfig, error) + GetProviders(ctx context.Context) ([]tables.TableProvider, error) + GetProviderByName(ctx context.Context, name string) (*tables.TableProvider, error) // MCP config CRUD GetMCPConfig(ctx context.Context) (*schemas.MCPConfig, error) @@ -110,6 +112,17 @@ type ConfigStore interface { CreateBudget(ctx context.Context, budget *tables.TableBudget, tx ...*gorm.DB) error UpdateBudget(ctx context.Context, budget *tables.TableBudget, tx ...*gorm.DB) error UpdateBudgets(ctx context.Context, budgets []*tables.TableBudget, tx ...*gorm.DB) error + UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64) error + UpdateRateLimitUsage(ctx context.Context, id string, tokenCurrentUsage int64, requestCurrentUsage int64) error + + // Model config CRUD + GetModelConfigs(ctx context.Context) ([]tables.TableModelConfig, error) + GetModelConfig(ctx context.Context, modelName string, provider *string) (*tables.TableModelConfig, error) + GetModelConfigByID(ctx context.Context, id string) (*tables.TableModelConfig, error) + CreateModelConfig(ctx context.Context, modelConfig *tables.TableModelConfig, tx ...*gorm.DB) error + UpdateModelConfig(ctx context.Context, modelConfig *tables.TableModelConfig, tx ...*gorm.DB) error + UpdateModelConfigs(ctx context.Context, modelConfigs []*tables.TableModelConfig, tx ...*gorm.DB) error + DeleteModelConfig(ctx context.Context, id string) error // Governance config CRUD GetGovernanceConfig(ctx context.Context) (*GovernanceConfig, error) diff --git a/framework/configstore/tables/modelconfig.go b/framework/configstore/tables/modelconfig.go new file mode 100644 index 0000000000..5e6b5ba6dc --- /dev/null +++ b/framework/configstore/tables/modelconfig.go @@ -0,0 +1,59 @@ +package tables + +import ( + "fmt" + "strings" + "time" + + "gorm.io/gorm" +) + +// TableModelConfig represents a model configuration with rate limiting and budgeting +type TableModelConfig struct { + ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` + ModelName string `gorm:"type:varchar(255);not null;uniqueIndex:idx_model_provider" json:"model_name"` + Provider *string `gorm:"type:varchar(50);uniqueIndex:idx_model_provider" json:"provider,omitempty"` // Optional provider, nullable + BudgetID *string `gorm:"type:varchar(255);index:idx_model_config_budget" json:"budget_id,omitempty"` + RateLimitID *string `gorm:"type:varchar(255);index:idx_model_config_rate_limit" json:"rate_limit_id,omitempty"` + + // Relationships + Budget *TableBudget `gorm:"foreignKey:BudgetID;onDelete:CASCADE" json:"budget,omitempty"` + RateLimit *TableRateLimit `gorm:"foreignKey:RateLimitID;onDelete:CASCADE" json:"rate_limit,omitempty"` + + // Config hash is used to detect the changes synced from config.json file + // Every time we sync the config.json file, we will update the config hash + ConfigHash string `gorm:"type:varchar(255);null" json:"config_hash"` + + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableModelConfig) TableName() string { + return "governance_model_configs" +} + +// BeforeSave hook for ModelConfig to validate required fields +func (mc *TableModelConfig) BeforeSave(tx *gorm.DB) error { + // Validate that ModelName is not empty + if strings.TrimSpace(mc.ModelName) == "" { + return fmt.Errorf("model_name cannot be empty") + } + + // Validate that if BudgetID is provided, it's not an empty string + if mc.BudgetID != nil && strings.TrimSpace(*mc.BudgetID) == "" { + return fmt.Errorf("budget_id cannot be an empty string") + } + + // Validate that if RateLimitID is provided, it's not an empty string + if mc.RateLimitID != nil && strings.TrimSpace(*mc.RateLimitID) == "" { + return fmt.Errorf("rate_limit_id cannot be an empty string") + } + + // Validate that if Provider is provided, it's not an empty string + if mc.Provider != nil && strings.TrimSpace(*mc.Provider) == "" { + return fmt.Errorf("provider cannot be an empty string") + } + + return nil +} diff --git a/framework/configstore/tables/provider.go b/framework/configstore/tables/provider.go index b85af6cb5d..9d345b12dd 100644 --- a/framework/configstore/tables/provider.go +++ b/framework/configstore/tables/provider.go @@ -3,6 +3,7 @@ package tables import ( "encoding/json" "fmt" + "strings" "time" "github.com/maximhq/bifrost/core/schemas" @@ -38,6 +39,14 @@ type TableProvider struct { // Foreign keys Models []TableModel `gorm:"foreignKey:ProviderID;constraint:OnDelete:CASCADE" json:"models"` + // Governance fields - Budget and Rate Limit for provider-level governance + BudgetID *string `gorm:"type:varchar(255);index:idx_provider_budget" json:"budget_id,omitempty"` + RateLimitID *string `gorm:"type:varchar(255);index:idx_provider_rate_limit" json:"rate_limit_id,omitempty"` + + // Governance relationships + Budget *TableBudget `gorm:"foreignKey:BudgetID;onDelete:CASCADE" json:"budget,omitempty"` + RateLimit *TableRateLimit `gorm:"foreignKey:RateLimitID;onDelete:CASCADE" json:"rate_limit,omitempty"` + // Config hash is used to detect the changes synced from config.json file // Every time we sync the config.json file, we will update the config hash ConfigHash string `gorm:"type:varchar(255);null" json:"config_hash"` @@ -79,6 +88,15 @@ func (p *TableProvider) BeforeSave(tx *gorm.DB) error { } p.CustomProviderConfigJSON = string(data) } + + // Validate governance fields + if p.BudgetID != nil && strings.TrimSpace(*p.BudgetID) == "" { + return fmt.Errorf("budget_id cannot be an empty string") + } + if p.RateLimitID != nil && strings.TrimSpace(*p.RateLimitID) == "" { + return fmt.Errorf("rate_limit_id cannot be an empty string") + } + return nil } diff --git a/plugins/governance/main.go b/plugins/governance/main.go index d0977aaa08..dcf2d01718 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -556,6 +556,9 @@ func (p *GovernancePlugin) PreHook(ctx *schemas.BifrostContext, req *schemas.Bif // Extract governance headers and virtual key using utility functions virtualKeyValue := getStringFromContext(ctx, schemas.BifrostContextKeyVirtualKey) requestID := getStringFromContext(ctx, schemas.BifrostContextKeyRequestID) + provider, model, _ := req.GetRequestFields() + + // Check if virtual key is mandatory when none is provided if virtualKeyValue == "" { if p.isVkMandatory != nil && *p.isVkMandatory { return req, &schemas.PluginShortCircuit{ @@ -567,23 +570,18 @@ func (p *GovernancePlugin) PreHook(ctx *schemas.BifrostContext, req *schemas.Bif }, }, }, nil - } else { - return req, nil, nil } } - provider, model, _ := req.GetRequestFields() + // First evaluate model and provider checks (applies even when virtual keys are disabled or not present) + result := p.resolver.EvaluateModelAndProviderRequest(ctx, provider, model, requestID) - // Create request context for evaluation - evaluationRequest := &EvaluationRequest{ - VirtualKey: virtualKeyValue, - Provider: provider, - Model: model, - RequestID: requestID, + // If model/provider checks passed and virtual key exists, evaluate virtual key checks + // This will overwrite the result with virtual key-specific decision + if result.Decision == DecisionAllow && virtualKeyValue != "" { + result = p.resolver.EvaluateVirtualKeyRequest(ctx, virtualKeyValue, provider, model, requestID) } - - // Use resolver to make governance decision (pure decision engine) - result := p.resolver.EvaluateRequest(ctx, evaluationRequest) + // If model/provider checks failed, skip virtual key evaluation and proceed to final decision handling if result.Decision != DecisionAllow { if ctx != nil { @@ -663,11 +661,6 @@ func (p *GovernancePlugin) PostHook(ctx *schemas.BifrostContext, result *schemas virtualKey := getStringFromContext(ctx, schemas.BifrostContextKeyVirtualKey) requestID := getStringFromContext(ctx, schemas.BifrostContextKeyRequestID) - // Skip if no virtual key - if virtualKey == "" { - return result, err, nil - } - // Extract request type, provider, and model requestType, provider, model := bifrost.GetResponseFields(result, err) @@ -687,11 +680,17 @@ func (p *GovernancePlugin) PostHook(ctx *schemas.BifrostContext, result *schemas isFinalChunk := bifrost.IsFinalChunk(ctx) - p.wg.Add(1) - go func() { - defer p.wg.Done() - p.postHookWorker(result, provider, model, requestType, virtualKey, requestID, isCacheRead, isBatch, isFinalChunk) - }() + // Always process usage tracking (with or without virtual key) + // If virtualKey is empty, it will be passed as empty string to postHookWorker + // The tracker will handle empty virtual keys gracefully by only updating provider-level and model-level usage + if model != "" { + p.wg.Add(1) + go func() { + defer p.wg.Done() + // Pass virtualKey (empty string if not present) - tracker handles this case + p.postHookWorker(result, provider, model, requestType, virtualKey, requestID, isCacheRead, isBatch, isFinalChunk) + }() + } return result, err, nil } @@ -711,12 +710,14 @@ func (p *GovernancePlugin) Cleanup() error { // postHookWorker is a worker function that processes the response and updates usage tracking // It is used to avoid blocking the main thread when updating usage tracking +// Handles both cases: with virtual key and without virtual key (empty string) +// When virtualKey is empty, the tracker will only update provider-level and model-level usage // Parameters: // - result: The Bifrost response to be processed // - provider: The provider of the request // - model: The model of the request // - requestType: The type of the request -// - virtualKey: The virtual key of the request +// - virtualKey: The virtual key of the request (empty string if not present) // - requestID: The request ID // - isCacheRead: Whether the request is a cache read // - isBatch: Whether the request is a batch request @@ -771,6 +772,7 @@ func (p *GovernancePlugin) postHookWorker(result *schemas.BifrostResponse, provi } // Queue usage update asynchronously using tracker + // UpdateUsage handles empty virtual keys gracefully by only updating provider-level and model-level usage p.tracker.UpdateUsage(p.ctx, usageUpdate) } } diff --git a/plugins/governance/resolver.go b/plugins/governance/resolver.go index 4009211594..532f219698 100644 --- a/plugins/governance/resolver.go +++ b/plugins/governance/resolver.go @@ -76,10 +76,63 @@ func NewBudgetResolver(store GovernanceStore, modelCatalog *modelcatalog.ModelCa } } -// EvaluateRequest evaluates a request against the new hierarchical governance system -func (r *BudgetResolver) EvaluateRequest(ctx *schemas.BifrostContext, evaluationRequest *EvaluationRequest) *EvaluationResult { +// EvaluateModelAndProviderRequest evaluates provider-level and model-level rate limits and budgets +// This applies even when virtual keys are disabled or not present +func (r *BudgetResolver) EvaluateModelAndProviderRequest(ctx *schemas.BifrostContext, provider schemas.ModelProvider, model string, requestID string) *EvaluationResult { + // Create evaluation request for the checks + request := &EvaluationRequest{ + Provider: provider, + Model: model, + RequestID: requestID, + } + + // 1. Check provider-level rate limits FIRST (before model-level checks) + if provider != "" { + if err, decision := r.store.CheckProviderRateLimit(ctx, request, nil, nil); err != nil { + return &EvaluationResult{ + Decision: decision, + Reason: fmt.Sprintf("Provider-level rate limit check failed: %s", err.Error()), + } + } + + // 2. Check provider-level budgets FIRST (before model-level checks) + if err := r.store.CheckProviderBudget(ctx, request, nil); err != nil { + return &EvaluationResult{ + Decision: DecisionBudgetExceeded, + Reason: fmt.Sprintf("Provider-level budget exceeded: %s", err.Error()), + } + } + } + + // 3. Check model-level rate limits (after provider-level checks) + if model != "" { + if err, decision := r.store.CheckModelRateLimit(ctx, request, nil, nil); err != nil { + return &EvaluationResult{ + Decision: decision, + Reason: fmt.Sprintf("Model-level rate limit check failed: %s", err.Error()), + } + } + + // 4. Check model-level budgets (after provider-level checks) + if err := r.store.CheckModelBudget(ctx, request, nil); err != nil { + return &EvaluationResult{ + Decision: DecisionBudgetExceeded, + Reason: fmt.Sprintf("Model-level budget exceeded: %s", err.Error()), + } + } + } + + // All provider-level and model-level checks passed + return &EvaluationResult{ + Decision: DecisionAllow, + Reason: "Request allowed by governance policy (provider-level and model-level checks passed)", + } +} + +// EvaluateVirtualKeyRequest evaluates virtual key-specific checks including validation, filtering, rate limits, and budgets +func (r *BudgetResolver) EvaluateVirtualKeyRequest(ctx *schemas.BifrostContext, virtualKeyValue string, provider schemas.ModelProvider, model string, requestID string) *EvaluationResult { // 1. Validate virtual key exists and is active - vk, exists := r.store.GetVirtualKey(evaluationRequest.VirtualKey) + vk, exists := r.store.GetVirtualKey(virtualKeyValue) if !exists { return &EvaluationResult{ Decision: DecisionVirtualKeyNotFound, @@ -111,25 +164,32 @@ func (r *BudgetResolver) EvaluateRequest(ctx *schemas.BifrostContext, evaluation } // 2. Check provider filtering - if !r.isProviderAllowed(vk, evaluationRequest.Provider) { + if !r.isProviderAllowed(vk, provider) { return &EvaluationResult{ Decision: DecisionProviderBlocked, - Reason: fmt.Sprintf("Provider '%s' is not allowed for this virtual key", evaluationRequest.Provider), + Reason: fmt.Sprintf("Provider '%s' is not allowed for this virtual key", provider), VirtualKey: vk, } } // 3. Check model filtering - if !r.isModelAllowed(vk, evaluationRequest.Provider, evaluationRequest.Model) { + if !r.isModelAllowed(vk, provider, model) { return &EvaluationResult{ Decision: DecisionModelBlocked, - Reason: fmt.Sprintf("Model '%s' is not allowed for this virtual key", evaluationRequest.Model), + Reason: fmt.Sprintf("Model '%s' is not allowed for this virtual key", model), VirtualKey: vk, } } - // 4. Check rate limits hierarchy (Provider level first, then VK level) - if rateLimitResult := r.checkRateLimitHierarchy(ctx, vk, string(evaluationRequest.Provider), evaluationRequest.Model, evaluationRequest.RequestID); rateLimitResult != nil { + evaluationRequest := &EvaluationRequest{ + VirtualKey: virtualKeyValue, + Provider: provider, + Model: model, + RequestID: requestID, + } + + // 4. Check rate limits hierarchy (VK level) + if rateLimitResult := r.checkRateLimitHierarchy(ctx, vk, evaluationRequest); rateLimitResult != nil { return rateLimitResult } @@ -140,7 +200,7 @@ func (r *BudgetResolver) EvaluateRequest(ctx *schemas.BifrostContext, evaluation // Find the provider config that matches the request's provider and get its allowed keys for _, pc := range vk.ProviderConfigs { - if schemas.ModelProvider(pc.Provider) == evaluationRequest.Provider && len(pc.Keys) > 0 { + if schemas.ModelProvider(pc.Provider) == provider && len(pc.Keys) > 0 { includeOnlyKeys := make([]string, 0, len(pc.Keys)) for _, dbKey := range pc.Keys { includeOnlyKeys = append(includeOnlyKeys, dbKey.KeyID) @@ -201,12 +261,12 @@ func (r *BudgetResolver) isProviderAllowed(vk *configstoreTables.TableVirtualKey } // checkRateLimitHierarchy checks provider-level rate limits first, then VK rate limits using flexible approach -func (r *BudgetResolver) checkRateLimitHierarchy(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider string, model string, requestID string) *EvaluationResult { - if decision, err := r.store.CheckRateLimit(ctx, vk, schemas.ModelProvider(provider), nil, nil); err != nil { +func (r *BudgetResolver) checkRateLimitHierarchy(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest) *EvaluationResult { + if decision, err := r.store.CheckRateLimit(ctx, vk, request, nil, nil); err != nil { // Check provider-level first (matching check order), then VK-level var rateLimitInfo *configstoreTables.TableRateLimit for _, pc := range vk.ProviderConfigs { - if pc.Provider == provider && pc.RateLimit != nil { + if pc.Provider == string(request.Provider) && pc.RateLimit != nil { rateLimitInfo = pc.RateLimit break } @@ -228,7 +288,7 @@ func (r *BudgetResolver) checkRateLimitHierarchy(ctx context.Context, vk *config // checkBudgetHierarchy checks the budget hierarchy atomically (VK → Team → Customer) func (r *BudgetResolver) checkBudgetHierarchy(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest) *EvaluationResult { // Use atomic budget checking to prevent race conditions - if err := r.store.CheckBudget(ctx, vk, request.Provider, nil); err != nil { + if err := r.store.CheckBudget(ctx, vk, request, nil); err != nil { r.logger.Debug(fmt.Sprintf("Atomic budget exceeded for VK %s: %s", vk.ID, err.Error())) return &EvaluationResult{ @@ -245,11 +305,20 @@ func (r *BudgetResolver) checkBudgetHierarchy(ctx context.Context, vk *configsto // isProviderBudgetViolated checks if a provider config's budget is violated func (r *BudgetResolver) isProviderBudgetViolated(ctx context.Context, vk *configstoreTables.TableVirtualKey, config configstoreTables.TableVirtualKeyProviderConfig) bool { + request := &EvaluationRequest{Provider: schemas.ModelProvider(config.Provider)} + + // 1. Check global provider-level budget first + if err := r.store.CheckProviderBudget(ctx, request, nil); err != nil { + r.logger.Debug(fmt.Sprintf("Global provider budget exceeded for provider %s: %s", config.Provider, err.Error())) + return true + } + + // 2. Check VK-level provider config budget if config.Budget == nil { return false } - if err := r.store.CheckBudget(ctx, vk, schemas.ModelProvider(config.Provider), nil); err != nil { - r.logger.Debug(fmt.Sprintf("Atomic budget exceeded for VK %s: %s", vk.ID, err.Error())) + if err := r.store.CheckBudget(ctx, vk, request, nil); err != nil { + r.logger.Debug(fmt.Sprintf("VK provider config budget exceeded for VK %s: %s", vk.ID, err.Error())) return true } return false @@ -257,12 +326,27 @@ func (r *BudgetResolver) isProviderBudgetViolated(ctx context.Context, vk *confi // isProviderRateLimitViolated checks if a provider config's rate limit is violated func (r *BudgetResolver) isProviderRateLimitViolated(ctx context.Context, vk *configstoreTables.TableVirtualKey, config configstoreTables.TableVirtualKeyProviderConfig) bool { + request := &EvaluationRequest{Provider: schemas.ModelProvider(config.Provider)} + + // 1. Check global provider-level rate limit first + if err, decision := r.store.CheckProviderRateLimit(ctx, request, nil, nil); err != nil || isRateLimitViolation(decision) { + r.logger.Debug(fmt.Sprintf("Global provider rate limit exceeded for provider %s", config.Provider)) + return true + } + + // 2. Check VK-level provider config rate limit if config.RateLimit == nil { return false } - decision, err := r.store.CheckRateLimit(ctx, vk, schemas.ModelProvider(config.Provider), nil, nil) - if err != nil || decision == DecisionRateLimited { + decision, err := r.store.CheckRateLimit(ctx, vk, request, nil, nil) + if err != nil || isRateLimitViolation(decision) { + r.logger.Debug(fmt.Sprintf("VK provider config rate limit exceeded for VK %s, provider %s", vk.ID, config.Provider)) return true } return false } + +// isRateLimitViolation returns true if the decision indicates a rate limit violation +func isRateLimitViolation(decision Decision) bool { + return decision == DecisionRateLimited || decision == DecisionTokenLimited || decision == DecisionRequestLimited +} diff --git a/plugins/governance/store.go b/plugins/governance/store.go index da1ba0f17c..39cbdb6061 100644 --- a/plugins/governance/store.go +++ b/plugins/governance/store.go @@ -17,11 +17,13 @@ import ( // LocalGovernanceStore provides in-memory cache for governance data with fast, non-blocking access type LocalGovernanceStore struct { // Core data maps using sync.Map for lock-free reads - virtualKeys sync.Map // string -> *VirtualKey (VK value -> VirtualKey with preloaded relationships) - teams sync.Map // string -> *Team (Team ID -> Team) - customers sync.Map // string -> *Customer (Customer ID -> Customer) - budgets sync.Map // string -> *Budget (Budget ID -> Budget) - rateLimits sync.Map // string -> *RateLimit (RateLimit ID -> RateLimit) + virtualKeys sync.Map // string -> *VirtualKey (VK value -> VirtualKey with preloaded relationships) + teams sync.Map // string -> *Team (Team ID -> Team) + customers sync.Map // string -> *Customer (Customer ID -> Customer) + budgets sync.Map // string -> *Budget (Budget ID -> Budget) + rateLimits sync.Map // string -> *RateLimit (RateLimit ID -> RateLimit) + modelConfigs sync.Map // string -> *ModelConfig (key: "modelName" or "modelName:provider" -> ModelConfig) + providers sync.Map // string -> *Provider (Provider name -> Provider with preloaded relationships) // Config store for refresh operations configStore configstore.ConfigStore @@ -50,16 +52,31 @@ type GovernanceData struct { type GovernanceStore interface { GetGovernanceData() *GovernanceData GetVirtualKey(vkValue string) (*configstoreTables.TableVirtualKey, bool) - CheckBudget(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, baselines map[string]float64) error - CheckRateLimit(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) - UpdateBudgetUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, cost float64) error - UpdateRateLimitUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error + // Provider-level governance checks + CheckProviderBudget(ctx context.Context, request *EvaluationRequest, baselines map[string]float64) error + CheckProviderRateLimit(ctx context.Context, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (error, Decision) + // Model-level governance checks + CheckModelBudget(ctx context.Context, request *EvaluationRequest, baselines map[string]float64) error + CheckModelRateLimit(ctx context.Context, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (error, Decision) + // VK-level governance checks + CheckBudget(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest, baselines map[string]float64) error + CheckRateLimit(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) + // In-memory usage updates (for VK-level) + UpdateVirtualKeyBudgetUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, cost float64) error + UpdateVirtualKeyRateLimitUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error + // In-memory reset checks (return items that need DB sync) ResetExpiredRateLimitsInMemory(ctx context.Context) []*configstoreTables.TableRateLimit ResetExpiredBudgetsInMemory(ctx context.Context) []*configstoreTables.TableBudget + // DB sync for expired items ResetExpiredRateLimits(ctx context.Context, resetRateLimits []*configstoreTables.TableRateLimit) error ResetExpiredBudgets(ctx context.Context, resetBudgets []*configstoreTables.TableBudget) error + // Provider and model-level usage updates (combined) + UpdateProviderAndModelBudgetUsageInMemory(ctx context.Context, model string, provider schemas.ModelProvider, cost float64) error + UpdateProviderAndModelRateLimitUsageInMemory(ctx context.Context, model string, provider schemas.ModelProvider, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error + // Dump operations DumpRateLimits(ctx context.Context, tokenBaselines map[string]int64, requestBaselines map[string]int64) error DumpBudgets(ctx context.Context, baselines map[string]float64) error + // In-memory CRUD operations CreateVirtualKeyInMemory(vk *configstoreTables.TableVirtualKey) UpdateVirtualKeyInMemory(vk *configstoreTables.TableVirtualKey, budgetBaselines map[string]float64, rateLimitTokensBaselines map[string]int64, rateLimitRequestsBaselines map[string]int64) DeleteVirtualKeyInMemory(vkID string) @@ -69,6 +86,12 @@ type GovernanceStore interface { CreateCustomerInMemory(customer *configstoreTables.TableCustomer) UpdateCustomerInMemory(customer *configstoreTables.TableCustomer, budgetBaselines map[string]float64) DeleteCustomerInMemory(customerID string) + // Model config in-memory operations + UpdateModelConfigInMemory(mc *configstoreTables.TableModelConfig) *configstoreTables.TableModelConfig + DeleteModelConfigInMemory(mcID string) + // Provider in-memory operations + UpdateProviderInMemory(provider *configstoreTables.TableProvider) *configstoreTables.TableProvider + DeleteProviderInMemory(providerName string) } // NewLocalGovernanceStore creates a new in-memory governance store @@ -163,7 +186,7 @@ func (gs *LocalGovernanceStore) GetVirtualKey(vkValue string) (*configstoreTable } // CheckBudget performs budget checking using in-memory store data (lock-free for high performance) -func (gs *LocalGovernanceStore) CheckBudget(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, baselines map[string]float64) error { +func (gs *LocalGovernanceStore) CheckBudget(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest, baselines map[string]float64) error { if vk == nil { return fmt.Errorf("virtual key cannot be nil") } @@ -173,6 +196,12 @@ func (gs *LocalGovernanceStore) CheckBudget(ctx context.Context, vk *configstore baselines = map[string]float64{} } + // Extract provider from request + var provider schemas.ModelProvider + if request != nil { + provider = request.Provider + } + // Use helper to collect budgets and their names (lock-free) budgetsToCheck, budgetNames := gs.collectBudgetsFromHierarchy(vk, provider) @@ -216,10 +245,412 @@ func (gs *LocalGovernanceStore) CheckBudget(ctx context.Context, vk *configstore return nil } +// CheckProviderBudget performs budget checking for provider-level configs (lock-free for high performance) +func (gs *LocalGovernanceStore) CheckProviderBudget(ctx context.Context, request *EvaluationRequest, baselines map[string]float64) error { + // This is to prevent nil pointer dereference + if baselines == nil { + baselines = map[string]float64{} + } + + // Extract provider from request + var provider schemas.ModelProvider + if request != nil { + provider = request.Provider + } + + // Get provider config + providerKey := string(provider) + value, exists := gs.providers.Load(providerKey) + if !exists || value == nil { + // No provider config found, allow request + return nil + } + + providerTable, ok := value.(*configstoreTables.TableProvider) + if !ok || providerTable == nil || providerTable.BudgetID == nil { + // No budget configured for provider, allow request + return nil + } + + // Read from budgets map to get the latest updated budget (same source as UpdateProviderBudgetUsage) + budgetValue, exists := gs.budgets.Load(*providerTable.BudgetID) + if !exists || budgetValue == nil { + // Budget not found in cache, allow request + return nil + } + + budget, ok := budgetValue.(*configstoreTables.TableBudget) + if !ok || budget == nil { + // Invalid budget type, allow request + return nil + } + + // Check if budget needs reset (in-memory check) + if budget.ResetDuration != "" { + if duration, err := configstoreTables.ParseDuration(budget.ResetDuration); err == nil { + if time.Since(budget.LastReset) >= duration { + // Budget expired but hasn't been reset yet - treat as reset + return nil // Skip budget check for expired budgets + } + } + } + + baseline, exists := baselines[budget.ID] + if !exists { + baseline = 0 + } + + // Check if current usage (local + remote baseline) exceeds budget limit + if budget.CurrentUsage+baseline >= budget.MaxLimit { + return fmt.Errorf("%s budget exceeded: %.4f >= %.4f dollars", + providerKey, budget.CurrentUsage+baseline, budget.MaxLimit) + } + + return nil +} + +// CheckProviderRateLimit checks provider-level rate limits and returns evaluation result if violated +func (gs *LocalGovernanceStore) CheckProviderRateLimit(ctx context.Context, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (error, Decision) { + var violations []string + + // This is to prevent nil pointer dereference + if tokensBaselines == nil { + tokensBaselines = map[string]int64{} + } + if requestsBaselines == nil { + requestsBaselines = map[string]int64{} + } + + // Extract provider from request + var provider schemas.ModelProvider + if request != nil { + provider = request.Provider + } + + // Get provider config + providerKey := string(provider) + value, exists := gs.providers.Load(providerKey) + if !exists || value == nil { + // No provider config found, allow request + return nil, DecisionAllow + } + + providerTable, ok := value.(*configstoreTables.TableProvider) + if !ok || providerTable == nil || providerTable.RateLimitID == nil { + // No rate limit configured for provider, allow request + return nil, DecisionAllow + } + + // Read from rateLimits map to get the latest updated rate limit (same source as UpdateProviderRateLimitUsage) + rateLimitValue, exists := gs.rateLimits.Load(*providerTable.RateLimitID) + if !exists || rateLimitValue == nil { + // Rate limit not found in cache, allow request + return nil, DecisionAllow + } + + rateLimit, ok := rateLimitValue.(*configstoreTables.TableRateLimit) + if !ok || rateLimit == nil { + // Invalid rate limit type, allow request + return nil, DecisionAllow + } + + // Check if rate limit needs reset (in-memory check) + // Track which limits are expired so we can skip only those specific checks + tokenLimitExpired := false + if rateLimit.TokenResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.TokenResetDuration); err == nil { + if time.Since(rateLimit.TokenLastReset) >= duration { + // Token rate limit expired but hasn't been reset yet - skip token check only + tokenLimitExpired = true + } + } + } + requestLimitExpired := false + if rateLimit.RequestResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.RequestResetDuration); err == nil { + if time.Since(rateLimit.RequestLastReset) >= duration { + // Request rate limit expired but hasn't been reset yet - skip request check only + requestLimitExpired = true + } + } + } + + tokensBaseline, exists := tokensBaselines[rateLimit.ID] + if !exists { + tokensBaseline = 0 + } + requestsBaseline, exists := requestsBaselines[rateLimit.ID] + if !exists { + requestsBaseline = 0 + } + + // Token limits - check if total usage (local + remote baseline) exceeds limit + // Skip this check if token limit has expired + if !tokenLimitExpired && rateLimit.TokenMaxLimit != nil && rateLimit.TokenCurrentUsage+tokensBaseline >= *rateLimit.TokenMaxLimit { + duration := "unknown" + if rateLimit.TokenResetDuration != nil { + duration = *rateLimit.TokenResetDuration + } + violations = append(violations, fmt.Sprintf("token limit exceeded (%d/%d, resets every %s)", + rateLimit.TokenCurrentUsage+tokensBaseline, *rateLimit.TokenMaxLimit, duration)) + } + + // Request limits - check if total usage (local + remote baseline) exceeds limit + // Skip this check if request limit has expired + if !requestLimitExpired && rateLimit.RequestMaxLimit != nil && rateLimit.RequestCurrentUsage+requestsBaseline >= *rateLimit.RequestMaxLimit { + duration := "unknown" + if rateLimit.RequestResetDuration != nil { + duration = *rateLimit.RequestResetDuration + } + violations = append(violations, fmt.Sprintf("request limit exceeded (%d/%d, resets every %s)", + rateLimit.RequestCurrentUsage+requestsBaseline, *rateLimit.RequestMaxLimit, duration)) + } + + if len(violations) > 0 { + // Determine specific violation type + decision := DecisionRateLimited // Default to general rate limited decision + if len(violations) == 1 { + if strings.Contains(violations[0], "token") { + decision = DecisionTokenLimited // More specific violation type + } else if strings.Contains(violations[0], "request") { + decision = DecisionRequestLimited // More specific violation type + } + } + return fmt.Errorf("rate limit violated for %s: %s", providerKey, violations), decision + } + + return nil, DecisionAllow // No rate limit violations +} + +// CheckModelBudget performs budget checking for model-level configs (lock-free for high performance) +func (gs *LocalGovernanceStore) CheckModelBudget(ctx context.Context, request *EvaluationRequest, baselines map[string]float64) error { + // This is to prevent nil pointer dereference + if baselines == nil { + baselines = map[string]float64{} + } + + // Extract model and provider from request + var model string + var provider *schemas.ModelProvider + if request != nil { + model = request.Model + if request.Provider != "" { + provider = &request.Provider + } + } + + // Collect model configs to check: model+provider (if exists) AND model-only (if exists) + var modelConfigsToCheck []*configstoreTables.TableModelConfig + var budgetNames []string + + // Check model+provider config first (more specific) - if provider is provided + if provider != nil { + key := fmt.Sprintf("%s:%s", model, string(*provider)) + if value, exists := gs.modelConfigs.Load(key); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.Budget != nil { + modelConfigsToCheck = append(modelConfigsToCheck, mc) + budgetNames = append(budgetNames, fmt.Sprintf("Model:%s:Provider:%s", model, string(*provider))) + } + } + } + + // Always check model-only config (if exists) - regardless of whether model+provider config exists + key := model + if value, exists := gs.modelConfigs.Load(key); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.Budget != nil { + modelConfigsToCheck = append(modelConfigsToCheck, mc) + budgetNames = append(budgetNames, fmt.Sprintf("Model:%s", model)) + } + } + + // Check each model budget + for i, mc := range modelConfigsToCheck { + if mc.BudgetID == nil { + continue + } + + // Read from budgets map to get the latest updated budget (same source as UpdateModelBudgetUsage) + budgetValue, exists := gs.budgets.Load(*mc.BudgetID) + if !exists || budgetValue == nil { + // Budget not found in cache, skip check + continue + } + + budget, ok := budgetValue.(*configstoreTables.TableBudget) + if !ok || budget == nil { + // Invalid budget type, skip check + continue + } + + // Check if budget needs reset (in-memory check) + if budget.ResetDuration != "" { + if duration, err := configstoreTables.ParseDuration(budget.ResetDuration); err == nil { + if time.Since(budget.LastReset) >= duration { + // Budget expired but hasn't been reset yet - treat as reset + continue // Skip budget check for expired budgets + } + } + } + + baseline, exists := baselines[budget.ID] + if !exists { + baseline = 0 + } + + // Check if current usage (local + remote baseline) exceeds budget limit + if budget.CurrentUsage+baseline >= budget.MaxLimit { + return fmt.Errorf("%s budget exceeded: %.4f >= %.4f dollars", + budgetNames[i], budget.CurrentUsage+baseline, budget.MaxLimit) + } + } + + return nil +} + +// CheckModelRateLimit checks model-level rate limits and returns evaluation result if violated +func (gs *LocalGovernanceStore) CheckModelRateLimit(ctx context.Context, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (error, Decision) { + var violations []string + + // This is to prevent nil pointer dereference + if tokensBaselines == nil { + tokensBaselines = map[string]int64{} + } + if requestsBaselines == nil { + requestsBaselines = map[string]int64{} + } + + // Extract model and provider from request + var model string + var provider *schemas.ModelProvider + if request != nil { + model = request.Model + if request.Provider != "" { + provider = &request.Provider + } + } + + // Collect model configs to check: model+provider (if exists) AND model-only (if exists) + var modelConfigsToCheck []*configstoreTables.TableModelConfig + var rateLimitNames []string + + // Check model+provider config first (more specific) - if provider is provided + if provider != nil { + key := fmt.Sprintf("%s:%s", model, string(*provider)) + if value, exists := gs.modelConfigs.Load(key); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.RateLimitID != nil { + modelConfigsToCheck = append(modelConfigsToCheck, mc) + rateLimitNames = append(rateLimitNames, fmt.Sprintf("Model:%s:Provider:%s", model, string(*provider))) + } + } + } + + // Always check model-only config (if exists) - regardless of whether model+provider config exists + key := model + if value, exists := gs.modelConfigs.Load(key); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.RateLimitID != nil { + modelConfigsToCheck = append(modelConfigsToCheck, mc) + rateLimitNames = append(rateLimitNames, fmt.Sprintf("Model:%s", model)) + } + } + + // Check each model rate limit + for i, mc := range modelConfigsToCheck { + if mc.RateLimitID == nil { + continue + } + + // Read from rateLimits map to get the latest updated rate limit (same source as UpdateModelRateLimitUsage) + rateLimitValue, exists := gs.rateLimits.Load(*mc.RateLimitID) + if !exists || rateLimitValue == nil { + // Rate limit not found in cache, skip check + continue + } + + rateLimit, ok := rateLimitValue.(*configstoreTables.TableRateLimit) + if !ok || rateLimit == nil { + // Invalid rate limit type, skip check + continue + } + + // Check if rate limit needs reset (in-memory check) + // Track which limits are expired so we can skip only those specific checks + tokenLimitExpired := false + if rateLimit.TokenResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.TokenResetDuration); err == nil { + if time.Since(rateLimit.TokenLastReset) >= duration { + // Token rate limit expired but hasn't been reset yet - skip token check only + tokenLimitExpired = true + } + } + } + requestLimitExpired := false + if rateLimit.RequestResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.RequestResetDuration); err == nil { + if time.Since(rateLimit.RequestLastReset) >= duration { + // Request rate limit expired but hasn't been reset yet - skip request check only + requestLimitExpired = true + } + } + } + + tokensBaseline, exists := tokensBaselines[rateLimit.ID] + if !exists { + tokensBaseline = 0 + } + requestsBaseline, exists := requestsBaselines[rateLimit.ID] + if !exists { + requestsBaseline = 0 + } + + // Token limits - check if total usage (local + remote baseline) exceeds limit + // Skip this check if token limit has expired + if !tokenLimitExpired && rateLimit.TokenMaxLimit != nil && rateLimit.TokenCurrentUsage+tokensBaseline >= *rateLimit.TokenMaxLimit { + duration := "unknown" + if rateLimit.TokenResetDuration != nil { + duration = *rateLimit.TokenResetDuration + } + violations = append(violations, fmt.Sprintf("token limit exceeded (%d/%d, resets every %s)", + rateLimit.TokenCurrentUsage+tokensBaseline, *rateLimit.TokenMaxLimit, duration)) + } + + // Request limits - check if total usage (local + remote baseline) exceeds limit + // Skip this check if request limit has expired + if !requestLimitExpired && rateLimit.RequestMaxLimit != nil && rateLimit.RequestCurrentUsage+requestsBaseline >= *rateLimit.RequestMaxLimit { + duration := "unknown" + if rateLimit.RequestResetDuration != nil { + duration = *rateLimit.RequestResetDuration + } + violations = append(violations, fmt.Sprintf("request limit exceeded (%d/%d, resets every %s)", + rateLimit.RequestCurrentUsage+requestsBaseline, *rateLimit.RequestMaxLimit, duration)) + } + + if len(violations) > 0 { + // Determine specific violation type + decision := DecisionRateLimited // Default to general rate limited decision + if len(violations) == 1 { + if strings.Contains(violations[0], "token") { + decision = DecisionTokenLimited // More specific violation type + } else if strings.Contains(violations[0], "request") { + decision = DecisionRequestLimited // More specific violation type + } + } + return fmt.Errorf("rate limit violated for %s: %s", rateLimitNames[i], violations), decision + } + } + + return nil, DecisionAllow // No rate limit violations +} + // CheckRateLimit checks a single rate limit and returns evaluation result if violated (true if violated, false if not) -func (gs *LocalGovernanceStore) CheckRateLimit(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) { +func (gs *LocalGovernanceStore) CheckRateLimit(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) { var violations []string + // Extract provider from request + var provider schemas.ModelProvider + if request != nil { + provider = request.Provider + } + // Collect rate limits and their names from the hierarchy rateLimits, rateLimitNames := gs.collectRateLimitsFromHierarchy(vk, provider) @@ -307,8 +738,8 @@ func (gs *LocalGovernanceStore) CheckRateLimit(ctx context.Context, vk *configst return DecisionAllow, nil // No rate limit violations } -// UpdateBudgetUsageInMemory performs atomic budget updates across the hierarchy (both in memory and in database) -func (gs *LocalGovernanceStore) UpdateBudgetUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, cost float64) error { +// UpdateVirtualKeyBudgetUsageInMemory performs atomic budget updates across the hierarchy (both in memory and in database) +func (gs *LocalGovernanceStore) UpdateVirtualKeyBudgetUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, cost float64) error { if vk == nil { return fmt.Errorf("virtual key cannot be nil") } @@ -330,7 +761,7 @@ func (gs *LocalGovernanceStore) UpdateBudgetUsageInMemory(ctx context.Context, v if now.Sub(clone.LastReset) >= duration { clone.CurrentUsage = 0 clone.LastReset = now - gs.logger.Debug("UpdateBudgetUsage: Budget %s was reset (expired, duration: %v)", budgetID, duration) + gs.logger.Debug("UpdateVirtualKeyBudgetUsageInMemory: Budget %s was reset (expired, duration: %v)", budgetID, duration) } } } @@ -338,18 +769,145 @@ func (gs *LocalGovernanceStore) UpdateBudgetUsageInMemory(ctx context.Context, v // Update the clone clone.CurrentUsage += cost gs.budgets.Store(budgetID, &clone) - gs.logger.Debug("UpdateBudgetUsage: Updated budget %s: %.4f -> %.4f (added %.4f)", + gs.logger.Debug("UpdateVirtualKeyBudgetUsageInMemory: Updated budget %s: %.4f -> %.4f (added %.4f)", budgetID, oldUsage, clone.CurrentUsage, cost) } } else { - gs.logger.Warn("UpdateBudgetUsage: Budget %s not found in local store", budgetID) + gs.logger.Warn("UpdateVirtualKeyBudgetUsageInMemory: Budget %s not found in local store", budgetID) + } + } + return nil +} + +// UpdateProviderAndModelBudgetUsageInMemory performs atomic budget updates for both provider-level and model-level configs (in memory) +func (gs *LocalGovernanceStore) UpdateProviderAndModelBudgetUsageInMemory(ctx context.Context, model string, provider schemas.ModelProvider, cost float64) error { + now := time.Now() + + // Helper function to update a budget by ID + updateBudget := func(budgetID string) { + if cachedBudgetValue, exists := gs.budgets.Load(budgetID); exists && cachedBudgetValue != nil { + if cachedBudget, ok := cachedBudgetValue.(*configstoreTables.TableBudget); ok && cachedBudget != nil { + // Clone FIRST to avoid race conditions + clone := *cachedBudget + // Check if budget needs reset (in-memory check) - operate on clone + if clone.ResetDuration != "" { + if duration, err := configstoreTables.ParseDuration(clone.ResetDuration); err == nil { + if now.Sub(clone.LastReset) >= duration { + clone.CurrentUsage = 0 + clone.LastReset = now + } + } + } + // Update the clone + clone.CurrentUsage += cost + gs.budgets.Store(budgetID, &clone) + } + } + } + + // 1. Update provider-level budget (if provider is set) + if provider != "" { + providerKey := string(provider) + if value, exists := gs.providers.Load(providerKey); exists && value != nil { + if providerTable, ok := value.(*configstoreTables.TableProvider); ok && providerTable != nil && providerTable.BudgetID != nil { + updateBudget(*providerTable.BudgetID) + } + } + } + + // 2. Update model-level budgets + // Check model+provider config first (more specific) - if provider is provided + if provider != "" { + key := fmt.Sprintf("%s:%s", model, string(provider)) + if value, exists := gs.modelConfigs.Load(key); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.BudgetID != nil { + updateBudget(*mc.BudgetID) + } + } + } + + // Always check model-only config (if exists) - regardless of whether model+provider config exists + if value, exists := gs.modelConfigs.Load(model); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.BudgetID != nil { + updateBudget(*mc.BudgetID) + } + } + + return nil +} + +// UpdateProviderAndModelRateLimitUsageInMemory updates rate limit counters for both provider-level and model-level rate limits (lock-free) +func (gs *LocalGovernanceStore) UpdateProviderAndModelRateLimitUsageInMemory(ctx context.Context, model string, provider schemas.ModelProvider, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error { + now := time.Now() + + // Helper function to update a rate limit by ID + updateRateLimit := func(rateLimitID string) { + if cachedRateLimitValue, exists := gs.rateLimits.Load(rateLimitID); exists && cachedRateLimitValue != nil { + if cachedRateLimit, ok := cachedRateLimitValue.(*configstoreTables.TableRateLimit); ok && cachedRateLimit != nil { + // Clone FIRST to avoid race conditions + clone := *cachedRateLimit + // Check if rate limit needs reset (in-memory check) - operate on clone + if clone.TokenResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*clone.TokenResetDuration); err == nil { + if now.Sub(clone.TokenLastReset) >= duration { + clone.TokenCurrentUsage = 0 + clone.TokenLastReset = now + } + } + } + if clone.RequestResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*clone.RequestResetDuration); err == nil { + if now.Sub(clone.RequestLastReset) >= duration { + clone.RequestCurrentUsage = 0 + clone.RequestLastReset = now + } + } + } + // Update the clone + if shouldUpdateTokens { + clone.TokenCurrentUsage += tokensUsed + } + if shouldUpdateRequests { + clone.RequestCurrentUsage += 1 + } + gs.rateLimits.Store(rateLimitID, &clone) + } + } + } + + // 1. Update provider-level rate limit (if provider is set) + if provider != "" { + providerKey := string(provider) + if value, exists := gs.providers.Load(providerKey); exists && value != nil { + if providerTable, ok := value.(*configstoreTables.TableProvider); ok && providerTable != nil && providerTable.RateLimitID != nil { + updateRateLimit(*providerTable.RateLimitID) + } + } + } + + // 2. Update model-level rate limits + // Check model+provider config first (more specific) - if provider is provided + if provider != "" { + key := fmt.Sprintf("%s:%s", model, string(provider)) + if value, exists := gs.modelConfigs.Load(key); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.RateLimitID != nil { + updateRateLimit(*mc.RateLimitID) + } } } + + // Always check model-only config (if exists) - regardless of whether model+provider config exists + if value, exists := gs.modelConfigs.Load(model); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.RateLimitID != nil { + updateRateLimit(*mc.RateLimitID) + } + } + return nil } -// UpdateRateLimitUsageInMemory updates rate limit counters for both provider-level and VK-level rate limits (lock-free) -func (gs *LocalGovernanceStore) UpdateRateLimitUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error { +// UpdateVirtualKeyRateLimitUsageInMemory updates rate limit counters for VK-level rate limits (lock-free) +func (gs *LocalGovernanceStore) UpdateVirtualKeyRateLimitUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error { if vk == nil { return fmt.Errorf("virtual key cannot be nil") } @@ -593,7 +1151,7 @@ func (gs *LocalGovernanceStore) DumpRateLimits(ctx context.Context, tokenBaselin requestBaselines = map[string]int64{} } - // Collect unique rate limit IDs from virtual keys + // Collect unique rate limit IDs from virtual keys, model configs, and providers rateLimitIDs := make(map[string]bool) gs.virtualKeys.Range(func(key, value interface{}) bool { vk, ok := value.(*configstoreTables.TableVirtualKey) @@ -613,6 +1171,30 @@ func (gs *LocalGovernanceStore) DumpRateLimits(ctx context.Context, tokenBaselin return true // continue }) + // Collect rate limit IDs from model configs + gs.modelConfigs.Range(func(key, value interface{}) bool { + mc, ok := value.(*configstoreTables.TableModelConfig) + if !ok || mc == nil { + return true // continue + } + if mc.RateLimitID != nil { + rateLimitIDs[*mc.RateLimitID] = true + } + return true // continue + }) + + // Collect rate limit IDs from providers + gs.providers.Range(func(key, value interface{}) bool { + provider, ok := value.(*configstoreTables.TableProvider) + if !ok || provider == nil { + return true // continue + } + if provider.RateLimitID != nil { + rateLimitIDs[*provider.RateLimitID] = true + } + return true // continue + }) + // Prepare rate limit usage updates with baselines type rateLimitUpdate struct { ID string @@ -780,8 +1362,20 @@ func (gs *LocalGovernanceStore) loadFromDatabase(ctx context.Context) error { return fmt.Errorf("failed to load rate limits: %w", err) } + // Load model configs + modelConfigs, err := gs.configStore.GetModelConfigs(ctx) + if err != nil { + return fmt.Errorf("failed to load model configs: %w", err) + } + + // Load providers with governance relationships (similar to GetModelConfigs) + providers, err := gs.configStore.GetProviders(ctx) + if err != nil { + return fmt.Errorf("failed to load providers: %w", err) + } + // Rebuild in-memory structures (lock-free) - gs.rebuildInMemoryStructures(ctx, customers, teams, virtualKeys, budgets, rateLimits) + gs.rebuildInMemoryStructures(ctx, customers, teams, virtualKeys, budgets, rateLimits, modelConfigs, providers) return nil } @@ -807,6 +1401,66 @@ func (gs *LocalGovernanceStore) loadFromConfigMemory(ctx context.Context, config // Load rate limits rateLimits := config.RateLimits + // Load model configs + modelConfigs := config.ModelConfigs + + // Load providers + providers := config.Providers + + // Populate model configs with their relationships (Budget and RateLimit) + for i := range modelConfigs { + mc := &modelConfigs[i] + + // Populate budget + if mc.BudgetID != nil { + for j := range budgets { + if budgets[j].ID == *mc.BudgetID { + mc.Budget = &budgets[j] + break + } + } + } + + // Populate rate limit + if mc.RateLimitID != nil { + for j := range rateLimits { + if rateLimits[j].ID == *mc.RateLimitID { + mc.RateLimit = &rateLimits[j] + break + } + } + } + + modelConfigs[i] = *mc + } + + // Populate providers with their relationships (Budget and RateLimit) + for i := range providers { + provider := &providers[i] + + // Populate budget + if provider.BudgetID != nil { + for j := range budgets { + if budgets[j].ID == *provider.BudgetID { + provider.Budget = &budgets[j] + break + } + } + } + + // Populate rate limit + if provider.RateLimitID != nil { + for j := range rateLimits { + if rateLimits[j].ID == *provider.RateLimitID { + provider.RateLimit = &rateLimits[j] + break + } + } + } + + providers[i] = *provider + } + // Populate virtual keys with their relationships for i := range virtualKeys { vk := &virtualKeys[i] @@ -866,19 +1520,21 @@ func (gs *LocalGovernanceStore) loadFromConfigMemory(ctx context.Context, config } // Rebuild in-memory structures (lock-free) - gs.rebuildInMemoryStructures(ctx, customers, teams, virtualKeys, budgets, rateLimits) + gs.rebuildInMemoryStructures(ctx, customers, teams, virtualKeys, budgets, rateLimits, modelConfigs, providers) return nil } // rebuildInMemoryStructures rebuilds all in-memory data structures (lock-free) -func (gs *LocalGovernanceStore) rebuildInMemoryStructures(ctx context.Context, customers []configstoreTables.TableCustomer, teams []configstoreTables.TableTeam, virtualKeys []configstoreTables.TableVirtualKey, budgets []configstoreTables.TableBudget, rateLimits []configstoreTables.TableRateLimit) { +func (gs *LocalGovernanceStore) rebuildInMemoryStructures(ctx context.Context, customers []configstoreTables.TableCustomer, teams []configstoreTables.TableTeam, virtualKeys []configstoreTables.TableVirtualKey, budgets []configstoreTables.TableBudget, rateLimits []configstoreTables.TableRateLimit, modelConfigs []configstoreTables.TableModelConfig, providers []configstoreTables.TableProvider) { // Clear existing data by creating new sync.Maps gs.virtualKeys = sync.Map{} gs.teams = sync.Map{} gs.customers = sync.Map{} gs.budgets = sync.Map{} gs.rateLimits = sync.Map{} + gs.modelConfigs = sync.Map{} + gs.providers = sync.Map{} // Build customers map for i := range customers { @@ -909,6 +1565,28 @@ func (gs *LocalGovernanceStore) rebuildInMemoryStructures(ctx context.Context, c vk := &virtualKeys[i] gs.virtualKeys.Store(vk.Value, vk) } + + // Build model configs map + // Key format: "modelName" for global configs, "modelName:provider" for provider-specific configs + for i := range modelConfigs { + mc := &modelConfigs[i] + if mc.Provider != nil { + // Store under provider-specific key + key := fmt.Sprintf("%s:%s", mc.ModelName, *mc.Provider) + gs.modelConfigs.Store(key, mc) + } else { + // Global config (applies to all providers) - store under model name only + key := mc.ModelName + gs.modelConfigs.Store(key, mc) + } + } + + // Build providers map + // Key format: provider name (e.g., "openai", "anthropic") + for i := range providers { + provider := &providers[i] + gs.providers.Store(provider.Name, provider) + } } // UTILITY FUNCTIONS @@ -1417,6 +2095,144 @@ func (gs *LocalGovernanceStore) DeleteCustomerInMemory(customerID string) { gs.customers.Delete(customerID) } +// UpdateModelConfigInMemory adds or updates a model config in the in-memory store (lock-free) +// Preserves existing usage values when updating budgets and rate limits +// Returns the updated model config with potentially modified usage values +func (gs *LocalGovernanceStore) UpdateModelConfigInMemory(mc *configstoreTables.TableModelConfig) *configstoreTables.TableModelConfig { + if mc == nil { + return nil // Nothing to update + } + + // Clone to avoid modifying the original + clone := *mc + + // Store associated budget if exists, preserving existing in-memory usage + if clone.Budget != nil { + if existingBudgetValue, exists := gs.budgets.Load(clone.Budget.ID); exists && existingBudgetValue != nil { + if eb, ok := existingBudgetValue.(*configstoreTables.TableBudget); ok && eb != nil { + clone.Budget.CurrentUsage = eb.CurrentUsage + } + } + gs.budgets.Store(clone.Budget.ID, clone.Budget) + } + + // Store associated rate limit if exists, preserving existing in-memory usage + if clone.RateLimit != nil { + if existingRateLimitValue, exists := gs.rateLimits.Load(clone.RateLimit.ID); exists && existingRateLimitValue != nil { + if erl, ok := existingRateLimitValue.(*configstoreTables.TableRateLimit); ok && erl != nil { + clone.RateLimit.TokenCurrentUsage = erl.TokenCurrentUsage + clone.RateLimit.RequestCurrentUsage = erl.RequestCurrentUsage + } + } + gs.rateLimits.Store(clone.RateLimit.ID, clone.RateLimit) + } + + // Determine the key based on whether provider is specified + // Key format: "modelName" for global configs, "modelName:provider" for provider-specific configs + if clone.Provider != nil { + key := fmt.Sprintf("%s:%s", clone.ModelName, *clone.Provider) + gs.modelConfigs.Store(key, &clone) + } else { + key := clone.ModelName + gs.modelConfigs.Store(key, &clone) + } + + return &clone +} + +// DeleteModelConfigInMemory removes a model config from the in-memory store (lock-free) +func (gs *LocalGovernanceStore) DeleteModelConfigInMemory(mcID string) { + if mcID == "" { + return // Nothing to delete + } + + // Find and delete the model config by ID + gs.modelConfigs.Range(func(key, value interface{}) bool { + mc, ok := value.(*configstoreTables.TableModelConfig) + if !ok || mc == nil { + return true // continue iteration + } + + if mc.ID == mcID { + // Delete associated budget if exists + if mc.BudgetID != nil { + gs.budgets.Delete(*mc.BudgetID) + } + + // Delete associated rate limit if exists + if mc.RateLimitID != nil { + gs.rateLimits.Delete(*mc.RateLimitID) + } + + gs.modelConfigs.Delete(key) + return false // stop iteration + } + return true // continue iteration + }) +} + +// UpdateProviderInMemory adds or updates a provider in the in-memory store (lock-free) +// Preserves existing usage values when updating budgets and rate limits +// Returns the updated provider with potentially modified usage values +func (gs *LocalGovernanceStore) UpdateProviderInMemory(provider *configstoreTables.TableProvider) *configstoreTables.TableProvider { + if provider == nil { + return nil // Nothing to update + } + + // Clone to avoid modifying the original + clone := *provider + + // Store associated budget if exists, preserving existing in-memory usage + if clone.Budget != nil { + if existingBudgetValue, exists := gs.budgets.Load(clone.Budget.ID); exists && existingBudgetValue != nil { + if eb, ok := existingBudgetValue.(*configstoreTables.TableBudget); ok && eb != nil { + clone.Budget.CurrentUsage = eb.CurrentUsage + } + } + gs.budgets.Store(clone.Budget.ID, clone.Budget) + } + + // Store associated rate limit if exists, preserving existing in-memory usage + if clone.RateLimit != nil { + if existingRateLimitValue, exists := gs.rateLimits.Load(clone.RateLimit.ID); exists && existingRateLimitValue != nil { + if erl, ok := existingRateLimitValue.(*configstoreTables.TableRateLimit); ok && erl != nil { + clone.RateLimit.TokenCurrentUsage = erl.TokenCurrentUsage + clone.RateLimit.RequestCurrentUsage = erl.RequestCurrentUsage + } + } + gs.rateLimits.Store(clone.RateLimit.ID, clone.RateLimit) + } + + // Store under provider name + gs.providers.Store(clone.Name, &clone) + + return &clone +} + +// DeleteProviderInMemory removes a provider from the in-memory store (lock-free) +func (gs *LocalGovernanceStore) DeleteProviderInMemory(providerName string) { + if providerName == "" { + return // Nothing to delete + } + + // Get provider to check for associated budget/rate limit + if providerValue, exists := gs.providers.Load(providerName); exists && providerValue != nil { + if provider, ok := providerValue.(*configstoreTables.TableProvider); ok && provider != nil { + // Delete associated budget if exists + if provider.BudgetID != nil { + gs.budgets.Delete(*provider.BudgetID) + } + + // Delete associated rate limit if exists + if provider.RateLimitID != nil { + gs.rateLimits.Delete(*provider.RateLimitID) + } + } + } + + gs.providers.Delete(providerName) +} + // Helper functions // updateBudgetReferences updates all VKs, teams, customers, and provider configs that reference a reset budget diff --git a/plugins/governance/tracker.go b/plugins/governance/tracker.go index 1a10622a51..25e3d3c360 100644 --- a/plugins/governance/tracker.go +++ b/plugins/governance/tracker.go @@ -67,15 +67,9 @@ func NewUsageTracker(ctx context.Context, store GovernanceStore, resolver *Budge // UpdateUsage queues a usage update for async processing (main business entry point) func (t *UsageTracker) UpdateUsage(ctx context.Context, update *UsageUpdate) { - // Get virtual key - vk, exists := t.store.GetVirtualKey(update.VirtualKey) - if !exists { - return - } - // Only process successful requests for usage tracking if !update.Success { - t.logger.Debug(fmt.Sprintf("Request was not successful, skipping usage update for VK: %s", vk.ID)) + t.logger.Debug("Request was not successful, skipping usage update") return } @@ -84,9 +78,36 @@ func (t *UsageTracker) UpdateUsage(ctx context.Context, update *UsageUpdate) { shouldUpdateRequests := !update.IsStreaming || (update.IsStreaming && update.IsFinalChunk) shouldUpdateBudget := !update.IsStreaming || (update.IsStreaming && update.HasUsageData) + // 1. Update rate limit usage for both provider-level and model-level + // This applies even when virtual keys are disabled or not present + if err := t.store.UpdateProviderAndModelRateLimitUsageInMemory(ctx, update.Model, update.Provider, update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { + t.logger.Error("failed to update rate limit usage for model %s, provider %s: %v", update.Model, update.Provider, err) + } + + // 2. Update budget usage for both provider-level and model-level + // This applies even when virtual keys are disabled or not present + if shouldUpdateBudget && update.Cost > 0 { + if err := t.store.UpdateProviderAndModelBudgetUsageInMemory(ctx, update.Model, update.Provider, update.Cost); err != nil { + t.logger.Error("failed to update budget usage for model %s, provider %s: %v", update.Model, update.Provider, err) + } + } + + // 3. Now handle virtual key-level updates (if virtual key exists) + if update.VirtualKey == "" { + // No virtual key, provider-level and model-level updates already done above + return + } + + // Get virtual key + vk, exists := t.store.GetVirtualKey(update.VirtualKey) + if !exists { + t.logger.Debug(fmt.Sprintf("Virtual key not found: %s", update.VirtualKey)) + return + } + // Update rate limit usage (both provider-level and VK-level) if applicable if vk.RateLimit != nil || len(vk.ProviderConfigs) > 0 { - if err := t.store.UpdateRateLimitUsageInMemory(ctx, vk, update.Provider, update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { + if err := t.store.UpdateVirtualKeyRateLimitUsageInMemory(ctx, vk, update.Provider, update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { t.logger.Error("failed to update rate limit usage for VK %s: %v", vk.ID, err) } } @@ -95,7 +116,7 @@ func (t *UsageTracker) UpdateUsage(ctx context.Context, update *UsageUpdate) { if shouldUpdateBudget && update.Cost > 0 { t.logger.Debug("updating budget usage for VK %s", vk.ID) // Use atomic budget update to prevent race conditions and ensure consistency - if err := t.store.UpdateBudgetUsageInMemory(ctx, vk, update.Provider, update.Cost); err != nil { + if err := t.store.UpdateVirtualKeyBudgetUsageInMemory(ctx, vk, update.Provider, update.Cost); err != nil { t.logger.Error("failed to update budget hierarchy atomically for VK %s: %v", vk.ID, err) } } diff --git a/ui/package-lock.json b/ui/package-lock.json index 1db28a177a..d8f1f861da 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1171,7 +1171,6 @@ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3069,7 +3068,6 @@ "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -3146,6 +3144,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3156,6 +3155,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3221,6 +3221,7 @@ "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.41.0", "@typescript-eslint/types": "8.41.0", @@ -3721,7 +3722,6 @@ "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -3732,24 +3732,21 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", @@ -3757,7 +3754,6 @@ "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -3769,8 +3765,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", @@ -3778,7 +3773,6 @@ "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3792,7 +3786,6 @@ "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -3803,7 +3796,6 @@ "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -3813,8 +3805,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", @@ -3822,7 +3813,6 @@ "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3840,7 +3830,6 @@ "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -3855,7 +3844,6 @@ "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3869,7 +3857,6 @@ "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -3885,7 +3872,6 @@ "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -3896,16 +3882,14 @@ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/acorn": { "version": "8.15.0", @@ -3913,6 +3897,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3926,7 +3911,6 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -3967,7 +3951,6 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3986,7 +3969,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4003,8 +3985,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-styles": { "version": "4.3.0", @@ -4376,8 +4357,7 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/call-bind": { "version": "1.0.8", @@ -4490,7 +4470,6 @@ "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -4575,8 +4554,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", @@ -4968,8 +4946,7 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.203.tgz", "integrity": "sha512-uz4i0vLhfm6dLZWbz/iH88KNDV+ivj5+2SA+utpgjKaj9Q0iDLuwk6Idhe9BTxciHudyx6IvTvijhkPvFGUQ0g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -5131,8 +5108,7 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -5208,7 +5184,6 @@ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -5231,6 +5206,7 @@ "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5320,6 +5296,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5421,6 +5398,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5752,7 +5730,6 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -5830,8 +5807,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fastq": { "version": "1.19.1", @@ -6100,8 +6076,7 @@ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true, - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/globals": { "version": "14.0.0", @@ -6779,7 +6754,6 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -6795,7 +6769,6 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7198,7 +7171,6 @@ "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" } @@ -7299,8 +7271,7 @@ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -7406,7 +7377,8 @@ "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/monaco-editor-webpack-plugin": { "version": "7.1.0", @@ -7474,14 +7446,14 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/next": { "version": "15.5.9", "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "15.5.9", "@swc/helpers": "0.5.15", @@ -7572,8 +7544,7 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/numeric-quantity": { "version": "2.1.0", @@ -7950,6 +7921,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8120,7 +8092,6 @@ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -8130,6 +8101,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8174,6 +8146,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8186,6 +8159,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8278,6 +8252,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8433,7 +8408,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8494,7 +8470,6 @@ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8618,8 +8593,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/safe-push-apply": { "version": "1.0.0", @@ -8668,7 +8642,6 @@ "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -8707,7 +8680,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -8720,8 +8692,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.2", @@ -8742,7 +8713,6 @@ "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -8954,7 +8924,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8974,7 +8943,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -9263,7 +9231,6 @@ "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", @@ -9283,7 +9250,6 @@ "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -9360,6 +9326,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9532,6 +9499,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9630,7 +9598,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -9759,7 +9726,6 @@ "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -9774,7 +9740,6 @@ "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -9824,7 +9789,6 @@ "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -9835,7 +9799,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -9850,7 +9813,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" }