diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index 24f81f72fef..c1106c635ba 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -203,29 +203,35 @@ const ( MCPContextKeyIncludeClients BifrostContextKey = "mcp-include-clients" // Context key for whitelist client filtering MCPContextKeyIncludeTools BifrostContextKey = "mcp-include-tools" // Context key for whitelist tool filtering (Note: toolName should be in "clientName-toolName" format for individual tools, or "clientName-*" for wildcard) - BifrostContextKeySelectedKeyID BifrostContextKey = "bifrost-selected-key-id" // string (to store the selected key ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeySelectedKeyName BifrostContextKey = "bifrost-selected-key-name" // string (to store the selected key name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceVirtualKeyID BifrostContextKey = "bifrost-governance-virtual-key-id" // string (to store the virtual key ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceVirtualKeyName BifrostContextKey = "bifrost-governance-virtual-key-name" // string (to store the virtual key name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceTeamID BifrostContextKey = "bifrost-governance-team-id" // string (to store the team ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceTeamName BifrostContextKey = "bifrost-governance-team-name" // string (to store the team name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceCustomerID BifrostContextKey = "bifrost-governance-customer-id" // string (to store the customer ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceCustomerName BifrostContextKey = "bifrost-governance-customer-name" // string (to store the customer name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceBusinessUnitID BifrostContextKey = "bifrost-governance-business-unit-id" // string (to store the business unit ID (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceBusinessUnitName BifrostContextKey = "bifrost-governance-business-unit-name" // string (to store the business unit name (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceRoutingRuleID BifrostContextKey = "bifrost-governance-routing-rule-id" // string (to store the routing rule ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceRoutingRuleName BifrostContextKey = "bifrost-governance-routing-rule-name" // string (to store the routing rule name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeySelectedPromptName BifrostContextKey = "bifrost-selected-prompt-name" // string (display name of the selected prompt (set by prompts plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeySelectedPromptVersion BifrostContextKey = "bifrost-selected-prompt-version" // string (numeric version as string, e.g. "3" (set by prompts plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeySelectedPromptID BifrostContextKey = "bifrost-selected-prompt-id" // string (id of the selected prompt (set by prompts plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceIncludeOnlyKeys BifrostContextKey = "bf-governance-include-only-keys" // []string (to store the include-only key IDs for provider config routing (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyNumberOfRetries BifrostContextKey = "bifrost-number-of-retries" // int (to store the number of retries (set by bifrost - DO NOT SET THIS MANUALLY)) - BifrostContextKeyFallbackIndex BifrostContextKey = "bifrost-fallback-index" // int (to store the fallback index (set by bifrost - DO NOT SET THIS MANUALLY)) 0 for primary, 1 for first fallback, etc. - BifrostContextKeyStreamEndIndicator BifrostContextKey = "bifrost-stream-end-indicator" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) - BifrostContextKeyStreamIdleTimeout BifrostContextKey = "bifrost-stream-idle-timeout" // time.Duration (per-chunk idle timeout for streaming) - BifrostContextKeySkipKeySelection BifrostContextKey = "bifrost-skip-key-selection" // bool (will pass an empty key to the provider) - BifrostContextKeyExtraHeaders BifrostContextKey = "bifrost-extra-headers" // map[string][]string - BifrostContextKeyURLPath BifrostContextKey = "bifrost-extra-url-path" // string + BifrostContextKeySelectedKeyID BifrostContextKey = "bifrost-selected-key-id" // string (to store the selected key ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeySelectedKeyName BifrostContextKey = "bifrost-selected-key-name" // string (to store the selected key name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceVirtualKeyID BifrostContextKey = "bifrost-governance-virtual-key-id" // string (to store the virtual key ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceVirtualKeyName BifrostContextKey = "bifrost-governance-virtual-key-name" // string (to store the virtual key name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceTeamID BifrostContextKey = "bifrost-governance-team-id" // string (to store the team ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceTeamName BifrostContextKey = "bifrost-governance-team-name" // string (to store the team name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceCustomerID BifrostContextKey = "bifrost-governance-customer-id" // string (to store the customer ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceCustomerName BifrostContextKey = "bifrost-governance-customer-name" // string (to store the customer name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceBusinessUnitID BifrostContextKey = "bifrost-governance-business-unit-id" // string (to store the business unit ID (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceBusinessUnitName BifrostContextKey = "bifrost-governance-business-unit-name" // string (to store the business unit name (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceTeamIDs BifrostContextKey = "bifrost-governance-team-ids" // []string (all teams a user/AP request belongs to; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceTeamNames BifrostContextKey = "bifrost-governance-team-names" // []string (display names, aligned with team-ids; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceBusinessUnitIDs BifrostContextKey = "bifrost-governance-business-unit-ids" // []string (distinct BUs across the user's teams; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceBusinessUnitNames BifrostContextKey = "bifrost-governance-business-unit-names" // []string (display names, aligned with business-unit-ids; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceCustomerIDs BifrostContextKey = "bifrost-governance-customer-ids" // []string (distinct customers a user/team request belongs to; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceCustomerNames BifrostContextKey = "bifrost-governance-customer-names" // []string (display names, aligned with customer-ids; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceRoutingRuleID BifrostContextKey = "bifrost-governance-routing-rule-id" // string (to store the routing rule ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceRoutingRuleName BifrostContextKey = "bifrost-governance-routing-rule-name" // string (to store the routing rule name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeySelectedPromptName BifrostContextKey = "bifrost-selected-prompt-name" // string (display name of the selected prompt (set by prompts plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeySelectedPromptVersion BifrostContextKey = "bifrost-selected-prompt-version" // string (numeric version as string, e.g. "3" (set by prompts plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeySelectedPromptID BifrostContextKey = "bifrost-selected-prompt-id" // string (id of the selected prompt (set by prompts plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceIncludeOnlyKeys BifrostContextKey = "bf-governance-include-only-keys" // []string (to store the include-only key IDs for provider config routing (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyNumberOfRetries BifrostContextKey = "bifrost-number-of-retries" // int (to store the number of retries (set by bifrost - DO NOT SET THIS MANUALLY)) + BifrostContextKeyFallbackIndex BifrostContextKey = "bifrost-fallback-index" // int (to store the fallback index (set by bifrost - DO NOT SET THIS MANUALLY)) 0 for primary, 1 for first fallback, etc. + BifrostContextKeyStreamEndIndicator BifrostContextKey = "bifrost-stream-end-indicator" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) + BifrostContextKeyStreamIdleTimeout BifrostContextKey = "bifrost-stream-idle-timeout" // time.Duration (per-chunk idle timeout for streaming) + BifrostContextKeySkipKeySelection BifrostContextKey = "bifrost-skip-key-selection" // bool (will pass an empty key to the provider) + BifrostContextKeyExtraHeaders BifrostContextKey = "bifrost-extra-headers" // map[string][]string + BifrostContextKeyURLPath BifrostContextKey = "bifrost-extra-url-path" // string BifrostContextKeyUseRawRequestBody BifrostContextKey = "bifrost-use-raw-request-body" BifrostContextKeyChangeRequestType BifrostContextKey = "bifrost-change-request-type" // RequestType (set by plugins to trigger request type conversion in core, e.g. text->chat or chat->responses) BifrostContextKeySendBackRawRequest BifrostContextKey = "bifrost-send-back-raw-request" // bool (per-request override — read by bifrost.go, never overwritten) @@ -1433,7 +1439,7 @@ type BifrostResponseExtraFields struct { ConvertedRequestType RequestType `json:"converted_request_type,omitempty"` DroppedCompatPluginParams []string `json:"dropped_compat_plugin_params,omitempty"` // params dropped by the compat plugin based on model catalog ProviderResponseHeaders map[string]string `json:"provider_response_headers,omitempty"` // HTTP response headers from the provider (filtered to exclude transport-level headers) - PassthroughPath string `json:"passthrough_path,omitempty"` // Stripped provider path for passthrough requests, e.g. "/v1/chat/completions" + PassthroughPath string `json:"passthrough_path,omitempty"` // Stripped provider path for passthrough requests, e.g. "/v1/chat/completions" } type BifrostMCPResponseExtraFields struct { @@ -1670,15 +1676,15 @@ func (e *ErrorField) UnmarshalJSON(data []byte) error { // BifrostErrorExtraFields contains additional fields in an error response. type BifrostErrorExtraFields struct { - Provider ModelProvider `json:"provider,omitempty"` - OriginalModelRequested string `json:"original_model_requested,omitempty"` - ResolvedModelUsed string `json:"resolved_model_used,omitempty"` - RequestType RequestType `json:"request_type,omitempty"` - MCPRequestType MCPRequestType `json:"mcp_request_type,omitempty"` - RawRequest interface{} `json:"raw_request,omitempty"` - RawResponse interface{} `json:"raw_response,omitempty"` - ConvertedRequestType RequestType `json:"converted_request_type,omitempty"` - DroppedCompatPluginParams []string `json:"dropped_compat_plugin_params,omitempty"` - KeyStatuses []KeyStatus `json:"key_statuses,omitempty"` - MCPAuthRequired *MCPAuthRequiredError `json:"mcp_auth_required,omitempty"` // Set when a per-user MCP tool requires the caller to complete an inline auth flow (OAuth or headers) + Provider ModelProvider `json:"provider,omitempty"` + OriginalModelRequested string `json:"original_model_requested,omitempty"` + ResolvedModelUsed string `json:"resolved_model_used,omitempty"` + RequestType RequestType `json:"request_type,omitempty"` + MCPRequestType MCPRequestType `json:"mcp_request_type,omitempty"` + RawRequest interface{} `json:"raw_request,omitempty"` + RawResponse interface{} `json:"raw_response,omitempty"` + ConvertedRequestType RequestType `json:"converted_request_type,omitempty"` + DroppedCompatPluginParams []string `json:"dropped_compat_plugin_params,omitempty"` + KeyStatuses []KeyStatus `json:"key_statuses,omitempty"` + MCPAuthRequired *MCPAuthRequiredError `json:"mcp_auth_required,omitempty"` // Set when a per-user MCP tool requires the caller to complete an inline auth flow (OAuth or headers) } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index 967a86b3c4f..10046457416 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -850,6 +850,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddCustomerBudgetsToBudgetsTable(ctx, db); err != nil { return err } + if err := migrationAddModelConfigBudgetsFKConstraint(ctx, db); err != nil { + return err + } return nil } @@ -7382,6 +7385,99 @@ func migrationAddTeamBudgetsToBudgetsTable(ctx context.Context, db *gorm.DB) err return nil } +// migrationAddModelConfigBudgetsFKConstraint adds the missing +// governance_budgets.model_config_id -> governance_model_configs(id) +// ON DELETE CASCADE foreign key (defined on TableModelConfig.Budgets via +// foreignKey:ModelConfigID;constraint:OnDelete:CASCADE). +// +// migrationAddMultiBudgetTables created the equivalent cascade FKs for VK- and +// ProviderConfig-owned budgets but never the model-config edge, and +// migrationAddBudgetModelConfigIDColumn added the column without a constraint. +// As a result deleting a model config never cascaded to its multi-budget rows, +// so they leaked (orphaned governance_budgets whose model_config_id points at a +// since-deleted config). This makes that cleanup structurally sound at the DB +// level, underneath the existing application-level cleanup in +// DeleteModelConfigsForScope/DeleteModelConfig. (The single owned rate-limit is +// intentionally left to application cleanup — rate-limits use the opposite +// owner.rate_limit_id convention, so reversing it just for model configs would +// introduce a one-off ownership split for a one-row-per-config leak surface.) +func migrationAddModelConfigBudgetsFKConstraint(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_model_config_budgets_fk_constraint", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + + // Pre-clean: budgets whose model_config_id already references a + // missing config would violate the FK we're about to add and block + // its creation. They are exactly the rows the cascade would have + // removed, so delete them — but only when nothing live still + // references them via the legacy governance_model_configs.budget_id + // (a NO ACTION FK), so this DELETE can't trip that constraint. + if err := tx.Exec(` + DELETE FROM governance_budgets + WHERE model_config_id IS NOT NULL + AND model_config_id NOT IN (SELECT id FROM governance_model_configs) + AND id NOT IN ( + SELECT budget_id FROM governance_model_configs WHERE budget_id IS NOT NULL + ) + `).Error; err != nil { + return fmt.Errorf("failed to pre-clean orphaned model-config budgets: %w", err) + } + + // Create the cascade FK (no-op if a prior fresh-DB migrate already made it). + if !mg.HasConstraint(&tables.TableModelConfig{}, "Budgets") { + if err := mg.CreateConstraint(&tables.TableModelConfig{}, "Budgets"); err != nil { + return fmt.Errorf("failed to create FK constraint for ModelConfig -> Budgets: %w", err) + } + } + return nil + }, + // Partially non-rollbackable: dropping the FK restores the previous + // schema, but the orphaned governance_budgets rows removed by the + // pre-clean are gone permanently. That loss is intentional — they were + // exactly the dead rows the missing cascade had leaked — so the schema + // rollback below is still provided rather than hard-failing. + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + if mg.HasConstraint(&tables.TableModelConfig{}, "Budgets") { + if err := mg.DropConstraint(&tables.TableModelConfig{}, "Budgets"); err != nil { + return err + } + } + return nil + }, + }}) + // SQLite workaround — same reasoning as migrationAddMultiBudgetTables: + // CreateConstraint rebuilds governance_budgets via DROP+RENAME inside a + // transaction, which fails while other tables hold FKs into it and + // foreign_keys is ON. PRAGMA foreign_keys can't change inside a transaction, + // so disable it (pinned to one connection) before the migrator opens its tx. + // Postgres supports ALTER TABLE ADD CONSTRAINT natively and needs none of this. + if db.Dialector.Name() == "sqlite" { + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("failed to get underlying sql.DB: %w", err) + } + sqlDB.SetMaxOpenConns(1) + defer sqlDB.SetMaxOpenConns(0) + + if err := db.Exec("PRAGMA foreign_keys = OFF").Error; err != nil { + return fmt.Errorf("failed to disable SQLite foreign keys: %w", err) + } + defer func() { + if err := db.Exec("PRAGMA foreign_keys = ON").Error; err != nil { + log.Fatalf("[Migration] FATAL: failed to re-enable SQLite foreign keys: %v", err) + } + }() + } + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running add_model_config_budgets_fk_constraint migration: %s", err.Error()) + } + return nil +} + // migrationAddPerUserOAuthTables adds the oauth_user_sessions and oauth_user_tokens tables func migrationAddPerUserOAuthTables(ctx context.Context, db *gorm.DB) error { m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ diff --git a/framework/configstore/migrations_test.go b/framework/configstore/migrations_test.go index 7dbd212939e..f522c7cb955 100644 --- a/framework/configstore/migrations_test.go +++ b/framework/configstore/migrations_test.go @@ -2336,6 +2336,72 @@ func TestMigrationAddTeamBudgetsToBudgetsTable_DropsLegacyBudgetColumnAndBackfil assertNoCorruptedFKReferences(t, db) } +// After the full migration chain, deleting a model config row must cascade-delete +// its model_config_id-owned budgets at the DB level (foreign_keys on) — no +// application code involved. Regression guard for migrationAddModelConfigBudgetsFKConstraint. +func TestMigrationAddModelConfigBudgetsFKConstraint_CascadesOnDelete(t *testing.T) { + ctx := context.Background() + n := time.Now().UnixNano() + testDBCounter + testDBCounter++ + // Shared-cache so the pool sees one DB; _foreign_keys=on so the cascade is enforced + // (mirrors the production DSN in sqlite.go). + dsn := fmt.Sprintf("file:fkcascade_%d?mode=memory&cache=shared&_foreign_keys=on", n) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, triggerMigrations(ctx, db)) + + require.True(t, db.Migrator().HasConstraint(&tables.TableModelConfig{}, "Budgets"), + "model_config -> budgets FK should exist after the migration chain") + + now := time.Now() + mc := tables.TableModelConfig{ID: "mc-cascade", ModelName: "gpt-4", Scope: tables.ModelConfigScopeGlobal, CreatedAt: now, UpdatedAt: now} + require.NoError(t, db.Create(&mc).Error) + b := tables.TableBudget{ID: "b-cascade", MaxLimit: 10, ResetDuration: "1h", LastReset: now, ModelConfigID: &mc.ID, CreatedAt: now, UpdatedAt: now} + require.NoError(t, db.Create(&b).Error) + + // Delete the model config DIRECTLY, bypassing application-level cleanup. + require.NoError(t, db.Exec("DELETE FROM governance_model_configs WHERE id = ?", mc.ID).Error) + + var cnt int64 + require.NoError(t, db.Model(&tables.TableBudget{}).Where("id = ?", "b-cascade").Count(&cnt).Error) + assert.Equal(t, int64(0), cnt, "owned budget should be cascade-deleted with its model config") +} + +// The migration must pre-clean budgets whose model_config_id already references a +// missing config (existing orphans) — otherwise the FK creation would fail on a DB +// that already has the leak. Valid budgets must be retained. +func TestMigrationAddModelConfigBudgetsFKConstraint_PreCleansOrphans(t *testing.T) { + ctx := context.Background() + n := time.Now().UnixNano() + testDBCounter + testDBCounter++ + // foreign_keys OFF (default) so we can plant an orphan and drop the auto-created FK + // to reproduce the production "column present, constraint missing" starting state. + dsn := fmt.Sprintf("file:fkpreclean_%d?mode=memory&cache=shared", n) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&tables.TableModelConfig{}, &tables.TableBudget{}, &tables.TableRateLimit{})) + if db.Migrator().HasConstraint(&tables.TableModelConfig{}, "Budgets") { + require.NoError(t, db.Migrator().DropConstraint(&tables.TableModelConfig{}, "Budgets")) + } + + now := time.Now() + live := tables.TableModelConfig{ID: "mc-live", ModelName: "gpt-4", Scope: tables.ModelConfigScopeGlobal, CreatedAt: now, UpdatedAt: now} + require.NoError(t, db.Create(&live).Error) + require.NoError(t, db.Create(&tables.TableBudget{ID: "b-live", MaxLimit: 1, ResetDuration: "1h", LastReset: now, ModelConfigID: &live.ID, CreatedAt: now, UpdatedAt: now}).Error) + ghost := "mc-ghost" + require.NoError(t, db.Create(&tables.TableBudget{ID: "b-orphan", MaxLimit: 1, ResetDuration: "1h", LastReset: now, ModelConfigID: &ghost, CreatedAt: now, UpdatedAt: now}).Error) + + require.NoError(t, migrationAddModelConfigBudgetsFKConstraint(ctx, db)) + + var orphan, liveB int64 + require.NoError(t, db.Model(&tables.TableBudget{}).Where("id = ?", "b-orphan").Count(&orphan).Error) + require.NoError(t, db.Model(&tables.TableBudget{}).Where("id = ?", "b-live").Count(&liveB).Error) + assert.Equal(t, int64(0), orphan, "orphaned model-config budget should be pre-cleaned by the migration") + assert.Equal(t, int64(1), liveB, "valid model-config budget must be retained") + assert.True(t, db.Migrator().HasConstraint(&tables.TableModelConfig{}, "Budgets"), + "FK constraint should exist after the migration") +} + // migration is part of the startup chain so a fresh DB emerges with // calendar_aligned on its current owners — the virtual key and the team — // and the legacy per-budget / per-rate-limit columns cleaned up. diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 629845f102b..d3ef71ca055 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -1164,43 +1164,9 @@ func (s *RDBConfigStore) DeleteProvider(ctx context.Context, provider schemas.Mo } // Clean up model configs scoped to this provider (and their owned budgets/rate-limits). - // Delete by snapshotted IDs rather than a second WHERE provider=? pass to avoid a race - // where a concurrent CreateModelConfig lands between the snapshot and the delete, leaving - // its owned budget/rate-limit rows dangling. - var providerModelConfigs []tables.TableModelConfig - if err := txDB.WithContext(ctx).Preload("Budgets").Where("provider = ?", string(provider)).Find(&providerModelConfigs).Error; err != nil { + if err := s.deleteModelConfigsWhere(ctx, txDB, "provider = ?", string(provider)); err != nil { return err } - if len(providerModelConfigs) > 0 { - var mcIDs []string - var budgetIDs []string - var rateLimitIDs []string - for i := range providerModelConfigs { - mcIDs = append(mcIDs, providerModelConfigs[i].ID) - for j := range providerModelConfigs[i].Budgets { - budgetIDs = append(budgetIDs, providerModelConfigs[i].Budgets[j].ID) - } - if providerModelConfigs[i].BudgetID != nil { - budgetIDs = append(budgetIDs, *providerModelConfigs[i].BudgetID) - } - if providerModelConfigs[i].RateLimitID != nil { - rateLimitIDs = append(rateLimitIDs, *providerModelConfigs[i].RateLimitID) - } - } - if err := txDB.WithContext(ctx).Where("id IN ?", mcIDs).Delete(&tables.TableModelConfig{}).Error; err != nil { - return err - } - if len(budgetIDs) > 0 { - if err := txDB.WithContext(ctx).Delete(&tables.TableBudget{}, "id IN ?", budgetIDs).Error; err != nil { - return err - } - } - if len(rateLimitIDs) > 0 { - if err := txDB.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id IN ?", rateLimitIDs).Error; err != nil { - return err - } - } - } return nil } @@ -3068,39 +3034,9 @@ func (s *RDBConfigStore) DeleteVirtualKey(ctx context.Context, id string, tx ... // Delete model configs scoped to this virtual key, along with their owned // budgets/rate-limits. scope_id has no FK constraint, so this cleanup must be // explicit; otherwise per-VK model limits would orphan and leak budget/rate-limit rows. - // Model configs are deleted first (matching DeleteModelConfig order) before their - // owned budget/rate-limit rows. - var scopedModelConfigs []tables.TableModelConfig - if err := txDB.WithContext(ctx). - Where("scope = ? AND scope_id = ?", tables.ModelConfigScopeVirtualKey, id). - Find(&scopedModelConfigs).Error; err != nil { + if err := s.DeleteModelConfigsForScope(ctx, txDB, tables.ModelConfigScopeVirtualKey, id); err != nil { return err } - budgetIDs := make([]string, 0, len(scopedModelConfigs)) - rateLimitIDs := make([]string, 0, len(scopedModelConfigs)) - for _, mc := range scopedModelConfigs { - if mc.BudgetID != nil { - budgetIDs = append(budgetIDs, *mc.BudgetID) - } - if mc.RateLimitID != nil { - rateLimitIDs = append(rateLimitIDs, *mc.RateLimitID) - } - } - if err := txDB.WithContext(ctx). - Where("scope = ? AND scope_id = ?", tables.ModelConfigScopeVirtualKey, id). - Delete(&tables.TableModelConfig{}).Error; err != nil { - return err - } - if len(budgetIDs) > 0 { - if err := txDB.WithContext(ctx).Delete(&tables.TableBudget{}, "id IN ?", budgetIDs).Error; err != nil { - return err - } - } - if len(rateLimitIDs) > 0 { - if err := txDB.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id IN ?", rateLimitIDs).Error; err != nil { - return err - } - } rateLimitID := virtualKey.RateLimitID // Delete the virtual key if err := txDB.WithContext(ctx).Delete(&tables.TableVirtualKey{}, "id = ?", id).Error; err != nil { @@ -4478,20 +4414,142 @@ func (s *RDBConfigStore) GetModelConfigByID(ctx context.Context, id string) (*ta return &modelConfig, nil } +// deleteModelConfigsWhere deletes every model config matching the given condition, +// along with the budgets and rate-limits those configs own. It is the single source +// of truth for tearing down model configs when their owner (a virtual key, provider, +// user, …) is removed — every owner-delete path funnels through here so the cleanup, +// including the easy-to-forget preload of multi-budget rows, lives in exactly one place. +// +// Owned budgets are gathered from BOTH the active Budgets slice (owned via +// ModelConfigID) and the legacy single BudgetID column. Deletion happens by +// snapshotted ID rather than re-running the WHERE clause, so a concurrent +// CreateModelConfig that lands between the snapshot and the delete can't leave its +// owned budget/rate-limit rows dangling. The snapshot is taken FOR UPDATE +// (mirroring DeleteModelConfig) so a concurrent UpdateModelConfig can't swap +// BudgetID/RateLimitID after the IDs are collected; rows are locked in stable id +// order to keep concurrent deleters deadlock-free. Configs are removed before +// their owned rows, matching DeleteModelConfig's order. +func (s *RDBConfigStore) deleteModelConfigsWhere(ctx context.Context, txDB *gorm.DB, query string, args ...any) error { + var modelConfigs []tables.TableModelConfig + if err := dbForUpdate(txDB.WithContext(ctx)).Preload("Budgets").Order("id").Where(query, args...).Find(&modelConfigs).Error; err != nil { + return err + } + if len(modelConfigs) == 0 { + return nil + } + + mcIDs := make([]string, 0, len(modelConfigs)) + budgetIDs := make([]string, 0, len(modelConfigs)) + rateLimitIDs := make([]string, 0, len(modelConfigs)) + for i := range modelConfigs { + mcIDs = append(mcIDs, modelConfigs[i].ID) + for j := range modelConfigs[i].Budgets { + budgetIDs = append(budgetIDs, modelConfigs[i].Budgets[j].ID) + } + if modelConfigs[i].BudgetID != nil { + budgetIDs = append(budgetIDs, *modelConfigs[i].BudgetID) + } + if modelConfigs[i].RateLimitID != nil { + rateLimitIDs = append(rateLimitIDs, *modelConfigs[i].RateLimitID) + } + } + + if err := txDB.WithContext(ctx).Where("id IN ?", mcIDs).Delete(&tables.TableModelConfig{}).Error; err != nil { + return err + } + if len(budgetIDs) > 0 { + if err := txDB.WithContext(ctx).Delete(&tables.TableBudget{}, "id IN ?", budgetIDs).Error; err != nil { + return err + } + } + if len(rateLimitIDs) > 0 { + if err := txDB.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id IN ?", rateLimitIDs).Error; err != nil { + return err + } + } + return nil +} + +// DeleteModelConfigsForScope removes all model configs targeting a given scope owner +// (e.g. scope=virtual_key, scopeID=) along with their owned budgets/rate-limits. +// Thin wrapper over deleteModelConfigsWhere for the scope/scope_id axis. Exported so +// out-of-package owner-delete paths (e.g. the enterprise user-deletion flow cleaning up +// scope=user configs) funnel through the same cleanup instead of reimplementing it. +func (s *RDBConfigStore) DeleteModelConfigsForScope(ctx context.Context, txDB *gorm.DB, scope, scopeID string) error { + // The tx is required (not variadic) on purpose: this cleanup must be atomic + // with the owner's delete. Guard against nil rather than falling back to + // s.DB(), which would silently run the cleanup outside that transaction. + if txDB == nil { + return fmt.Errorf("DeleteModelConfigsForScope requires the owner-delete transaction, got nil tx") + } + return s.deleteModelConfigsWhere(ctx, txDB, "scope = ? AND scope_id = ?", scope, scopeID) +} + // 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() + // Locking the scope owner and inserting the config must be atomic, so wrap in a + // transaction when the caller didn't supply one. + if len(tx) == 0 || tx[0] == nil { + return s.DB().WithContext(ctx).Transaction(func(transaction *gorm.DB) error { + return s.CreateModelConfig(ctx, modelConfig, transaction) + }) + } + txDB := tx[0] + + // Serialize against deletion of the scope owner. A scoped config's scope_id carries + // no FK, so without this a CreateModelConfig for scope=virtual_key could commit just + // after a concurrent DeleteVirtualKey, leaving the config (and its owned budgets/ + // rate-limits) pointing at a virtual key that no longer exists. Locking the owner row + // makes the two transactions mutually exclusive and surfaces an already-deleted owner + // as ErrNotFound. Callers that create owner-scoped configs must create the owner first. + if err := s.lockModelConfigScopeOwner(ctx, txDB, modelConfig); err != nil { + return err } + if err := txDB.WithContext(ctx).Create(modelConfig).Error; err != nil { return s.parseGormError(err) } return nil } +// lockModelConfigScopeOwner takes a FOR UPDATE lock on the row a scoped model config +// targets and confirms it still exists, returning ErrNotFound when the owner is gone. +// Global configs (no scope owner) are a no-op. Only scopes whose owner table lives in +// this store are locked; other scopes (e.g. the enterprise "user" scope, whose owner +// table is out of package) are the responsibility of their own create path. The lock is +// FOR UPDATE on Postgres and a plain existence check on SQLite (whose writer +// serialization already prevents the interleave), mirroring lockBudgetOwner. +// +// Provider-bound configs (any scope) are additionally serialized against +// DeleteProvider, which tears down every config matching the provider column. The +// provider row is optional — providers may be env-configured with no DB row — so +// absence is tolerated rather than treated as a missing owner. +func (s *RDBConfigStore) lockModelConfigScopeOwner(ctx context.Context, txDB *gorm.DB, mc *tables.TableModelConfig) error { + if mc == nil { + return nil + } + if mc.Provider != nil && *mc.Provider != "" { + var provider tables.TableProvider + if err := dbForUpdate(txDB.WithContext(ctx)).First(&provider, "name = ?", *mc.Provider).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + if mc.ScopeID == nil || *mc.ScopeID == "" { + return nil + } + switch mc.Scope { + case tables.ModelConfigScopeVirtualKey: + var vk tables.TableVirtualKey + if err := dbForUpdate(txDB.WithContext(ctx)).First(&vk, "id = ?", *mc.ScopeID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return 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 { if len(tx) == 0 { diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go index 227c437d4e3..7047f47166e 100644 --- a/framework/configstore/rdb_test.go +++ b/framework/configstore/rdb_test.go @@ -692,6 +692,87 @@ func TestDeleteVirtualKey_CleansUpScopedModelConfigs(t *testing.T) { assert.Equal(t, int64(0), rlCount, "owned rate limit should be deleted") } +// TestDeleteVirtualKey_CleansUpMultiBudgetScopedModelConfigs is a regression test for a +// leak where DeleteVirtualKey cleaned only the legacy single BudgetID column and ignored +// the modern multi-budget rows owned via TableBudget.ModelConfigID. Those budgets carry no +// virtual_key_id, so the VK's own budget sweep didn't catch them either — they orphaned. +func TestDeleteVirtualKey_CleansUpMultiBudgetScopedModelConfigs(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + vk := &tables.TableVirtualKey{ + ID: "vk-multibudget", + Name: "MultiBudget VK", + Value: "vk-multibudget-value", + IsActive: schemas.Ptr(true), + } + require.NoError(t, store.CreateVirtualKey(ctx, vk)) + + rateLimit := &tables.TableRateLimit{ + ID: "rl-mb", + TokenMaxLimit: schemas.Ptr(int64(1000)), + TokenResetDuration: schemas.Ptr("1h"), + } + require.NoError(t, store.CreateRateLimit(ctx, rateLimit)) + + mc := &tables.TableModelConfig{ + ID: "mc-multibudget", + ModelName: "gpt-4", + Scope: tables.ModelConfigScopeVirtualKey, + ScopeID: schemas.Ptr(vk.ID), + RateLimitID: &rateLimit.ID, + } + require.NoError(t, store.CreateModelConfig(ctx, mc)) + + // Modern budgets are owned via ModelConfigID (no virtual_key_id), mirroring how the + // governance handler creates them. + mbBudgetIDs := []string{"b-mb-1", "b-mb-2"} + for _, id := range mbBudgetIDs { + require.NoError(t, store.CreateBudget(ctx, &tables.TableBudget{ + ID: id, + MaxLimit: 100, + ResetDuration: "1h", + ModelConfigID: &mc.ID, + })) + } + + require.NoError(t, store.DeleteVirtualKey(ctx, vk.ID)) + + _, err := store.GetModelConfigByID(ctx, "mc-multibudget") + assert.Error(t, err, "scoped model config should be deleted with the VK") + + for _, id := range mbBudgetIDs { + var count int64 + require.NoError(t, store.DB().Model(&tables.TableBudget{}).Where("id = ?", id).Count(&count).Error) + assert.Equal(t, int64(0), count, "modern model-config budget %s should be deleted, not orphaned", id) + } + + var rlCount int64 + require.NoError(t, store.DB().Model(&tables.TableRateLimit{}).Where("id = ?", "rl-mb").Count(&rlCount).Error) + assert.Equal(t, int64(0), rlCount, "owned rate limit should be deleted") +} + +// TestCreateModelConfig_RejectsMissingScopeOwner verifies the scope-owner lock in +// CreateModelConfig: a virtual_key-scoped config whose scope_id points at a non-existent +// VK is rejected (ErrNotFound) rather than created as an orphan. This is the guard that +// closes the CreateModelConfig↔DeleteVirtualKey race. +func TestCreateModelConfig_RejectsMissingScopeOwner(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + mc := &tables.TableModelConfig{ + ID: "mc-orphan", + ModelName: "gpt-4", + Scope: tables.ModelConfigScopeVirtualKey, + ScopeID: schemas.Ptr("vk-does-not-exist"), + } + err := store.CreateModelConfig(ctx, mc) + assert.ErrorIs(t, err, ErrNotFound, "creating a VK-scoped config for a missing VK should be rejected") + + _, getErr := store.GetModelConfigByID(ctx, "mc-orphan") + assert.Error(t, getErr, "rejected config must not have been persisted") +} + func TestDeleteProvider_CleansUpProviderModelConfigs(t *testing.T) { store := setupRDBTestStore(t) ctx := context.Background() diff --git a/framework/configstore/store.go b/framework/configstore/store.go index 8b447f56ce0..9f37f0a53f4 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -267,6 +267,8 @@ type ConfigStore interface { 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, tx ...*gorm.DB) error + // DeleteModelConfigsForScope deletes all model configs (and their owned budgets/rate-limits) for a scope owner. Must run inside the owner-delete transaction. + DeleteModelConfigsForScope(ctx context.Context, tx *gorm.DB, scope, scopeID string) error // Governance config CRUD GetGovernanceConfig(ctx context.Context) (*GovernanceConfig, error) diff --git a/framework/logstore/matviews.go b/framework/logstore/matviews.go index d341c209ed6..5a46e58cd0a 100644 --- a/framework/logstore/matviews.go +++ b/framework/logstore/matviews.go @@ -37,6 +37,7 @@ SELECT COALESCE(team_id, '') AS team_id, COALESCE(customer_id, '') AS customer_id, COALESCE(business_unit_id, '') AS business_unit_id, + COALESCE(alias, '') AS alias, COUNT(*) AS count, SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count, SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count, @@ -51,7 +52,7 @@ SELECT COALESCE(SUM(cost), 0) AS total_cost FROM logs WHERE status IN ('success', 'error') -GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 +GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 ` // mvLogsHourlyUniqueIdx is required for REFRESH MATERIALIZED VIEW CONCURRENTLY. @@ -59,7 +60,7 @@ GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 // during startup ensure / repair paths. const mvLogsHourlyUniqueIdx = ` CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS mv_logs_hourly_uniq -ON mv_logs_hourly (hour, provider, model, status, object_type, selected_key_id, virtual_key_id, routing_rule_id, user_id, team_id, customer_id, business_unit_id) +ON mv_logs_hourly (hour, provider, model, status, object_type, selected_key_id, virtual_key_id, routing_rule_id, user_id, team_id, customer_id, business_unit_id, alias) ` // mvLogsHourlyRequiredColumns is the canonical column set used by @@ -78,6 +79,7 @@ var mvLogsHourlyRequiredColumns = []string{ "team_id", "customer_id", "business_unit_id", + "alias", } // legacyMatViewNames are matviews from previous schema versions that no longer @@ -116,12 +118,38 @@ const filterDataMatViewWindow = "30 days" // - requiredColumns: resolved column aliases used by repairMatViewShapes // to detect drifted matviews. Declared explicitly (not parsed from // selectExpr) so SQL fragments like COALESCE(...) cannot poison the check. +// - bodyOverride: when set, replaces the whole `SELECT DISTINCT ... FROM +// logs WHERE ...` body (selectExpr/whereExpr are ignored). Used by the +// multi-valued team / business-unit views, which must union the scalar +// column with the JSON-array column rather than read a single column. type filterMatViewDef struct { name string selectExpr string whereExpr string uniqueIdx string requiredColumns []string + bodyOverride string +} + +// multiValueFilterMatViewBody builds the SELECT body for a filter matview whose +// dimension is single-valued on the scalar column (old / pre-migration rows and +// the VK-team path) and multi-valued on the JSON-array column (the enterprise +// user/AP path). It reuses teamOrBUFanoutFrom — the same fan-out the ranking / +// histogram readers use — so a team / business unit that only ever appears in +// the JSON array still surfaces in the filter dropdown. The fanned-out +// dim_id/dim_name become the dropdown id/name; the visibility columns +// (user_id, team_id, virtual_key_id) come from the original log row (exposed via +// l.* by the fan-out subquery) so DAC scope still applies. idCol is the scalar +// id column ("team_id" / "business_unit_id"). +func multiValueFilterMatViewBody(idCol string) string { + from, _ := teamOrBUFanoutFrom(idCol) + return fmt.Sprintf( + "SELECT DISTINCT dim_id AS id, dim_name AS name, "+ + "COALESCE(user_id, '') AS user_id, COALESCE(team_id, '') AS team_id, "+ + "COALESCE(virtual_key_id, '') AS virtual_key_id "+ + "FROM %s WHERE timestamp >= NOW() - INTERVAL '%s' AND dim_id != '' AND dim_name != ''", + from, filterDataMatViewWindow, + ) } // scopeProjection is the per-row visibility columns appended to every @@ -202,33 +230,36 @@ var filterMatViews = []filterMatViewDef{ }, { name: "mv_filter_teams", - // team_id is exposed as "id" for the dropdown and also as the scope - // column for uniform DAC predicates. - selectExpr: "team_id AS id, team_name AS name, " + - "COALESCE(user_id, '') AS user_id, COALESCE(team_id, '') AS team_id, " + - "COALESCE(virtual_key_id, '') AS virtual_key_id", - whereExpr: "team_id IS NOT NULL AND team_id != '' AND team_name IS NOT NULL AND team_name != ''", + // A request can belong to one team (scalar team_id) or many (JSON-array + // team_ids, enterprise user/AP path). bodyOverride unions both so every + // team shows in the dropdown, not just the scalar primary. team_id is + // exposed as the dropdown "id" and the original row's team_id is kept as + // the scope column for uniform DAC predicates. + bodyOverride: multiValueFilterMatViewBody("team_id"), uniqueIdx: "id, name, " + scopeIdxColumns, requiredColumns: append([]string{"id", "name"}, scopeRequiredColumns...), }, { - name: "mv_filter_customers", - selectExpr: "customer_id AS id, customer_name AS name, " + scopeProjection, - whereExpr: "customer_id IS NOT NULL AND customer_id != '' AND customer_name IS NOT NULL AND customer_name != ''", + name: "mv_filter_customers", + // A request can carry one customer (scalar customer_id) or many (JSON-array + // customer_ids, enterprise team↔customer M2M). bodyOverride unions both so + // every customer shows in the dropdown, not just the scalar primary. + bodyOverride: multiValueFilterMatViewBody("customer_id"), uniqueIdx: "id, name, " + scopeIdxColumns, requiredColumns: append([]string{"id", "name"}, scopeRequiredColumns...), }, { - name: "mv_filter_users", - selectExpr: "user_id AS id, user_name AS name, " + scopeProjection, - whereExpr: "user_id IS NOT NULL AND user_id != '' AND user_name IS NOT NULL AND user_name != ''", + name: "mv_filter_users", + selectExpr: "user_id AS id, user_name AS name, " + scopeProjection, + whereExpr: "user_id IS NOT NULL AND user_id != '' AND user_name IS NOT NULL AND user_name != ''", uniqueIdx: "id, name, " + scopeIdxColumns, requiredColumns: append([]string{"id", "name"}, scopeRequiredColumns...), }, { - name: "mv_filter_business_units", - selectExpr: "business_unit_id AS id, business_unit_name AS name, " + scopeProjection, - whereExpr: "business_unit_id IS NOT NULL AND business_unit_id != '' AND business_unit_name IS NOT NULL AND business_unit_name != ''", + name: "mv_filter_business_units", + // Same scalar-or-JSON-array union as mv_filter_teams: a request can carry + // one business unit (scalar) or many (JSON-array business_unit_ids). + bodyOverride: multiValueFilterMatViewBody("business_unit_id"), uniqueIdx: "id, name, " + scopeIdxColumns, requiredColumns: append([]string{"id", "name"}, scopeRequiredColumns...), }, @@ -242,11 +273,14 @@ var filterMatViewKeyPairColumns = map[[2]string]string{ {"routing_rule_id", "routing_rule_name"}: "mv_filter_routing_rules", {"team_id", "team_name"}: "mv_filter_teams", {"customer_id", "customer_name"}: "mv_filter_customers", - {"user_id", "user_name"}: "mv_filter_users", + {"user_id", "user_name"}: "mv_filter_users", {"business_unit_id", "business_unit_name"}: "mv_filter_business_units", } func filterMatViewDDL(v filterMatViewDef) string { + if v.bodyOverride != "" { + return fmt.Sprintf("CREATE MATERIALIZED VIEW IF NOT EXISTS %s AS %s", v.name, v.bodyOverride) + } return fmt.Sprintf( "CREATE MATERIALIZED VIEW IF NOT EXISTS %s AS SELECT DISTINCT %s FROM logs WHERE timestamp >= NOW() - INTERVAL '%s' AND (%s)", v.name, v.selectExpr, filterDataMatViewWindow, v.whereExpr, @@ -736,7 +770,10 @@ func canUseMatViewFilters(f SearchFilters) bool { f.MinTokens == nil && f.MaxTokens == nil && f.MinCost == nil && f.MaxCost == nil && !f.MissingCostOnly && - len(f.CacheHitTypes) == 0 + len(f.CacheHitTypes) == 0 && + len(f.TeamIDs) == 0 && + len(f.BusinessUnitIDs) == 0 && + len(f.CustomerIDs) == 0 } // canUseMatView checks both that materialized views are ready (created and @@ -807,6 +844,9 @@ func applyMatViewFiltersOnly(q *gorm.DB, f SearchFilters) *gorm.DB { if len(f.Models) > 0 { q = q.Where("model IN ?", f.Models) } + if len(f.Aliases) > 0 { + q = q.Where("alias IN ?", f.Aliases) + } if len(f.Status) > 0 { q = q.Where("status IN ?", f.Status) } @@ -1738,10 +1778,10 @@ func (s *RDBLogStore) getDimensionRankingsFromMatView(ctx context.Context, filte } type row struct { - ID string `gorm:"column:id"` - Total int64 `gorm:"column:total"` - TotalTkns int64 `gorm:"column:total_tkns"` - TotalCost float64 `gorm:"column:total_cost"` + ID string `gorm:"column:id"` + Total int64 `gorm:"column:total"` + TotalTkns int64 `gorm:"column:total_tkns"` + TotalCost float64 `gorm:"column:total_cost"` } var results []row diff --git a/framework/logstore/migrations.go b/framework/logstore/migrations.go index a6fffe5e682..92b42252ff0 100644 --- a/framework/logstore/migrations.go +++ b/framework/logstore/migrations.go @@ -336,6 +336,24 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationRecreateFilterUsersMatView(ctx, db); err != nil { return err } + if err := migrationAddMultiTeamBusinessUnitColumns(ctx, db); err != nil { + return err + } + if err := migrationAddMultiTeamBusinessUnitGINIndexes(ctx, db); err != nil { + return err + } + if err := migrationRecreateFilterTeamBUMatViews(ctx, db); err != nil { + return err + } + if err := migrationAddCustomerArrayColumns(ctx, db); err != nil { + return err + } + if err := migrationAddCustomerArrayGINIndexes(ctx, db); err != nil { + return err + } + if err := migrationRecreateFilterCustomersMatView(ctx, db); err != nil { + return err + } // migrationSplitFilterDataMatView is intentionally NOT invoked in this // release. Dropping mv_logs_filterdata while old replicas are still // serving /api/logs/filterdata from it would surface "relation does not @@ -2005,6 +2023,38 @@ func migrationAddMetadataGINIndex(ctx context.Context, db *gorm.DB) error { return nil } +// migrationAddMultiTeamBusinessUnitGINIndexes registers the GIN indexes backing +// multi-team / multi-BU log filtering. Like migrationAddMetadataGINIndex, the +// build itself is deferred to ensureMultiTeamBusinessUnitGINIndexes (post-startup, +// background, CONCURRENTLY); this migration exists only to provide a rollback that +// drops the indexes. Postgres-only. +func migrationAddMultiTeamBusinessUnitGINIndexes(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = false + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_add_multi_team_bu_gin_indexes_v1", + Migrate: func(tx *gorm.DB) error { + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if tx.Dialector.Name() == "postgres" { + if err := tx.Exec("DROP INDEX IF EXISTS idx_logs_team_ids_gin").Error; err != nil { + return fmt.Errorf("failed to drop team_ids GIN index: %w", err) + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_logs_business_unit_ids_gin").Error; err != nil { + return fmt.Errorf("failed to drop business_unit_ids GIN index: %w", err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while registering multi team/BU GIN indexes: %s", err.Error()) + } + return nil +} + // ensureMetadataGINIndex checks whether idx_logs_metadata_gin exists and is valid. // If the index is missing or was left in an INVALID state by a previously interrupted // CREATE INDEX CONCURRENTLY, it drops the remnant and rebuilds the index synchronously. @@ -2088,6 +2138,62 @@ func cleanupInvalidLogMetadata(ctx context.Context, conn *sql.Conn) error { `) } +// ensureArrayGINIndex builds a partial jsonb_path_ops GIN index on a JSON-array +// text column (e.g. team_ids) so `column::jsonb @> '[...]'` containment filters +// are indexed. Mirrors ensureMetadataGINIndex's lifecycle: tolerates an INVALID +// remnant from an interrupted build and builds CONCURRENTLY so writers are not +// blocked. No data cleanup is needed — the log writer only ever stores a valid +// JSON array or NULL in these columns. indexName/column are internal constants +// (not user input), so identifier interpolation is safe. Postgres-only. +func ensureArrayGINIndex(ctx context.Context, conn *sql.Conn, indexName, column string) error { + var indexValid bool + if err := conn.QueryRowContext(ctx, ` + SELECT COALESCE(bool_and(pi.indisvalid), false) + FROM pg_class pc + JOIN pg_index pi ON pi.indrelid = pc.oid + JOIN pg_class ic ON ic.oid = pi.indexrelid + WHERE pc.relname = 'logs' AND ic.relname = $1 + `, indexName).Scan(&indexValid); err != nil { + return fmt.Errorf("failed to query GIN index validity for %s: %w", indexName, err) + } + if indexValid { + return nil + } + + // Drop any INVALID remnant left by a prior interrupted CONCURRENTLY build. + if _, err := conn.ExecContext(ctx, "DROP INDEX CONCURRENTLY IF EXISTS "+indexName); err != nil { + return fmt.Errorf("failed to drop invalid GIN index %s: %w", indexName, err) + } + + // Non-fatal tuning to speed up the build. + _, _ = conn.ExecContext(ctx, "SET maintenance_work_mem = '512MB'") + _, _ = conn.ExecContext(ctx, "SET max_parallel_maintenance_workers = 4") + + // jsonb_path_ops supports @> (containment) and is ~3x smaller than the default + // opclass. The partial predicate matches the IS JSON ARRAY guard the filter + // query adds (rdb.go), so the planner uses this index. + stmt := fmt.Sprintf( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS %s ON logs USING gin ((%s::jsonb) jsonb_path_ops) WHERE %s IS NOT NULL AND %s IS JSON ARRAY", + indexName, column, column, column, + ) + if _, err := conn.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("failed to create GIN index %s: %w", indexName, err) + } + return nil +} + +// ensureMultiTeamBusinessUnitGINIndexes builds the GIN indexes backing multi-team +// and multi-BU log filtering (team_ids / business_unit_ids). +func ensureMultiTeamBusinessUnitGINIndexes(ctx context.Context, conn *sql.Conn) error { + if err := ensureArrayGINIndex(ctx, conn, "idx_logs_team_ids_gin", "team_ids"); err != nil { + return err + } + if err := ensureArrayGINIndex(ctx, conn, "idx_logs_business_unit_ids_gin", "business_unit_ids"); err != nil { + return err + } + return ensureArrayGINIndex(ctx, conn, "idx_logs_customer_ids_gin", "customer_ids") +} + // migrationAddDashboardEnhancements adds cached_read_tokens column to logs table. // The expensive backfill, covering index rebuild, and MCP index creation are deferred // to ensureDashboardEnhancements (called post-startup in a background goroutine) so @@ -2290,6 +2396,13 @@ type performanceIndexDef struct { sql string } +// ftsInputCharLimit is the maximum number of characters fed to to_tsvector via left(). +// It must be low enough that even worst-case multi-byte / high-lexeme content keeps +// the tsvector output under Postgres's 1,048,575-byte hard limit. The same constant +// must be used in both the index expression and the query predicate so the planner +// recognises the expression match and uses the GIN index. +const ftsInputCharLimit = 250000 + // performanceIndexes is the set of full-text and GIN indexes built by ensurePerformanceIndexes. // Each statement uses CREATE INDEX CONCURRENTLY to avoid blocking writes. var performanceIndexes = []performanceIndexDef{ @@ -2402,17 +2515,19 @@ var performanceIndexes = []performanceIndexDef{ table: "logs", name: "idx_logs_content_summary_fts", // left() caps input characters to stay within to_tsvector's 1MB output limit. - sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_content_summary_fts ON logs USING GIN (to_tsvector('simple', left(content_summary, 800000))) WHERE content_summary IS NOT NULL", + // Use ftsInputCharLimit: even worst-case UTF-8 / high-lexeme content stays + // well under the 1,048,575-byte tsvector limit. + sql: fmt.Sprintf("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_content_summary_fts ON logs USING GIN (to_tsvector('simple', left(content_summary, %d))) WHERE content_summary IS NOT NULL", ftsInputCharLimit), }, { table: "mcp_tool_logs", name: "idx_mcp_logs_arguments_fts", - sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mcp_logs_arguments_fts ON mcp_tool_logs USING GIN (to_tsvector('simple', left(arguments, 800000))) WHERE arguments IS NOT NULL", + sql: fmt.Sprintf("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mcp_logs_arguments_fts ON mcp_tool_logs USING GIN (to_tsvector('simple', left(arguments, %d))) WHERE arguments IS NOT NULL", ftsInputCharLimit), }, { table: "mcp_tool_logs", name: "idx_mcp_logs_result_fts", - sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mcp_logs_result_fts ON mcp_tool_logs USING GIN (to_tsvector('simple', left(result, 800000))) WHERE result IS NOT NULL", + sql: fmt.Sprintf("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mcp_logs_result_fts ON mcp_tool_logs USING GIN (to_tsvector('simple', left(result, %d))) WHERE result IS NOT NULL", ftsInputCharLimit), }, { table: "logs", @@ -2837,6 +2952,50 @@ func migrationAddGovernanceContextColumns(ctx context.Context, db *gorm.DB) erro return nil } +// migrationAddMultiTeamBusinessUnitColumns adds the JSON-array columns capturing +// the full deduped set of teams / business units a request belongs to (enterprise +// user/AP path). The scalar team_id/business_unit_id remain the primary; these +// power display, multi-team filtering (jsonb @> + GIN), and fan-out aggregation. +func migrationAddMultiTeamBusinessUnitColumns(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + + columns := []string{"team_ids", "team_names", "business_unit_ids", "business_unit_names"} + + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_add_multi_team_business_unit_columns", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + for _, col := range columns { + if !mig.HasColumn(&Log{}, col) { + if err := mig.AddColumn(&Log{}, col); err != nil { + return err + } + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + for _, col := range columns { + if mig.HasColumn(&Log{}, col) { + if err := mig.DropColumn(&Log{}, col); err != nil { + return err + } + } + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while adding multi team/business-unit columns: %s", err.Error()) + } + return nil +} + // migrationRecreateMatViewsWithGovernanceColumns drops and recreates materialized views // so they include the new governance context columns (user_id, team_id, customer_id, business_unit_id). // The actual rebuild is deferred to ensureMatViews, which runs after startup on @@ -3333,3 +3492,139 @@ func migrationRecreateFilterUsersMatView(ctx context.Context, db *gorm.DB) error } return nil } + +// migrationRecreateFilterTeamBUMatViews drops mv_filter_teams and +// mv_filter_business_units so ensureMatViews recreates them with the multi-value +// body (scalar column UNION the JSON-array column). Required because +// repairMatViewShapes only detects drift by column presence, and the column +// shape (id, name, user_id, team_id, virtual_key_id) is unchanged — only the +// SELECT body changed — so the views would otherwise keep their old scalar-only +// definition. Recreated views keep identical columns, so old replicas reading +// them during a rolling deploy are unaffected (no legacyMatViewNames dance). +func migrationRecreateFilterTeamBUMatViews(ctx context.Context, db *gorm.DB) error { + if db.Dialector.Name() != "postgres" { + return nil + } + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_recreate_filter_team_bu_matviews_multivalue", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + for _, view := range []string{"mv_filter_teams", "mv_filter_business_units"} { + if err := tx.Exec("DROP MATERIALIZED VIEW IF EXISTS " + view + " CASCADE").Error; err != nil { + return fmt.Errorf("failed to drop %s: %w", view, err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while recreating filter team/business-unit matviews: %s", err.Error()) + } + return nil +} + +// migrationAddCustomerArrayColumns adds the JSON-array columns capturing the full +// deduped set of customers a request belongs to (a team can belong to many +// customers via the enterprise team↔customer M2M). The scalar customer_id remains +// the primary; these power display, multi-customer filtering (jsonb @> + GIN), and +// fan-out aggregation, mirroring team_ids / business_unit_ids. +func migrationAddCustomerArrayColumns(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + + columns := []string{"customer_ids", "customer_names"} + + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_add_customer_array_columns", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + for _, col := range columns { + if !mig.HasColumn(&Log{}, col) { + if err := mig.AddColumn(&Log{}, col); err != nil { + return err + } + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + for _, col := range columns { + if mig.HasColumn(&Log{}, col) { + if err := mig.DropColumn(&Log{}, col); err != nil { + return err + } + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while adding customer array columns: %s", err.Error()) + } + return nil +} + +// migrationAddCustomerArrayGINIndexes registers the GIN index backing multi-customer +// log filtering. Like the team/BU GIN migration, the build itself is deferred to +// ensureMultiTeamBusinessUnitGINIndexes (post-startup, background, CONCURRENTLY); +// this migration exists only to provide a rollback that drops the index. Postgres-only. +func migrationAddCustomerArrayGINIndexes(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = false + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_add_customer_array_gin_indexes_v1", + Migrate: func(tx *gorm.DB) error { + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if tx.Dialector.Name() == "postgres" { + if err := tx.Exec("DROP INDEX IF EXISTS idx_logs_customer_ids_gin").Error; err != nil { + return fmt.Errorf("failed to drop customer_ids GIN index: %w", err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while registering customer GIN indexes: %s", err.Error()) + } + return nil +} + +// migrationRecreateFilterCustomersMatView drops mv_filter_customers so ensureMatViews +// recreates it with the multi-value body (scalar customer_id UNION the JSON-array +// customer_ids), mirroring migrationRecreateFilterTeamBUMatViews. The column shape +// is unchanged so old replicas reading it during a rolling deploy are unaffected. +func migrationRecreateFilterCustomersMatView(ctx context.Context, db *gorm.DB) error { + if db.Dialector.Name() != "postgres" { + return nil + } + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_recreate_filter_customers_matview_multivalue", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := tx.Exec("DROP MATERIALIZED VIEW IF EXISTS mv_filter_customers CASCADE").Error; err != nil { + return fmt.Errorf("failed to drop mv_filter_customers: %w", err) + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while recreating filter customers matview: %s", err.Error()) + } + return nil +} diff --git a/framework/logstore/multi_team_filter_test.go b/framework/logstore/multi_team_filter_test.go new file mode 100644 index 00000000000..8127fb88a79 --- /dev/null +++ b/framework/logstore/multi_team_filter_test.go @@ -0,0 +1,89 @@ +package logstore + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMultiValueDimensionFilterSQL verifies the predicate that matches logs by a +// dimension that is scalar on the primary column and multi-valued on the JSON +// array column: it must OR the scalar IN with guarded array containment per id, +// and bind args in order (the id slice first, then one JSON fragment per id). +func TestMultiValueDimensionFilterSQL(t *testing.T) { + sql, args := multiValueDimensionFilterSQL("team_id", "team_ids", []string{"t1", "t2"}) + + // Outer parens so it ANDs as one group with other filters. + assert.True(t, strings.HasPrefix(sql, "(") && strings.HasSuffix(sql, ")"), "must be parenthesised: %s", sql) + // Scalar primary path (covers VK-team / pre-migration / NULL-array rows). + assert.Contains(t, sql, "team_id IN ?") + // Partial-index guard so the planner uses the jsonb_path_ops GIN. + assert.Contains(t, sql, "team_ids IS NOT NULL AND team_ids IS JSON ARRAY") + // One containment test per requested id. + assert.Equal(t, 2, strings.Count(sql, "team_ids::jsonb @> ?::jsonb")) + + // Args: the id slice first (for `IN ?`), then a JSON array fragment per id. + require.Len(t, args, 3) + assert.Equal(t, []string{"t1", "t2"}, args[0]) + assert.Equal(t, `["t1"]`, args[1]) + assert.Equal(t, `["t2"]`, args[2]) + + // Works for the BU columns too. + buSQL, buArgs := multiValueDimensionFilterSQL("business_unit_id", "business_unit_ids", []string{"bu1"}) + assert.Contains(t, buSQL, "business_unit_id IN ?") + assert.Contains(t, buSQL, "business_unit_ids::jsonb @> ?::jsonb") + require.Len(t, buArgs, 2) + assert.Equal(t, `["bu1"]`, buArgs[1]) +} + +// TestTeamOrBUFanoutFrom verifies the fan-out FROM subquery: it must unnest the +// array columns (id+name aligned by ordinality) for array rows and fall back to +// the scalar id/name for non-array rows, with mutually exclusive branches, and +// be aliased AS logs so DAC scope + filters still resolve. +func TestTeamOrBUFanoutFrom(t *testing.T) { + teamSQL, ok := teamOrBUFanoutFrom("team_id") + require.True(t, ok) + assert.Contains(t, teamSQL, "jsonb_array_elements_text(l.team_ids::jsonb) WITH ORDINALITY") + assert.Contains(t, teamSQL, "jsonb_array_elements_text(l.team_names::jsonb) WITH ORDINALITY") + assert.Contains(t, teamSQL, "ON n.ord = t.ord", "names aligned with ids by ordinality") + // array branch guard + mutually-exclusive scalar fallback branch + assert.Contains(t, teamSQL, "WHERE l.team_ids IS NOT NULL AND l.team_ids IS JSON ARRAY") + assert.Contains(t, teamSQL, "SELECT l.team_id, COALESCE(l.team_name, '')") + assert.Contains(t, teamSQL, "WHERE l.team_ids IS NULL OR l.team_ids IS NOT JSON ARRAY") + assert.Contains(t, teamSQL, "UNION ALL") + assert.Contains(t, teamSQL, ") AS logs", "aliased AS logs so scope/filters resolve") + assert.Contains(t, teamSQL, "fan.dim_id AS dim_id") + assert.Contains(t, teamSQL, "fan.dim_name AS dim_name") + + buSQL, ok := teamOrBUFanoutFrom("business_unit_id") + require.True(t, ok) + assert.Contains(t, buSQL, "business_unit_ids::jsonb") + assert.Contains(t, buSQL, "SELECT l.business_unit_id, COALESCE(l.business_unit_name, '')") + + // Customers fan out too (enterprise team↔customer M2M). + custSQL, ok := teamOrBUFanoutFrom("customer_id") + require.True(t, ok) + assert.Contains(t, custSQL, "jsonb_array_elements_text(l.customer_ids::jsonb) WITH ORDINALITY") + assert.Contains(t, custSQL, "SELECT l.customer_id, COALESCE(l.customer_name, '')") + + // Non-fan-out dimensions return false (caller uses the normal scalar path). + _, ok = teamOrBUFanoutFrom("user_id") + assert.False(t, ok) + _, ok = teamOrBUFanoutFrom("provider") + assert.False(t, ok) +} + +// TestCanUseMatViewFilters_ExcludesTeamBU verifies that a team or business-unit +// filter disqualifies the matview path: mv_logs_hourly only has the scalar +// primary, so these must fall through to the raw (array-or-scalar) path to stay +// complete. Other filters (e.g. provider) remain matview-eligible. +func TestCanUseMatViewFilters_ExcludesTeamBU(t *testing.T) { + assert.True(t, canUseMatViewFilters(SearchFilters{}), "empty filters → matview eligible") + assert.True(t, canUseMatViewFilters(SearchFilters{Providers: []string{"openai"}}), "provider filter stays matview-eligible") + + assert.False(t, canUseMatViewFilters(SearchFilters{TeamIDs: []string{"t1"}}), "team filter must force the raw path") + assert.False(t, canUseMatViewFilters(SearchFilters{BusinessUnitIDs: []string{"bu1"}}), "BU filter must force the raw path") + assert.False(t, canUseMatViewFilters(SearchFilters{CustomerIDs: []string{"c1"}}), "customer filter must force the raw path") +} diff --git a/framework/logstore/multi_team_matview_test.go b/framework/logstore/multi_team_matview_test.go new file mode 100644 index 00000000000..bc3aa60e872 --- /dev/null +++ b/framework/logstore/multi_team_matview_test.go @@ -0,0 +1,151 @@ +package logstore + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/maximhq/bifrost/framework/queryscope" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// insertTeamBULog inserts a single log row carrying the scalar and/or JSON-array +// team / business-unit columns. Empty-string args are stored as NULL so the +// matview's scalar-vs-array branch selection (team_ids IS JSON ARRAY) is +// exercised faithfully — a row with no array column must fall back to the scalar. +func insertTeamBULog(t *testing.T, db *gorm.DB, ts time.Time, + userID, teamID, teamName, teamIDs, teamNames, buID, buName, buIDs, buNames string) { + t.Helper() + nz := func(s string) any { + if s == "" { + return nil + } + return s + } + err := db.Exec(` + INSERT INTO logs (id, timestamp, object_type, provider, model, status, + user_id, team_id, team_name, team_ids, team_names, + business_unit_id, business_unit_name, business_unit_ids, business_unit_names, + created_at, latency, cost, prompt_tokens, completion_tokens, total_tokens) + VALUES (?, ?, 'chat_completion', 'openai', 'gpt-4', 'success', + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 100, 0.01, 10, 5, 15) + `, uuid.New().String(), ts, nz(userID), nz(teamID), nz(teamName), nz(teamIDs), nz(teamNames), + nz(buID), nz(buName), nz(buIDs), nz(buNames), ts).Error + require.NoError(t, err, "failed to insert team/BU test log") +} + +func keyPairsByID(pairs []KeyPairResult) map[string]string { + byID := make(map[string]string, len(pairs)) + for _, p := range pairs { + byID[p.ID] = p.Name + } + return byID +} + +// TestFilterTeamMatView_CollectsScalarAndArray is the regression for the reported +// bug: the team filter dropdown read mv_filter_teams, which only projected the +// scalar team_id/team_name. Teams that exist only in the JSON-array team_ids +// (enterprise user/AP path) were missing. The recreated view must surface both. +func TestFilterTeamMatView_CollectsScalarAndArray(t *testing.T) { + store, db := setupPerfTestDB(t) + store.matViewsReady.Store(true) // force the matview read path + ctx := context.Background() + now := time.Now().UTC() + + // Old / VK-team log: scalar team only, no JSON array. + insertTeamBULog(t, db, now, "u-1", "t-scalar", "Scalar Team", "", "", "", "", "", "") + // Enterprise log: teams only in the JSON array, scalar team NULL. + insertTeamBULog(t, db, now, "u-1", "", "", `["t-arr1","t-arr2"]`, `["Array One","Array Two"]`, "", "", "", "") + refreshTestMatViews(t, db) + + pairs, err := store.GetDistinctKeyPairs(ctx, "team_id", "team_name", 1000, "") + require.NoError(t, err) + byID := keyPairsByID(pairs) + + assert.Equal(t, "Scalar Team", byID["t-scalar"], "scalar-only team must still appear (backward compatibility)") + assert.Equal(t, "Array One", byID["t-arr1"], "array-only team must now appear") + assert.Equal(t, "Array Two", byID["t-arr2"], "second array team must appear, name aligned by ordinality") +} + +// TestFilterBusinessUnitMatView_CollectsScalarAndArray mirrors the team test for +// the business-unit dropdown (mv_filter_business_units), which had the identical +// scalar-only bug. +func TestFilterBusinessUnitMatView_CollectsScalarAndArray(t *testing.T) { + store, db := setupPerfTestDB(t) + store.matViewsReady.Store(true) + ctx := context.Background() + now := time.Now().UTC() + + insertTeamBULog(t, db, now, "u-1", "", "", "", "", "bu-scalar", "Scalar BU", "", "") + insertTeamBULog(t, db, now, "u-1", "", "", "", "", "", "", `["bu-arr1"]`, `["BU One"]`) + refreshTestMatViews(t, db) + + pairs, err := store.GetDistinctKeyPairs(ctx, "business_unit_id", "business_unit_name", 1000, "") + require.NoError(t, err) + byID := keyPairsByID(pairs) + + assert.Equal(t, "Scalar BU", byID["bu-scalar"], "scalar-only business unit must still appear") + assert.Equal(t, "BU One", byID["bu-arr1"], "array-only business unit must now appear") +} + +// TestDimensionRankings_ActualVsAttributedTotals pins the semantics of the +// server-side totals: a request attributed to two teams counts once in +// TotalActualRequests (COUNT(DISTINCT id)) and twice in +// TotalAttributedRequests (COUNT(*) over the fan-out), while the per-team +// rows stay accurate per team. +func TestDimensionRankings_ActualVsAttributedTotals(t *testing.T) { + store, db := setupPerfTestDB(t) + ctx := context.Background() + now := time.Now().UTC() + + // One request attributed to two teams + one scalar single-team request: + // 2 actual requests, 3 attributed team-credits. + insertTeamBULog(t, db, now, "u-1", "", "", `["t-a","t-b"]`, `["Team A","Team B"]`, "", "", "", "") + insertTeamBULog(t, db, now, "u-1", "t-a", "Team A", "", "", "", "", "", "") + + start := now.Add(-time.Hour) + end := now.Add(time.Hour) + res, err := store.GetDimensionRankings(ctx, SearchFilters{StartTime: &start, EndTime: &end}, RankingDimensionTeam) + require.NoError(t, err) + + assert.Equal(t, int64(2), res.TotalActualRequests, "actual must count distinct requests, not fan-out rows") + assert.Equal(t, int64(3), res.TotalAttributedRequests, "attributed must credit a request once per team it touches") + + requestsByID := make(map[string]int64, len(res.Rankings)) + for _, r := range res.Rankings { + requestsByID[r.ID] = r.TotalRequests + } + assert.Equal(t, int64(2), requestsByID["t-a"], "t-a is touched by both requests (array + scalar fallback)") + assert.Equal(t, int64(1), requestsByID["t-b"], "t-b is touched only by the multi-team request") +} + +// TestFilterTeamMatView_DACScopeAppliesAfterFanout proves the visibility columns +// (user_id) survive the fan-out: each fanned-out team row carries its source +// log's user_id, so a QueryScope still both resolves (no "column does not exist") +// and filters — an array team owned by another user must not leak. +func TestFilterTeamMatView_DACScopeAppliesAfterFanout(t *testing.T) { + store, db := setupPerfTestDB(t) + store.matViewsReady.Store(true) + now := time.Now().UTC() + + // Array team owned by u-secret; scalar team visible to u-visible. + insertTeamBULog(t, db, now, "u-secret", "", "", `["t-secret"]`, `["Secret Team"]`, "", "", "", "") + insertTeamBULog(t, db, now, "u-visible", "t-public", "Public Team", "", "", "", "", "", "") + refreshTestMatViews(t, db) + + scope := queryscope.QueryScope(func(db *gorm.DB) *gorm.DB { + return db.Where("user_id = ?", "u-visible") + }) + ctx := queryscope.WithQueryScope(context.Background(), scope) + + pairs, err := store.GetDistinctKeyPairs(ctx, "team_id", "team_name", 1000, "") + require.NoError(t, err, "scope WHERE must resolve against the fanned-out matview columns") + byID := keyPairsByID(pairs) + + assert.Equal(t, "Public Team", byID["t-public"], "team visible to the scoped user must appear") + _, leaked := byID["t-secret"] + assert.False(t, leaked, "array team owned by another user must be filtered out by DAC scope") +} diff --git a/framework/logstore/postgres.go b/framework/logstore/postgres.go index b908765e976..7f1dd332bdc 100644 --- a/framework/logstore/postgres.go +++ b/framework/logstore/postgres.go @@ -197,6 +197,12 @@ func newPostgresLogStore(ctx context.Context, config *PostgresConfig, logger sch logger.Info("logstore: metadata GIN index is ready") } + if err := ensureMultiTeamBusinessUnitGINIndexes(context.Background(), lock.conn); err != nil { + logger.Warn(fmt.Sprintf("logstore: team/business-unit GIN index build failed: %s (filtering will still work without the index)", err)) + } else { + logger.Info("logstore: team/business-unit GIN indexes are ready") + } + if err := ensureDashboardEnhancements(context.Background(), lock.conn); err != nil { logger.Warn(fmt.Sprintf("logstore: dashboard enhancements failed: %s (dashboard will still work with partial data)", err)) } else { diff --git a/framework/logstore/rdb.go b/framework/logstore/rdb.go index ce10cbcf4e7..caf4cdb59fb 100644 --- a/framework/logstore/rdb.go +++ b/framework/logstore/rdb.go @@ -85,6 +85,64 @@ func (s *RDBLogStore) ScopedDB(ctx context.Context) *gorm.DB { return db } +// multiValueDimensionFilterSQL builds a Postgres predicate matching logs by a +// dimension that is single-valued on the scalar column (the primary, set by the +// VK path / pre-migration rows) and multi-valued on the JSON-array column (the +// full set, set by the enterprise user/AP path). It ORs the scalar `IN` (btree +// index) with array containment per id (partial jsonb_path_ops GIN index). The +// `IS NOT NULL AND IS JSON ARRAY` guard matches the partial index predicate so +// the planner uses the GIN. Returns the parenthesised SQL and its args. +func multiValueDimensionFilterSQL(scalarCol, arrayCol string, ids []string) (string, []interface{}) { + arrConds := make([]string, len(ids)) + args := []interface{}{ids} + for i, id := range ids { + arrConds[i] = arrayCol + "::jsonb @> ?::jsonb" + frag, _ := sonic.Marshal([]string{id}) + args = append(args, string(frag)) + } + sql := fmt.Sprintf("(%s IN ? OR (%s IS NOT NULL AND %s IS JSON ARRAY AND (%s)))", + scalarCol, arrayCol, arrayCol, strings.Join(arrConds, " OR ")) + return sql, args +} + +// teamOrBUFanoutFrom returns a Postgres FROM subquery (aliased AS logs) that fans +// each log row out to one row per associated team / business unit, exposing +// derived `dim_id` and `dim_name` columns alongside all original log columns +// (l.*) so DAC scope and filters still resolve. Rows with the JSON-array column +// set are unnested (id+name aligned by ordinality); rows without it (pre-upgrade +// or VK-team logs) fall back to the scalar id/name — so historical logs keep +// contributing. The two branches are mutually exclusive, so no row is counted +// twice for the same dimension value. Returns ("", false) for non-fan-out +// dimensions. idCol is the scalar id column ("team_id" / "business_unit_id"), +// which both the ranking and histogram dimensions resolve to. No bind args: all +// identifiers are internal constants. +func teamOrBUFanoutFrom(idCol string) (string, bool) { + var arrIDs, arrNames, scalarName string + switch idCol { + case "team_id": + arrIDs, arrNames, scalarName = "team_ids", "team_names", "team_name" + case "business_unit_id": + arrIDs, arrNames, scalarName = "business_unit_ids", "business_unit_names", "business_unit_name" + case "customer_id": + arrIDs, arrNames, scalarName = "customer_ids", "customer_names", "customer_name" + default: + return "", false + } + return fmt.Sprintf(`( + SELECT l.*, fan.dim_id AS dim_id, fan.dim_name AS dim_name + FROM logs l + CROSS JOIN LATERAL ( + SELECT t.value AS dim_id, COALESCE(n.value, '') AS dim_name + FROM jsonb_array_elements_text(l.%[1]s::jsonb) WITH ORDINALITY AS t(value, ord) + LEFT JOIN jsonb_array_elements_text(l.%[2]s::jsonb) WITH ORDINALITY AS n(value, ord) ON n.ord = t.ord + WHERE l.%[1]s IS NOT NULL AND l.%[1]s IS JSON ARRAY + UNION ALL + SELECT l.%[3]s, COALESCE(l.%[4]s, '') + WHERE l.%[1]s IS NULL OR l.%[1]s IS NOT JSON ARRAY + ) AS fan +) AS logs`, arrIDs, arrNames, idCol, scalarName), true +} + // applyFilters applies search filters to a GORM query. Callers are // responsible for starting from ScopedDB(ctx) when row visibility // should be respected; this helper only adds the per-call filter @@ -121,16 +179,31 @@ func (s *RDBLogStore) applyFilters(baseQuery *gorm.DB, filters SearchFilters) *g baseQuery = baseQuery.Where("routing_rule_id IN ?", filters.RoutingRuleIDs) } if len(filters.TeamIDs) > 0 { - baseQuery = baseQuery.Where("team_id IN ?", filters.TeamIDs) + if s.db.Dialector.Name() == "postgres" { + sql, args := multiValueDimensionFilterSQL("team_id", "team_ids", filters.TeamIDs) + baseQuery = baseQuery.Where(sql, args...) + } else { + baseQuery = baseQuery.Where("team_id IN ?", filters.TeamIDs) + } } if len(filters.CustomerIDs) > 0 { - baseQuery = baseQuery.Where("customer_id IN ?", filters.CustomerIDs) + if s.db.Dialector.Name() == "postgres" { + sql, args := multiValueDimensionFilterSQL("customer_id", "customer_ids", filters.CustomerIDs) + baseQuery = baseQuery.Where(sql, args...) + } else { + baseQuery = baseQuery.Where("customer_id IN ?", filters.CustomerIDs) + } } if len(filters.UserIDs) > 0 { baseQuery = baseQuery.Where("user_id IN ?", filters.UserIDs) } if len(filters.BusinessUnitIDs) > 0 { - baseQuery = baseQuery.Where("business_unit_id IN ?", filters.BusinessUnitIDs) + if s.db.Dialector.Name() == "postgres" { + sql, args := multiValueDimensionFilterSQL("business_unit_id", "business_unit_ids", filters.BusinessUnitIDs) + baseQuery = baseQuery.Where(sql, args...) + } else { + baseQuery = baseQuery.Where("business_unit_id IN ?", filters.BusinessUnitIDs) + } } if len(filters.RoutingEngineUsed) > 0 { // Query routing engines (comma-separated values) - find logs containing ANY of the specified engines @@ -232,7 +305,9 @@ func (s *RDBLogStore) applyFilters(baseQuery *gorm.DB, filters SearchFilters) *g if filters.ContentSearch != "" { dialect := s.db.Dialector.Name() if dialect == "postgres" { - baseQuery = baseQuery.Where("to_tsvector('simple', content_summary) @@ plainto_tsquery('simple', ?)", filters.ContentSearch) + // Must match the idx_logs_content_summary_fts expression exactly (incl. the + // left() cap) so the planner uses the GIN expression index. + baseQuery = baseQuery.Where(fmt.Sprintf("to_tsvector('simple', left(content_summary, %d)) @@ plainto_tsquery('simple', ?)", ftsInputCharLimit), filters.ContentSearch) } else { baseQuery = baseQuery.Where("content_summary LIKE ?", "%"+filters.ContentSearch+"%") } @@ -2003,7 +2078,25 @@ func (s *RDBLogStore) GetDimensionRankings(ctx context.Context, filters SearchFi return nil, fmt.Errorf("invalid ranking dimension: %s", dimension) } - if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) { + // Multi-valued team / business-unit dimensions fan out over the JSON array + // (with scalar fallback for old / VK-team logs) so a request credits every + // team/BU it touches. Postgres-only; this forces the live path — the + // matview-accelerated equivalent is deferred to the partitioning work. + fanoutFrom := "" + if s.db.Dialector.Name() == "postgres" { + if f, isFanout := teamOrBUFanoutFrom(idCol); isFanout { + fanoutFrom = f + idCol, nameCol = "dim_id", "dim_name" + } + } + baseTable := func(q *gorm.DB) *gorm.DB { + if fanoutFrom != "" { + return q.Table(fanoutFrom) + } + return q.Model(&Log{}) + } + + if fanoutFrom == "" && s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) { return s.getDimensionRankingsFromMatView(ctx, filters, dimension) } @@ -2022,7 +2115,7 @@ func (s *RDBLogStore) GetDimensionRankings(ctx context.Context, filters SearchFi COALESCE(SUM(cost), 0) as total_cost `, idCol, nameExpr) - currentQuery := s.ScopedDB(ctx).Model(&Log{}) + currentQuery := baseTable(s.ScopedDB(ctx)) currentQuery = s.applyFilters(currentQuery, filters) currentQuery = currentQuery.Where("status IN ?", []string{"success", "error"}) currentQuery = currentQuery.Where(fmt.Sprintf("%s IS NOT NULL AND %s != ''", idCol, idCol)) @@ -2051,6 +2144,27 @@ func (s *RDBLogStore) GetDimensionRankings(ctx context.Context, filters SearchFi }, nil } + // For fan-out dimensions the per-row counts credit a request to every + // dimension value it touches, so their sum overstates real traffic. Compute + // both requestCounts in one pass over the same fanned population (identical + // predicate chain) so actual <= attributed always holds — summing the + // (limit-capped) rankings client-side could undercount attributed below it. + var requestCounts struct { + ActualRequests int64 `gorm:"column:actual_requests"` + AttributedRequests int64 `gorm:"column:attributed_requests"` + } + if fanoutFrom != "" { + requestsCountsQuery := baseTable(s.ScopedDB(ctx)) + requestsCountsQuery = s.applyFilters(requestsCountsQuery, filters) + requestsCountsQuery = requestsCountsQuery.Where("status IN ?", []string{"success", "error"}) + requestsCountsQuery = requestsCountsQuery.Where(fmt.Sprintf("%s IS NOT NULL AND %s != ''", idCol, idCol)) + if err := requestsCountsQuery. + Select("COUNT(DISTINCT id) as actual_requests, COUNT(*) as attributed_requests"). + Scan(&requestCounts).Error; err != nil { + return nil, fmt.Errorf("failed to get dimension ranking totals for %s: %w", dimension, err) + } + } + prevMap := make(map[string]DimensionRankingEntry) if filters.StartTime != nil && filters.EndTime != nil { duration := filters.EndTime.Sub(*filters.StartTime) @@ -2061,7 +2175,7 @@ func (s *RDBLogStore) GetDimensionRankings(ctx context.Context, filters SearchFi prevFilters.StartTime = &prevStart prevFilters.EndTime = &prevEnd - prevQuery := s.ScopedDB(ctx).Model(&Log{}) + prevQuery := baseTable(s.ScopedDB(ctx)) prevQuery = s.applyFilters(prevQuery, prevFilters) prevQuery = prevQuery.Where("status IN ?", []string{"success", "error"}) prevQuery = prevQuery.Where(fmt.Sprintf("%s IS NOT NULL AND %s != ''", idCol, idCol)) @@ -2129,7 +2243,12 @@ func (s *RDBLogStore) GetDimensionRankings(ctx context.Context, filters SearchFi } } - return &DimensionRankingResult{Rankings: rankings, Dimension: dimension}, nil + return &DimensionRankingResult{ + Rankings: rankings, + Dimension: dimension, + TotalActualRequests: requestCounts.ActualRequests, + TotalAttributedRequests: requestCounts.AttributedRequests, + }, nil } // pctChange computes the percentage change from old to new. @@ -2663,12 +2782,29 @@ func (s *RDBLogStore) GetDimensionCostHistogram(ctx context.Context, filters Sea if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 } - if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getDimensionCostHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) - } dimCol := string(dimension) dialect := s.db.Dialector.Name() - baseQuery := s.ScopedDB(ctx).Model(&Log{}) + // Team / business-unit dimensions fan out over the JSON array (scalar + // fallback for old / VK-team logs). Postgres-only; forces the live path. + // NOTE: under fan-out the per-bucket *total* cost is the attributed total + // (≥ real, since a shared request counts toward each of its teams/BUs); the + // per-dimension breakdown is exact. Surface it as "attributed" in the UI. + fanoutFrom := "" + if dialect == "postgres" { + if f, isFanout := teamOrBUFanoutFrom(dimCol); isFanout { + fanoutFrom = f + dimCol = "dim_id" + } + } + if fanoutFrom == "" && dialect == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getDimensionCostHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) + } + baseQuery := s.ScopedDB(ctx) + if fanoutFrom != "" { + baseQuery = baseQuery.Table(fanoutFrom) + } else { + baseQuery = baseQuery.Model(&Log{}) + } baseQuery = s.applyFilters(baseQuery, filters) baseQuery = baseQuery.Where("status IN ?", []string{"success", "error"}) baseQuery = baseQuery.Where("cost IS NOT NULL AND cost > 0") @@ -2758,12 +2894,26 @@ func (s *RDBLogStore) GetDimensionTokenHistogram(ctx context.Context, filters Se if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 } - if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getDimensionTokenHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) - } dimCol := string(dimension) dialect := s.db.Dialector.Name() - baseQuery := s.ScopedDB(ctx).Model(&Log{}) + // Team / business-unit dimensions fan out over the JSON array (scalar + // fallback for old / VK-team logs). Postgres-only; forces the live path. + fanoutFrom := "" + if dialect == "postgres" { + if f, isFanout := teamOrBUFanoutFrom(dimCol); isFanout { + fanoutFrom = f + dimCol = "dim_id" + } + } + if fanoutFrom == "" && dialect == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getDimensionTokenHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) + } + baseQuery := s.ScopedDB(ctx) + if fanoutFrom != "" { + baseQuery = baseQuery.Table(fanoutFrom) + } else { + baseQuery = baseQuery.Model(&Log{}) + } baseQuery = s.applyFilters(baseQuery, filters) baseQuery = baseQuery.Where("status IN ?", []string{"success", "error"}) @@ -3441,7 +3591,12 @@ func (s *RDBLogStore) applyMCPFilters(baseQuery *gorm.DB, filters MCPToolLogSear // Search in both arguments and result fields dialect := s.db.Dialector.Name() if dialect == "postgres" { - baseQuery = baseQuery.Where("(to_tsvector('simple', arguments) @@ plainto_tsquery('simple', ?) OR to_tsvector('simple', result) @@ plainto_tsquery('simple', ?))", filters.ContentSearch, filters.ContentSearch) + // Must match idx_mcp_logs_arguments_fts / idx_mcp_logs_result_fts expressions + // exactly (incl. the left() cap) so the planner uses the GIN expression indexes. + baseQuery = baseQuery.Where( + fmt.Sprintf("(to_tsvector('simple', left(arguments, %d)) @@ plainto_tsquery('simple', ?) OR to_tsvector('simple', left(result, %d)) @@ plainto_tsquery('simple', ?))", ftsInputCharLimit, ftsInputCharLimit), + filters.ContentSearch, filters.ContentSearch, + ) } else { search := "%" + filters.ContentSearch + "%" baseQuery = baseQuery.Where("(arguments LIKE ? OR result LIKE ?)", search, search) diff --git a/framework/logstore/rdb_postgres_perf_test.go b/framework/logstore/rdb_postgres_perf_test.go index 68674dcd3b9..ed2a27190e3 100644 --- a/framework/logstore/rdb_postgres_perf_test.go +++ b/framework/logstore/rdb_postgres_perf_test.go @@ -183,8 +183,14 @@ func insertPerfMCPLog(t *testing.T, db *gorm.DB, opts mcpLogOpts) { // refreshTestMatViews refreshes materialized views after inserting test data. // This is needed because matviews are populated at creation time and don't // automatically reflect new inserts until explicitly refreshed. +// +// The refresh gate is reset first: it's a package-level singleton, so without a +// reset a prior test leaves it initialized and the eventually-consistent +// pg_stat_user_tables counter (which lags fresh INSERTs by a few seconds) can +// make refreshMatViews short-circuit, leaving the matview stale for this test. func refreshTestMatViews(t *testing.T, db *gorm.DB) { t.Helper() + resetTestMatViewRefreshGate() ctx := context.Background() err := refreshMatViews(ctx, db) require.NoError(t, err, "Failed to refresh materialized views") diff --git a/framework/logstore/tables.go b/framework/logstore/tables.go index 0fa010be5a0..9ed3c4f6581 100644 --- a/framework/logstore/tables.go +++ b/framework/logstore/tables.go @@ -154,6 +154,12 @@ type Log struct { CustomerName *string `gorm:"type:varchar(255)" json:"customer_name"` BusinessUnitID *string `gorm:"type:varchar(255);index:idx_logs_business_unit_id" json:"business_unit_id"` BusinessUnitName *string `gorm:"type:varchar(255)" json:"business_unit_name"` + TeamIDs *string `gorm:"type:text" json:"-"` + TeamNames *string `gorm:"type:text" json:"-"` + CustomerIDs *string `gorm:"type:text" json:"-"` + CustomerNames *string `gorm:"type:text" json:"-"` + BusinessUnitIDs *string `gorm:"type:text" json:"-"` + BusinessUnitNames *string `gorm:"type:text" json:"-"` InputHistory string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ChatMessage ResponsesInputHistory string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ResponsesMessage OutputMessage string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ChatMessage @@ -249,6 +255,12 @@ type Log struct { AttemptTrailParsed []schemas.KeyAttemptRecord `gorm:"-" json:"attempt_trail,omitempty"` BudgetIDsParsed []string `gorm:"-" json:"budget_ids,omitempty"` RateLimitIDsParsed []string `gorm:"-" json:"rate_limit_ids,omitempty"` + TeamIDsParsed []string `gorm:"-" json:"team_ids,omitempty"` + TeamNamesParsed []string `gorm:"-" json:"team_names,omitempty"` + CustomerIDsParsed []string `gorm:"-" json:"customer_ids,omitempty"` + CustomerNamesParsed []string `gorm:"-" json:"customer_names,omitempty"` + BusinessUnitIDsParsed []string `gorm:"-" json:"business_unit_ids,omitempty"` + BusinessUnitNamesParsed []string `gorm:"-" json:"business_unit_names,omitempty"` // Populated in handlers after find using the virtual key id and key id VirtualKey *tables.TableVirtualKey `gorm:"-" json:"virtual_key,omitempty"` // redacted @@ -577,6 +589,55 @@ func (l *Log) SerializeFields() error { } } + if len(l.TeamIDsParsed) > 0 { + if data, err := sonic.Marshal(l.TeamIDsParsed); err != nil { + return err + } else { + s := string(data) + l.TeamIDs = &s + } + } + if len(l.TeamNamesParsed) > 0 { + if data, err := sonic.Marshal(l.TeamNamesParsed); err != nil { + return err + } else { + s := string(data) + l.TeamNames = &s + } + } + if len(l.CustomerIDsParsed) > 0 { + if data, err := sonic.Marshal(l.CustomerIDsParsed); err != nil { + return err + } else { + s := string(data) + l.CustomerIDs = &s + } + } + if len(l.CustomerNamesParsed) > 0 { + if data, err := sonic.Marshal(l.CustomerNamesParsed); err != nil { + return err + } else { + s := string(data) + l.CustomerNames = &s + } + } + if len(l.BusinessUnitIDsParsed) > 0 { + if data, err := sonic.Marshal(l.BusinessUnitIDsParsed); err != nil { + return err + } else { + s := string(data) + l.BusinessUnitIDs = &s + } + } + if len(l.BusinessUnitNamesParsed) > 0 { + if data, err := sonic.Marshal(l.BusinessUnitNamesParsed); err != nil { + return err + } else { + s := string(data) + l.BusinessUnitNames = &s + } + } + // Build content summary for search. // Skip if already set (e.g., by the hybrid log store which builds input-only summaries). if l.ContentSummary == "" { @@ -813,6 +874,37 @@ func (l *Log) DeserializeFields() error { } } + if l.TeamIDs != nil && *l.TeamIDs != "" { + if err := sonic.Unmarshal([]byte(*l.TeamIDs), &l.TeamIDsParsed); err != nil { + l.TeamIDsParsed = nil + } + } + if l.TeamNames != nil && *l.TeamNames != "" { + if err := sonic.Unmarshal([]byte(*l.TeamNames), &l.TeamNamesParsed); err != nil { + l.TeamNamesParsed = nil + } + } + if l.CustomerIDs != nil && *l.CustomerIDs != "" { + if err := sonic.Unmarshal([]byte(*l.CustomerIDs), &l.CustomerIDsParsed); err != nil { + l.CustomerIDsParsed = nil + } + } + if l.CustomerNames != nil && *l.CustomerNames != "" { + if err := sonic.Unmarshal([]byte(*l.CustomerNames), &l.CustomerNamesParsed); err != nil { + l.CustomerNamesParsed = nil + } + } + if l.BusinessUnitIDs != nil && *l.BusinessUnitIDs != "" { + if err := sonic.Unmarshal([]byte(*l.BusinessUnitIDs), &l.BusinessUnitIDsParsed); err != nil { + l.BusinessUnitIDsParsed = nil + } + } + if l.BusinessUnitNames != nil && *l.BusinessUnitNames != "" { + if err := sonic.Unmarshal([]byte(*l.BusinessUnitNames), &l.BusinessUnitNamesParsed); err != nil { + l.BusinessUnitNamesParsed = nil + } + } + if l.RoutingEnginesUsedStr != nil && *l.RoutingEnginesUsedStr != "" { // Parse comma-separated routing engines l.RoutingEnginesUsed = strings.Split(*l.RoutingEnginesUsedStr, ",") @@ -1601,6 +1693,13 @@ type DimensionRankingWithTrend struct { type DimensionRankingResult struct { Rankings []DimensionRankingWithTrend `json:"rankings"` Dimension RankingDimension `json:"dimension"` + // TotalActualRequests / TotalAttributedRequests are only set for fan-out + // dimensions (team / business unit / customer) on Postgres. Attributed + // counts credit a request to every dimension value it touches, so their + // sum can exceed the real request count; actual is COUNT(DISTINCT id) + // over the same attributed population. Zero/omitted when not computed. + TotalActualRequests int64 `json:"total_actual_requests,omitempty"` + TotalAttributedRequests int64 `json:"total_attributed_requests,omitempty"` } // NodeUsageCursor identifies the last log row included in a node usage scan. diff --git a/plugins/governance/main.go b/plugins/governance/main.go index 203123e667b..ae17e1ad1b5 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -1530,7 +1530,7 @@ func (p *GovernancePlugin) PostLLMHook(ctx *schemas.BifrostContext, result *sche // lookups) and attach them to the context. The logging plugin reads these keys // when building the log entry, enabling ghost-node usage reconciliation to // attribute cost/tokens to the correct governance entities. - budgetIDs, rateLimitIDs := p.store.CollectApplicableGovernanceIDs(ctx, effectiveVK, provider, requestedModel) + budgetIDs, rateLimitIDs := p.store.CollectApplicableGovernanceIDs(ctx, effectiveVK, userID, provider, requestedModel) if len(budgetIDs) > 0 { ctx.SetValue(schemas.BifrostContextKeyGovernanceBudgetIDs, budgetIDs) } diff --git a/plugins/governance/modelprovidergovernance_test.go b/plugins/governance/modelprovidergovernance_test.go index c3a0a985d89..ba497a6767d 100644 --- a/plugins/governance/modelprovidergovernance_test.go +++ b/plugins/governance/modelprovidergovernance_test.go @@ -2476,3 +2476,39 @@ func TestStore_CheckVirtualKeyScopedModelBudget_MultiBudget_OneExceededBlocks(t _, err = store.CheckScopedModelBudget(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) assert.Error(t, err, "an exceeded budget among several on a VK-scoped config must block") } + +// TestCollectApplicableGovernanceIDs_UserScopedModelConfigs pins the user-path +// half of the log row's budget_ids / rate_limit_ids stamping: the tracker +// charges user-scoped model-config budgets / rate limits whenever a user is +// resolved (UpdateScopedModel*UsageInMemory with ModelConfigScopeUser), so +// CollectApplicableGovernanceIDs must report those IDs for ghost-node +// reconciliation — and must not when no (or a different) user is present. +func TestCollectApplicableGovernanceIDs_UserScopedModelConfigs(t *testing.T) { + logger := NewMockLogger() + userID := "user1" + budget := buildBudget("user-mc-b", 100.0, "1h") + rl := buildRateLimit("user-mc-rl", 1000, 100) + mc := buildModelConfig("mc-user", "gpt-4", nil, budget, rl) + mc.Scope = configstoreTables.ModelConfigScopeUser + mc.ScopeID = &userID + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*budget}, + RateLimits: []configstoreTables.TableRateLimit{*rl}, + }, nil) + require.NoError(t, err) + + budgetIDs, rateLimitIDs := store.CollectApplicableGovernanceIDs(context.Background(), "", "user1", schemas.OpenAI, "gpt-4") + assert.Contains(t, budgetIDs, "user-mc-b", "user-scoped model budget must reach the log row") + assert.Contains(t, rateLimitIDs, "user-mc-rl", "user-scoped model rate limit must reach the log row") + + // No user resolved → the user-scoped IDs must not leak onto the row. + budgetIDs, rateLimitIDs = store.CollectApplicableGovernanceIDs(context.Background(), "", "", schemas.OpenAI, "gpt-4") + assert.NotContains(t, budgetIDs, "user-mc-b") + assert.NotContains(t, rateLimitIDs, "user-mc-rl") + + // A different user's request must not pick up this user's scoped IDs. + budgetIDs, rateLimitIDs = store.CollectApplicableGovernanceIDs(context.Background(), "", "user2", schemas.OpenAI, "gpt-4") + assert.NotContains(t, budgetIDs, "user-mc-b") + assert.NotContains(t, rateLimitIDs, "user-mc-rl") +} diff --git a/plugins/governance/store.go b/plugins/governance/store.go index 6bc14ecf47e..250b668e912 100644 --- a/plugins/governance/store.go +++ b/plugins/governance/store.go @@ -160,6 +160,10 @@ type GovernanceStore interface { // Team level CheckUserBudget CheckTeamBudget(ctx context.Context, teamID string, request *EvaluationRequest, baselines map[string]float64) (Decision, error) CheckTeamRateLimit(ctx context.Context, teamID string, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) + // Team-level live budget/rate-limit collectors (resolved from the hot maps); + // used by the enterprise user→team→business-unit hierarchy collector. + CollectTeamBudgets(ctx context.Context, teamID string) []*configstoreTables.TableBudget + CollectTeamRateLimits(ctx context.Context, teamID string) []*configstoreTables.TableRateLimit // Customer-level governance checks CheckCustomerBudget(ctx context.Context, customerID string, request *EvaluationRequest, baselines map[string]float64) (Decision, error) CheckCustomerRateLimit(ctx context.Context, customerID string, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) @@ -190,12 +194,11 @@ type GovernanceStore interface { GetScopedRoutingRules(ctx context.Context, scope string, scopeID string) []*configstoreTables.TableRoutingRule UpdateRoutingRuleInMemory(ctx context.Context, rule *configstoreTables.TableRoutingRule) error DeleteRoutingRuleInMemory(ctx context.Context, id string) error - // CollectApplicableGovernanceIDs returns the budget and rate-limit IDs that - // govern a request for the given virtual key, provider, and model. The - // returned IDs are attached to log entries so that ghost-node usage - // reconciliation can attribute cost and tokens to the correct governance - // entities. - CollectApplicableGovernanceIDs(ctx context.Context, virtualKey string, provider schemas.ModelProvider, model string) (budgetIDs []string, rateLimitIDs []string) + // CollectApplicableGovernanceIDs returns every budget and rate-limit ID this node charges for the given (virtualKey, userID, provider, model). + // The IDs are stamped on the log row so ghost-node reconciliation can re-attribute cost and tokens; + // missing any ID here means that usage vanishes from cluster baselines when the node ghosts. + // userID contributes the user-scoped model-config IDs; + CollectApplicableGovernanceIDs(ctx context.Context, virtualKey string, userID string, provider schemas.ModelProvider, model string) (budgetIDs []string, rateLimitIDs []string) } // NewLocalGovernanceStore creates a new in-memory governance store @@ -1267,6 +1270,117 @@ func (gs *LocalGovernanceStore) CheckTeamRateLimit(ctx context.Context, teamID s return gs.CheckRateLimit(ctx, entityWiseRateLimits, tokensBaselines, requestsBaselines) } +// CollectTeamBudgets returns the live budget objects configured for a team, +// resolved by ID from the hot budgets map (so usage counters and recent edits +// are reflected). Mirrors the read pattern in CheckTeamBudget. Returns nil when +// the team is unknown or has no budgets. Exported so the enterprise layer can +// fold team budgets into a user→team→business-unit hierarchy collector the same +// way collectBudgetsFromHierarchy folds them into the VK hierarchy. +func (gs *LocalGovernanceStore) CollectTeamBudgets(ctx context.Context, teamID string) []*configstoreTables.TableBudget { + if teamID == "" { + return nil + } + teamValue, exists := gs.teams.Load(teamID) + if !exists || teamValue == nil { + return nil + } + team, ok := teamValue.(*configstoreTables.TableTeam) + if !ok || team == nil || len(team.Budgets) == 0 { + return nil + } + list := make([]*configstoreTables.TableBudget, 0, len(team.Budgets)) + for _, b := range team.Budgets { + if hot := gs.LoadBudget(ctx, b.ID); hot != nil { + list = append(list, hot) + } + } + if len(list) == 0 { + return nil + } + return list +} + +// CollectTeamRateLimits returns the live rate-limit object configured for a team +// (at most one), resolved by ID from the hot rate-limits map. Mirrors the read +// pattern in CheckTeamRateLimit. Returns nil when the team is unknown or has no +// rate limit. Exported for the enterprise user-hierarchy collector. +func (gs *LocalGovernanceStore) CollectTeamRateLimits(ctx context.Context, teamID string) []*configstoreTables.TableRateLimit { + if teamID == "" { + return nil + } + teamValue, exists := gs.teams.Load(teamID) + if !exists || teamValue == nil { + return nil + } + team, ok := teamValue.(*configstoreTables.TableTeam) + if !ok || team == nil || team.RateLimitID == nil { + return nil + } + rl := gs.LoadRateLimit(ctx, *team.RateLimitID) + if rl == nil { + return nil + } + return []*configstoreTables.TableRateLimit{rl} +} + +// CollectCustomerBudgets returns the customer's live budgets resolved from the hot budgets map, or nil if the customer is unknown or has none. +func (gs *LocalGovernanceStore) CollectCustomerBudgets(ctx context.Context, customerID string) []*configstoreTables.TableBudget { + if customerID == "" { + return nil + } + customerValue, exists := gs.customers.Load(customerID) + if !exists || customerValue == nil { + return nil + } + customer, ok := customerValue.(*configstoreTables.TableCustomer) + if !ok || customer == nil || len(customer.Budgets) == 0 { + return nil + } + list := make([]*configstoreTables.TableBudget, 0, len(customer.Budgets)) + for i := range customer.Budgets { + if hot := gs.LoadBudget(ctx, customer.Budgets[i].ID); hot != nil { + list = append(list, hot) + } + } + return list +} + +// CollectCustomerRateLimits returns the customer's live rate-limit (at most one) resolved from the hot rate-limits map, or nil if the customer is unknown or has none. +func (gs *LocalGovernanceStore) CollectCustomerRateLimits(ctx context.Context, customerID string) []*configstoreTables.TableRateLimit { + if customerID == "" { + return nil + } + customerValue, exists := gs.customers.Load(customerID) + if !exists || customerValue == nil { + return nil + } + customer, ok := customerValue.(*configstoreTables.TableCustomer) + if !ok || customer == nil || customer.RateLimitID == nil { + return nil + } + rl := gs.LoadRateLimit(ctx, *customer.RateLimitID) + if rl == nil { + return nil + } + return []*configstoreTables.TableRateLimit{rl} +} + +// GetTeamCustomerID returns a team's scalar customer id (TableTeam.CustomerID), or "" if the team is unknown or has no scalar customer. The enterprise layer uses it to exclude that customer from its M2M team→customer propagation so the OSS VK→team→customer hierarchy doesn't double-charge it. +func (gs *LocalGovernanceStore) GetTeamCustomerID(ctx context.Context, teamID string) string { + if teamID == "" { + return "" + } + teamValue, exists := gs.teams.Load(teamID) + if !exists || teamValue == nil { + return "" + } + team, ok := teamValue.(*configstoreTables.TableTeam) + if !ok || team == nil || team.CustomerID == nil { + return "" + } + return *team.CustomerID +} + // CheckCustomerBudget checks customer-level budget and returns evaluation result if violated func (gs *LocalGovernanceStore) CheckCustomerBudget(ctx context.Context, customerID string, request *EvaluationRequest, baselines map[string]float64) (Decision, error) { if customerID == "" { @@ -2520,7 +2634,7 @@ func (gs *LocalGovernanceStore) collectRateLimitIDsFromMemory(ctx context.Contex // affected by a request with the given virtual key, provider, and model. // It combines provider-level, model-level, and VK-hierarchy (team/customer) IDs. // All lookups are fast in-memory sync.Map reads. -func (gs *LocalGovernanceStore) CollectApplicableGovernanceIDs(ctx context.Context, virtualKey string, provider schemas.ModelProvider, model string) (budgetIDs []string, rateLimitIDs []string) { +func (gs *LocalGovernanceStore) CollectApplicableGovernanceIDs(ctx context.Context, virtualKey string, userID string, provider schemas.ModelProvider, model string) (budgetIDs []string, rateLimitIDs []string) { seenBudgets := map[string]bool{} seenRateLimits := map[string]bool{} @@ -2568,6 +2682,13 @@ func (gs *LocalGovernanceStore) CollectApplicableGovernanceIDs(ctx context.Conte } } + // --- User-scoped model configs (user / AP path) --- + if userID != "" && model != "" { + for _, mc := range gs.collectModelConfigsFor(ctx, configstoreTables.ModelConfigScopeUser, userID, model, providerStr) { + addModelConfigIDs(mc) + } + } + // --- VK hierarchy (VK-scoped model configs + team/customer) --- if virtualKey != "" { if vk, exists := gs.GetVirtualKey(ctx, virtualKey); exists && vk != nil { @@ -2862,15 +2983,29 @@ func (gs *LocalGovernanceStore) DeleteVirtualKeyInMemory(ctx context.Context, vk // Evict any model configs scoped to this virtual key (and their budgets/rate-limits). // Mirrors the DB-side cleanup in DeleteVirtualKey and keeps the in-memory store // consistent even when the VK entry was already removed. + gs.DeleteModelConfigsForScopeInMemory(ctx, configstoreTables.ModelConfigScopeVirtualKey, vkID) +} + +// DeleteModelConfigsForScopeInMemory evicts every cached model config targeting the +// given scope owner (e.g. scope=virtual_key, scopeID=) along with the budgets +// and rate-limits those configs own. It is the in-memory mirror of +// RDBConfigStore.DeleteModelConfigsForScope; every owner-eviction path routes through +// here so the cleanup lives in one place. Exported so out-of-package owner-eviction +// paths (e.g. the enterprise user-deletion flow) reuse it. Owned budgets are released +// from both the active Budgets slice and the legacy single BudgetID column. +func (gs *LocalGovernanceStore) DeleteModelConfigsForScopeInMemory(ctx context.Context, scope, scopeID string) { gs.modelConfigs.Range(func(key, value any) bool { mc, ok := value.(*configstoreTables.TableModelConfig) if !ok || mc == nil { return true } - if mc.Scope == configstoreTables.ModelConfigScopeVirtualKey && mc.ScopeID != nil && *mc.ScopeID == vkID { + if mc.Scope == scope && mc.ScopeID != nil && *mc.ScopeID == scopeID { for i := range mc.Budgets { gs.DeleteBudget(ctx, mc.Budgets[i].ID) } + if mc.BudgetID != nil { + gs.DeleteBudget(ctx, *mc.BudgetID) + } if mc.RateLimitID != nil { gs.DeleteRateLimit(ctx, *mc.RateLimitID) } diff --git a/plugins/logging/main.go b/plugins/logging/main.go index 6b4e93b4a63..3e21b231201 100644 --- a/plugins/logging/main.go +++ b/plugins/logging/main.go @@ -919,6 +919,24 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas. if rateLimitIDs, ok := ctx.Value(schemas.BifrostContextKeyGovernanceRateLimitIDs).([]string); ok && len(rateLimitIDs) > 0 { entry.RateLimitIDsParsed = rateLimitIDs } + if teamIDs, ok := ctx.Value(schemas.BifrostContextKeyGovernanceTeamIDs).([]string); ok && len(teamIDs) > 0 { + entry.TeamIDsParsed = teamIDs + } + if teamNames, ok := ctx.Value(schemas.BifrostContextKeyGovernanceTeamNames).([]string); ok && len(teamNames) > 0 { + entry.TeamNamesParsed = teamNames + } + if buIDs, ok := ctx.Value(schemas.BifrostContextKeyGovernanceBusinessUnitIDs).([]string); ok && len(buIDs) > 0 { + entry.BusinessUnitIDsParsed = buIDs + } + if buNames, ok := ctx.Value(schemas.BifrostContextKeyGovernanceBusinessUnitNames).([]string); ok && len(buNames) > 0 { + entry.BusinessUnitNamesParsed = buNames + } + if customerIDs, ok := ctx.Value(schemas.BifrostContextKeyGovernanceCustomerIDs).([]string); ok && len(customerIDs) > 0 { + entry.CustomerIDsParsed = customerIDs + } + if customerNames, ok := ctx.Value(schemas.BifrostContextKeyGovernanceCustomerNames).([]string); ok && len(customerNames) > 0 { + entry.CustomerNamesParsed = customerNames + } entry.MetadataParsed = pending.InitialData.Metadata entry.MetadataParsed = mergeRealtimeMetadata(entry.MetadataParsed, ctx) entry.RoutingEngineLogs = routingEngineLogs diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index b6714e0bad1..c9a05f5d97a 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -3200,26 +3200,6 @@ func createGovernanceConfigInStore(ctx context.Context, config *Config) { return fmt.Errorf("failed to create rate limit %s: %w", rateLimit.ID, err) } } - for i := range config.GovernanceConfig.ModelConfigs { - modelConfig := &config.GovernanceConfig.ModelConfigs[i] - if err := validateModelConfigGovernanceOwnership(tx, *modelConfig); err != nil { - return err - } - modelConfigHash, err := configstore.GenerateModelConfigHash(*modelConfig) - if err != nil { - logger.Warn("failed to generate model config hash for %s: %v", modelConfig.ID, err) - } else { - modelConfig.ConfigHash = modelConfigHash - } - if err := config.ConfigStore.CreateModelConfig(ctx, modelConfig, tx); err != nil { - return fmt.Errorf("failed to create model config %s: %w", modelConfig.ID, err) - } - if len(modelConfig.BudgetIDs) > 0 { - if err := linkModelConfigBudgets(tx, modelConfig.ID, modelConfig.BudgetIDs); err != nil { - return err - } - } - } for i := range config.GovernanceConfig.Providers { provider := &config.GovernanceConfig.Providers[i] if provider.Name == "" { @@ -3363,6 +3343,31 @@ func createGovernanceConfigInStore(ctx context.Context, config *Config) { } } + // Create model configs last: scope=virtual_key configs reference a virtual key by + // scope_id, and CreateModelConfig now locks that owner row and rejects a missing one + // (ErrNotFound), so the owning virtual keys must already exist. Budgets referenced via + // BudgetIDs are likewise all created by this point. + for i := range config.GovernanceConfig.ModelConfigs { + modelConfig := &config.GovernanceConfig.ModelConfigs[i] + if err := validateModelConfigGovernanceOwnership(tx, *modelConfig); err != nil { + return err + } + modelConfigHash, err := configstore.GenerateModelConfigHash(*modelConfig) + if err != nil { + logger.Warn("failed to generate model config hash for %s: %v", modelConfig.ID, err) + } else { + modelConfig.ConfigHash = modelConfigHash + } + if err := config.ConfigStore.CreateModelConfig(ctx, modelConfig, tx); err != nil { + return fmt.Errorf("failed to create model config %s: %w", modelConfig.ID, err) + } + if len(modelConfig.BudgetIDs) > 0 { + if err := linkModelConfigBudgets(tx, modelConfig.ID, modelConfig.BudgetIDs); err != nil { + return err + } + } + } + // Create pricing overrides after virtual keys so that scoped overrides referencing // a virtual key ID are inserted after the VK row exists. for i := range config.GovernanceConfig.PricingOverrides { diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index 481332040f4..a1b612d2700 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -1151,6 +1151,10 @@ func (m *MockConfigStore) DeleteModelConfig(ctx context.Context, id string, tx . return nil } +func (m *MockConfigStore) DeleteModelConfigsForScope(ctx context.Context, tx *gorm.DB, scope, scopeID string) error { + return nil +} + // Budget/Rate limit usage func (m *MockConfigStore) UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64, tx ...*gorm.DB) error { return nil diff --git a/ui/app/_fallbacks/enterprise/components/user-groups/sheets/customerDetailSheet.tsx b/ui/app/_fallbacks/enterprise/components/user-groups/sheets/customerDetailSheet.tsx new file mode 100644 index 00000000000..3351fd3b7c8 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/user-groups/sheets/customerDetailSheet.tsx @@ -0,0 +1,165 @@ +import { Label } from "@/components/ui/label"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { resetDurationLabels } from "@/lib/constants/governance"; +import { Customer } from "@/lib/types/governance"; +import { cn } from "@/lib/utils"; +import { formatCompactNumber } from "@/lib/utils/numbers"; + +interface Props { + customer: Customer | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function formatResetDuration(duration: string | null | undefined): string { + if (!duration) return ""; + return resetDurationLabels[duration] || duration; +} + +function formatCurrency(value: number): string { + return `$${value.toFixed(2)}`; +} + +function DetailCard({ title, children, contentClassName }: { title: string; children: React.ReactNode; contentClassName?: string }) { + return ( +
+
+

{title}

+
+
{children}
+
+ ); +} + + +function BudgetLineBar({ current, max, resetDuration }: { current: number; max: number; resetDuration?: string }) { + const pct = max > 0 ? Math.min((current / max) * 100, 100) : 0; + const isOver80 = pct >= 80; + const isOver100 = pct >= 100; + return ( +
+
+ + {formatResetDuration(resetDuration)} +
+
+
+
+
+ + {formatCurrency(current)} / {formatCurrency(max)} + + {pct.toFixed(0)}% +
+
+ ); +} + +function RateLimitBar({ label, current, max, resetDuration }: { label: string; current: number; max: number; resetDuration?: string }) { + const pct = max > 0 ? Math.min((current / max) * 100, 100) : 0; + const isOver80 = pct >= 80; + const isOver100 = pct >= 100; + return ( +
+
+ {label} + {formatResetDuration(resetDuration)} +
+
+
+
+
+ + {formatCompactNumber(current)} / {formatCompactNumber(max)} + + {pct.toFixed(0)}% +
+
+ ); +} + + +// +// OSS fallback for the enterprise CustomerDetailSheet. It renders the Info, +// Budgets, and Rate Limits sections from the customer already in hand, and omits +// the Teams / Business Units sections, which depend on enterprise-only APIs. + +export function CustomerDetailSheet({ customer, open, onOpenChange }: Props) { + const budgets = customer?.budgets ?? []; + const rateLimit = customer?.rate_limit; + const hasRateLimit = rateLimit?.token_max_limit != null || rateLimit?.request_max_limit != null; + + return ( + + + + {customer?.name || "Customer Details"} + Usage details for this customer. + + + {customer && ( +
+ {/* ── Info ─────────────────────────────────────────── */} + +
+
+ +

{customer.name ?? "—"}

+
+
+
+ + {/* ── Budgets ──────────────────────────────────────── */} + + {budgets.length > 0 ? ( +
+ {[...budgets] + .sort((a, b) => (b.max_limit || 0) - (a.max_limit || 0)) + .map((b) => ( + + ))} +
+ ) : ( +

No budgets configured

+ )} +
+ + {/* ── Rate Limits ──────────────────────────────────── */} + + {rateLimit && hasRateLimit ? ( +
+ {rateLimit.token_max_limit != null && ( + + )} + {rateLimit.request_max_limit != null && ( + + )} +
+ ) : ( +

No rate limits configured

+ )} +
+
+ )} +
+
+ ); +} + +export default CustomerDetailSheet; diff --git a/ui/app/_fallbacks/enterprise/components/user-groups/teamsView.tsx b/ui/app/_fallbacks/enterprise/components/user-groups/teamsView.tsx index 7bf74fbae88..58f5bd69472 100644 --- a/ui/app/_fallbacks/enterprise/components/user-groups/teamsView.tsx +++ b/ui/app/_fallbacks/enterprise/components/user-groups/teamsView.tsx @@ -1,9 +1,9 @@ import TeamsTable from "@/app/workspace/governance/views/teamsTable"; import FullPageLoader from "@/components/fullPageLoader"; import { useDebouncedValue } from "@/hooks/useDebounce"; +import { parseAsSafeString } from "@/lib/queryParamsParser"; import { getErrorMessage, useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; -import { parseAsSafeString } from "@/lib/queryParamsParser"; import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; import { useEffect, useRef } from "react"; import { toast } from "sonner"; @@ -93,26 +93,24 @@ export function TeamsView() { } return ( -
- setUrlState({ search: val || null, offset: 0 }, { history: "replace" })} - offset={urlState.offset} - limit={PAGE_SIZE} - onOffsetChange={(newOffset) => setUrlState({ offset: newOffset })} - selectedTeamId={urlState.selected_team || null} - onTeamAdd={() => setUrlState({ selected_team: "new" })} - onTeamSelect={(team) => { - setUrlState({ selected_team: team?.id ?? null }); - }} - onDialogClose={() => setUrlState({ selected_team: null })} - isLoading={isFetching} - /> -
+ setUrlState({ search: val || null, offset: 0 }, { history: "replace" })} + offset={urlState.offset} + limit={PAGE_SIZE} + onOffsetChange={(newOffset) => setUrlState({ offset: newOffset })} + selectedTeamId={urlState.selected_team || null} + onTeamAdd={() => setUrlState({ selected_team: "new" })} + onTeamSelect={(team) => { + setUrlState({ selected_team: team?.id ?? null }); + }} + onDialogClose={() => setUrlState({ selected_team: null })} + isLoading={isFetching} + /> ); } \ No newline at end of file diff --git a/ui/app/main.tsx b/ui/app/main.tsx index 35e327c3a35..55cf51495f6 100644 --- a/ui/app/main.tsx +++ b/ui/app/main.tsx @@ -1,4 +1,4 @@ -import { RouterProvider, createRouter, parseSearchWith } from "@tanstack/react-router"; +import { RouterProvider, createRouter, parseSearchWith, stringifySearchWith } from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; @@ -23,6 +23,17 @@ function safeJsonParse(value: string): unknown { return value; } +// Keep query params compatible with nuqs parsers: primitive strings stay raw +// (so numeric-looking IDs are not JSON-quoted), while arrays use comma lists. +function stringifySearchValue(value: unknown): string { + if (Array.isArray(value)) return value.map(String).join(","); + if (value === null || value === undefined) return ""; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return JSON.stringify(value); +} + const router = createRouter({ routeTree, defaultPreload: "intent", @@ -31,6 +42,7 @@ const router = createRouter({ defaultNotFoundComponent: NotFoundComponent, defaultErrorComponent: ErrorComponent, parseSearch: parseSearchWith(safeJsonParse), + stringifySearch: stringifySearchWith(stringifySearchValue), }); declare module "@tanstack/react-router" { @@ -46,4 +58,4 @@ createRoot(rootEl).render( , -); \ No newline at end of file +); diff --git a/ui/app/workspace/audit-logs/page.tsx b/ui/app/workspace/audit-logs/page.tsx index 248a590083c..4ed5a506195 100644 --- a/ui/app/workspace/audit-logs/page.tsx +++ b/ui/app/workspace/audit-logs/page.tsx @@ -2,7 +2,7 @@ import AuditLogsView from "@enterprise/components/audit-logs/auditLogsView"; export default function AuditLogsPage() { return ( -
+
); diff --git a/ui/app/workspace/dashboard/components/charts/chartCard.tsx b/ui/app/workspace/dashboard/components/charts/chartCard.tsx index ce0552595f1..dcd815199ba 100644 --- a/ui/app/workspace/dashboard/components/charts/chartCard.tsx +++ b/ui/app/workspace/dashboard/components/charts/chartCard.tsx @@ -15,6 +15,11 @@ interface ChartCardProps { total?: ReactNode; totalLabel?: string; totalTooltip?: ReactNode; + // Optional second labeled total rendered beside the first (e.g. actual vs + // attributed request counts). + secondaryTotal?: ReactNode; + secondaryTotalLabel?: string; + secondaryTotalTooltip?: ReactNode; } function TotalChip({ @@ -61,6 +66,9 @@ function Header({ total, totalLabel, totalTooltip, + secondaryTotal, + secondaryTotalLabel, + secondaryTotalTooltip, testId, }: { title: string; @@ -69,9 +77,13 @@ function Header({ total?: ReactNode; totalLabel?: string; totalTooltip?: ReactNode; + secondaryTotal?: ReactNode; + secondaryTotalLabel?: string; + secondaryTotalTooltip?: ReactNode; testId?: string; }) { const hasTotal = total !== undefined && total !== null; + const hasSecondaryTotal = secondaryTotal !== undefined && secondaryTotal !== null; const hasActionRow = hasTotal || controls; return (
@@ -81,7 +93,17 @@ function Header({ {hasActionRow && (
{hasTotal ? ( - +
+ + {hasSecondaryTotal && ( + + )} +
) : ( )} @@ -104,6 +126,9 @@ export function ChartCard({ total, totalLabel, totalTooltip, + secondaryTotal, + secondaryTotalLabel, + secondaryTotalTooltip, }: ChartCardProps) { if (loading) { return ( @@ -115,6 +140,9 @@ export function ChartCard({ total={total} totalLabel={totalLabel} totalTooltip={totalTooltip} + secondaryTotal={secondaryTotal} + secondaryTotalLabel={secondaryTotalLabel} + secondaryTotalTooltip={secondaryTotalTooltip} testId={testId} />
@@ -133,6 +161,9 @@ export function ChartCard({ total={total} totalLabel={totalLabel} totalTooltip={totalTooltip} + secondaryTotal={secondaryTotal} + secondaryTotalLabel={secondaryTotalLabel} + secondaryTotalTooltip={secondaryTotalTooltip} testId={testId} />
{children}
diff --git a/ui/app/workspace/dashboard/components/charts/costChart.tsx b/ui/app/workspace/dashboard/components/charts/costChart.tsx index 0dd2491eab2..cabea4a836e 100644 --- a/ui/app/workspace/dashboard/components/charts/costChart.tsx +++ b/ui/app/workspace/dashboard/components/charts/costChart.tsx @@ -45,13 +45,13 @@ function CustomTooltip({ active, payload, selectedModel, displayModels }: any) { {isOther ? OTHER_SERIES_LABEL : model} - {formatCost(cost)} + {formatCost(cost)}
); })}
Total - {formatCost(data.total_cost)} + {formatCost(data.total_cost)}
) : ( @@ -60,7 +60,7 @@ function CustomTooltip({ active, payload, selectedModel, displayModels }: any) { {selectedModel} - {formatCost(data.by_model?.[selectedModel] || 0)} + {formatCost(data.by_model?.[selectedModel] || 0)}
)}
@@ -141,7 +141,7 @@ function CostChartImpl({ data, chartType, startTime, endTime, selectedModel }: C domain={[0, (dataMax: number) => Math.max(dataMax, 0.01)]} allowDataOverflow={false} /> - } /> + } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> {displayModels.map((model, idx) => ( Math.max(dataMax, 0.01)]} allowDataOverflow={false} /> - } /> + } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> {displayModels.map((model, idx) => { const color = model === OTHER_SERIES_KEY ? OTHER_SERIES_COLOR : getModelColor(idx); return ( diff --git a/ui/app/workspace/dashboard/components/charts/latencyChart.tsx b/ui/app/workspace/dashboard/components/charts/latencyChart.tsx index 0c4afe86c8e..96db10e5bd1 100644 --- a/ui/app/workspace/dashboard/components/charts/latencyChart.tsx +++ b/ui/app/workspace/dashboard/components/charts/latencyChart.tsx @@ -162,7 +162,7 @@ function LatencyChartImpl({ data, chartType, startTime, endTime }: LatencyChartP domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} allowDataOverflow={false} /> - } /> + } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> {/* Render P99 first (behind), then overlay in descending order so Avg is in front */} {isOther ? OTHER_SERIES_LABEL : model} - {total.toLocaleString()} + {total.toLocaleString()}
); })} @@ -167,7 +167,7 @@ function ModelUsageChartImpl({ data, chartType, startTime, endTime, selectedMode domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} allowDataOverflow={false} /> - } /> + } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> {selectedModel === "all" ? ( displayModels.map((model, idx) => ( Math.max(dataMax, 1)]} allowDataOverflow={false} /> - } /> + } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> {selectedModel === "all" ? ( displayModels.map((model, idx) => { const color = model === OTHER_SERIES_KEY ? OTHER_SERIES_COLOR : getModelColor(idx); diff --git a/ui/app/workspace/dashboard/components/dimensionRankingsTab.tsx b/ui/app/workspace/dashboard/components/dimensionRankingsTab.tsx index 6ced24e0764..64e4bec83f5 100644 --- a/ui/app/workspace/dashboard/components/dimensionRankingsTab.tsx +++ b/ui/app/workspace/dashboard/components/dimensionRankingsTab.tsx @@ -19,6 +19,7 @@ interface DimensionRankingsTabProps { loading: boolean; dimensionLabel: string; testIdPrefix: string; + attributed?: boolean; } function TopDimensionTooltip({ active, payload }: any) { @@ -38,14 +39,16 @@ function TopDimensionChart({ loading, dimensionLabel, testIdPrefix, + attributed, }: { data: DimensionRankingsResponse | null; loading: boolean; dimensionLabel: string; testIdPrefix: string; + attributed?: boolean; }) { - const { chartData, grandTotal, rankedItems } = useMemo(() => { - if (!data?.rankings?.length) return { chartData: [], grandTotal: null, rankedItems: [] }; + const { chartData, grandTotal, rankedItems, actualTotal, attributedTotal } = useMemo(() => { + if (!data?.rankings?.length) return { chartData: [], grandTotal: null, rankedItems: [], actualTotal: null, attributedTotal: null }; const sorted = [...data.rankings].sort((a, b) => b.total_requests - a.total_requests); const top = sorted.slice(0, 10); @@ -64,8 +67,13 @@ function TopDimensionChart({ fill: getModelColor(item.colorIdx), })); - return { chartData: chart, grandTotal: total, rankedItems: items }; - }, [data]); + // Server-computed totals (fan-out dimensions only); when absent, fall + // back to the client-side attributed sum. + const actual = attributed ? (data.total_actual_requests ?? null) : null; + const attributedSum = actual !== null ? (data.total_attributed_requests ?? total) : total; + + return { chartData: chart, grandTotal: total, rankedItems: items, actualTotal: actual, attributedTotal: attributedSum }; + }, [data, attributed]); return ( : undefined} - totalTooltip={grandTotal !== null ? grandTotal.toLocaleString("en-US") : undefined} + totalLabel={attributed && actualTotal === null ? "Total Requests (attributed)" : "Total Requests"} + total={ + actualTotal !== null ? ( + + ) : grandTotal !== null ? ( + + ) : undefined + } + totalTooltip={ + grandTotal === null ? undefined : actualTotal !== null ? ( +
Actual number of requests sent
+ ) : attributed ? ( +
+
+ Attributed - a request counts toward each {dimensionLabel.toLowerCase()} it belongs to, so this can exceed the actual request + count. +
+
+ ) : ( + grandTotal.toLocaleString("en-US") + ) + } + secondaryTotalLabel="Attributed Requests" + secondaryTotal={actualTotal !== null ? : undefined} + secondaryTotalTooltip={ + actualTotal === null ? undefined : ( +
+
+ A request counts toward each {dimensionLabel.toLowerCase()} it belongs to, so this can exceed the total request count. +
+
+ ) + } >
{chartData.length > 0 ? ( - + { const { x, y, payload } = props; - const maxChars = 14; - const label = - payload.value.length > maxChars - ? `${payload.value.slice(0, maxChars)}…` - : payload.value; + const labelWidth = 92; return ( - - {payload.value} - {label} - + +
+ {payload.value} +
+
); }} tickLine={false} axisLine={false} - width={92} + width={100} /> - } /> + } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> ("total_requests"); const [sortOrder, setSortOrder] = useState("desc"); @@ -187,6 +221,7 @@ function DimensionRankingsTabImpl({ data, loading, dimensionLabel, testIdPrefix loading={loading} dimensionLabel={dimensionLabel} testIdPrefix={testIdPrefix} + attributed={attributed} /> {loading ? ( @@ -240,9 +275,7 @@ function DimensionRankingsTabImpl({ data, loading, dimensionLabel, testIdPrefix
{entry.name || entry.id} - {entry.name && entry.name !== entry.id && ( - {entry.id} - )} + {entry.name && entry.name !== entry.id && {entry.id}}
@@ -273,4 +306,4 @@ function DimensionRankingsTabImpl({ data, loading, dimensionLabel, testIdPrefix ); } -export const DimensionRankingsTab = memo(DimensionRankingsTabImpl); +export const DimensionRankingsTab = memo(DimensionRankingsTabImpl); \ No newline at end of file diff --git a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx index 07fc78703ac..26147111d91 100644 --- a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx +++ b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx @@ -190,7 +190,7 @@ function TopModelsChart({ domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} allowDataOverflow={false} /> - } /> + } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> {displayModels.map((model, idx) => ( ; + return ( + + ); }, ); diff --git a/ui/app/workspace/governance/access-profiles/page.tsx b/ui/app/workspace/governance/access-profiles/page.tsx index f7ee0a84f0a..fc6c52690c0 100644 --- a/ui/app/workspace/governance/access-profiles/page.tsx +++ b/ui/app/workspace/governance/access-profiles/page.tsx @@ -10,7 +10,7 @@ export default function AccessProfilesPage() { } return ( -
+
); diff --git a/ui/app/workspace/governance/business-units/page.tsx b/ui/app/workspace/governance/business-units/page.tsx index a79fff05137..ae707f93412 100644 --- a/ui/app/workspace/governance/business-units/page.tsx +++ b/ui/app/workspace/governance/business-units/page.tsx @@ -2,7 +2,7 @@ import { BusinessUnitsView } from "@enterprise/components/user-groups/businessUn export default function GovernanceBusinessUnitsPage() { return ( -
+
); diff --git a/ui/app/workspace/governance/customers/page.tsx b/ui/app/workspace/governance/customers/page.tsx index 0ab897b09f3..0807e5cf844 100644 --- a/ui/app/workspace/governance/customers/page.tsx +++ b/ui/app/workspace/governance/customers/page.tsx @@ -1,10 +1,10 @@ import CustomersTable from "@/app/workspace/governance/views/customerTable"; import FullPageLoader from "@/components/fullPageLoader"; import { useDebouncedValue } from "@/hooks/useDebounce"; +import { parseAsSafeString } from "@/lib/queryParamsParser"; import { getErrorMessage, useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; -import { parseAsSafeString } from "@/lib/queryParamsParser"; -import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; +import { parseAsInteger, useQueryStates } from "nuqs"; import { useEffect, useRef } from "react"; import { toast } from "sonner"; @@ -89,7 +89,7 @@ export default function GovernanceCustomersPage() { } return ( -
+
+
); diff --git a/ui/app/workspace/governance/teams/page.tsx b/ui/app/workspace/governance/teams/page.tsx index 4eb479daa68..18f44472ffc 100644 --- a/ui/app/workspace/governance/teams/page.tsx +++ b/ui/app/workspace/governance/teams/page.tsx @@ -1,5 +1,7 @@ import { TeamsView } from "@enterprise/components/user-groups/teamsView"; export default function GovernanceTeamsPage() { - return ; + return (
+ +
); } \ No newline at end of file diff --git a/ui/app/workspace/governance/users/page.tsx b/ui/app/workspace/governance/users/page.tsx index e20f1394d77..7f13cfdabe1 100644 --- a/ui/app/workspace/governance/users/page.tsx +++ b/ui/app/workspace/governance/users/page.tsx @@ -2,7 +2,7 @@ import UsersView from "@enterprise/components/user-groups/usersView"; export default function GovernanceUsersPage() { return ( -
+
); diff --git a/ui/app/workspace/governance/views/customerSheet.tsx b/ui/app/workspace/governance/views/customerSheet.tsx index 653b16f4d43..0c5d8ff3c9b 100644 --- a/ui/app/workspace/governance/views/customerSheet.tsx +++ b/ui/app/workspace/governance/views/customerSheet.tsx @@ -8,7 +8,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alertDialog"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -20,10 +19,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { resetDurationOptions, supportsCalendarAlignment } from "@/lib/constants/governance"; import { getErrorMessage, useCreateCustomerMutation, useUpdateCustomerMutation } from "@/lib/store"; import { CreateBudgetRequest, CreateCustomerRequest, Customer, UpdateCustomerRequest } from "@/lib/types/governance"; -import { formatCurrency } from "@/lib/utils/governance"; import { Validator } from "@/lib/utils/validation"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; -import { formatDistanceToNow } from "date-fns"; import isEqual from "lodash.isequal"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -264,8 +261,8 @@ export default function CustomerSheet({ open, onOpenChange, customer, onSuccess return ( - - + + {isEditing ? "Edit Customer" : "Create Customer"} {isEditing @@ -274,8 +271,8 @@ export default function CustomerSheet({ open, onOpenChange, customer, onSuccess -
-
+ +
@@ -365,81 +362,10 @@ export default function CustomerSheet({ open, onOpenChange, customer, onSuccess - - {isEditing && ((customer?.budgets?.length ?? 0) > 0 || customer?.rate_limit) && ( -
-

Current Usage

-
- {customer?.budgets?.map((budget) => ( -
-

Budget ({budget.reset_duration})

-
- - {formatCurrency(budget.current_usage)} / {formatCurrency(budget.max_limit)} - - = budget.max_limit ? "destructive" : "default"} - className="text-xs" - > - {Math.round((budget.current_usage / budget.max_limit) * 100)}% - -
-

- Last Reset: {formatDistanceToNow(new Date(budget.last_reset), { addSuffix: true })} -

-
- ))} - {customer?.rate_limit?.token_max_limit && ( -
-

Tokens

-
- - {customer.rate_limit.token_current_usage.toLocaleString()} /{" "} - {customer.rate_limit.token_max_limit.toLocaleString()} - - = customer.rate_limit.token_max_limit ? "destructive" : "default" - } - className="text-xs" - > - {Math.round((customer.rate_limit.token_current_usage / customer.rate_limit.token_max_limit) * 100)}% - -
-

- Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.token_last_reset), { addSuffix: true })} -

-
- )} - {customer?.rate_limit?.request_max_limit && ( -
-

Requests

-
- - {customer.rate_limit.request_current_usage.toLocaleString()} /{" "} - {customer.rate_limit.request_max_limit.toLocaleString()} - - = customer.rate_limit.request_max_limit ? "destructive" : "default" - } - className="text-xs" - > - {Math.round((customer.rate_limit.request_current_usage / customer.rate_limit.request_max_limit) * 100)}% - -
-

- Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.request_last_reset), { addSuffix: true })} -

-
- )} -
-
- )}
- + diff --git a/ui/app/workspace/governance/views/customerTable.tsx b/ui/app/workspace/governance/views/customerTable.tsx index 5342e32371c..98ca641e5e8 100644 --- a/ui/app/workspace/governance/views/customerTable.tsx +++ b/ui/app/workspace/governance/views/customerTable.tsx @@ -21,12 +21,14 @@ import { getErrorMessage, useDeleteCustomerMutation } from "@/lib/store"; import { Customer, Team, VirtualKey } from "@/lib/types/governance"; import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils/governance"; +import { CustomerDetailSheet } from "@enterprise/components/user-groups/sheets/customerDetailSheet"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; -import { ChevronLeft, ChevronRight, Edit, MoreHorizontal, Plus, Search, Trash2 } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { ChevronLeft, ChevronRight, Edit, MoreHorizontal, Plus, ScrollText, Search, Trash2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; -import CustomerSheet from "./customerSheet"; import { CustomersEmptyState } from "./customersEmptyState"; +import CustomerSheet from "./customerSheet"; // Helper to format reset duration for display const formatResetDuration = (duration: string) => { @@ -66,14 +68,31 @@ function CustomerActionsMenu({ customer, canUpdate, canDelete, onEdit, onDelete disabled={!canUpdate} data-testid={`customer-button-edit-${customer.id}`} onSelect={(e) => { + e.stopPropagation(); e.preventDefault(); onEdit(customer); setIsOpen(false); }} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} > Edit + + { + e.stopPropagation(); + setIsOpen(false); + }} + onPointerDown={(e) => e.stopPropagation()} + > + + View logs + + e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} > Delete @@ -122,6 +143,7 @@ export default function CustomersTable({ const [showCustomerSheet, setShowCustomerSheet] = useState(false); const [editingCustomer, setEditingCustomer] = useState(null); const [confirmDeleteCustomer, setConfirmDeleteCustomer] = useState(null); + const [viewingCustomer, setViewingCustomer] = useState(null); const hasCreateAccess = useRbac(RbacResource.Customers, RbacOperation.Create); const hasUpdateAccess = useRbac(RbacResource.Customers, RbacOperation.Update); @@ -198,6 +220,14 @@ export default function CustomersTable({ onSuccess={handleCustomerSaved} /> + { + if (!open) setViewingCustomer(null); + }} + customer={viewingCustomer} + /> +
@@ -279,7 +309,20 @@ export default function CustomersTable({ setViewingCustomer(customer)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setViewingCustomer(customer); + } + }} >
@@ -534,4 +577,4 @@ export default function CustomersTable({ ); -} \ No newline at end of file +} diff --git a/ui/app/workspace/governance/views/teamSheet.tsx b/ui/app/workspace/governance/views/teamSheet.tsx index 973832d005f..140276612cb 100644 --- a/ui/app/workspace/governance/views/teamSheet.tsx +++ b/ui/app/workspace/governance/views/teamSheet.tsx @@ -395,8 +395,8 @@ export default function TeamSheet({ onEscapeKeyDown={() => onCancel()} > {isEditing ? "Edit Team" : "Create Team"} diff --git a/ui/app/workspace/governance/views/teamsTable.tsx b/ui/app/workspace/governance/views/teamsTable.tsx index a548ac3dfa0..c0213b302a7 100644 --- a/ui/app/workspace/governance/views/teamsTable.tsx +++ b/ui/app/workspace/governance/views/teamsTable.tsx @@ -22,7 +22,8 @@ import { Customer, Team, VirtualKey } from "@/lib/types/governance"; import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils/governance"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; -import { ChevronLeft, ChevronRight, Edit, MoreHorizontal, Plus, Search, Trash2 } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { ChevronLeft, ChevronRight, Edit, MoreHorizontal, Plus, ScrollText, Search, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import TeamSheet from "./teamSheet"; @@ -79,6 +80,12 @@ function TeamActionsMenu({ Edit + + setIsOpen(false)}> + + View logs + + ); -} \ No newline at end of file +} diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx index 8b7c1f3954e..6a1d74f8cfa 100644 --- a/ui/app/workspace/logs/sheets/logDetailView.tsx +++ b/ui/app/workspace/logs/sheets/logDetailView.tsx @@ -1,5 +1,4 @@ import { formatCost, formatLatency } from "@/app/workspace/dashboard/utils/chartUtils"; -import { formatCompactNumber } from "@/lib/utils/numbers"; import { AlertDialog, AlertDialogAction, @@ -31,6 +30,7 @@ import { RequestTypeColors, RequestTypeLabels, RoutingEngineUsedColors, RoutingE import { ContentBlock, LogEntry, ResponsesMessage } from "@/lib/types/logs"; import { cn } from "@/lib/utils"; import { downloadAsJson } from "@/lib/utils/browser-download"; +import { formatCompactNumber } from "@/lib/utils/numbers"; import { isJson } from "@/lib/utils/validation"; import { Link } from "@tanstack/react-router"; import { addMilliseconds, format } from "date-fns"; @@ -421,7 +421,7 @@ function RoutingDecisionLogs({ logs }: { logs: string }) { className={cn( "inline-block w-24 shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase", RoutingEngineUsedColors[scope as keyof typeof RoutingEngineUsedColors] ?? - "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", + "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", )} > {RoutingEngineUsedLabels[scope as keyof typeof RoutingEngineUsedLabels] ?? scope} @@ -542,18 +542,18 @@ export function LogDetailView({ const isRealtimeTurn = log.object === "realtime.turn"; const passthroughParams = isPassthrough ? (log.params as { - method?: string; - path?: string; - raw_query?: string; - status_code?: number; - }) + method?: string; + path?: string; + raw_query?: string; + status_code?: number; + }) : null; let toolsParameter = null; if (log.params?.tools) { try { toolsParameter = JSON.stringify(log.params.tools, null, 2); - } catch {} + } catch { } } const audioFormat = (log.params as any)?.audio?.format || (log.params as any)?.extra_params?.audio?.format || undefined; @@ -570,7 +570,7 @@ export function LogDetailView({ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return Object.values(parsed).reduce((sum, v) => sum + (Array.isArray(v) ? v.length : 0), 0); } - } catch {} + } catch { } return 0; })(); @@ -658,9 +658,18 @@ export function LogDetailView({ {RequestTypeLabels[log.object as keyof typeof RequestTypeLabels] ?? log.object} {log.routing_rule && ( - - rule: {log.routing_rule.name} - + + + rule: {log.routing_rule.name} + + )} {log.metadata?.isAsyncRequest ? ( @@ -722,13 +731,27 @@ export function LogDetailView({ {log.routing_rule && (
Rule
- “{log.routing_rule.name}” + + “{log.routing_rule.name}” +
)} {log.selected_key && (
Key
- {log.selected_key.name} + + {log.selected_key.name} +
)}
@@ -770,11 +793,10 @@ export function LogDetailView({ } sub={ log.token_usage - ? `total ${formatCompactNumber(log.token_usage.total_tokens ?? 0)}${ - log.token_usage.completion_tokens_details?.reasoning_tokens - ? ` · reasoning ${formatCompactNumber(log.token_usage.completion_tokens_details.reasoning_tokens)}` - : "" - }` + ? `total ${formatCompactNumber(log.token_usage.total_tokens ?? 0)}${log.token_usage.completion_tokens_details?.reasoning_tokens + ? ` · reasoning ${formatCompactNumber(log.token_usage.completion_tokens_details.reasoning_tokens)}` + : "" + }` : "—" } hasRightBorder @@ -896,7 +918,7 @@ export function LogDetailView({ onFilterByParentRequestId(log.parent_request_id as string)} > {log.parent_request_id} @@ -910,68 +932,116 @@ export function LogDetailView({ } /> )} - {log.selected_key && } + {log.selected_key && ( + + {log.selected_key.name} + + } + /> + )} {(log.selected_prompt_id || log.selected_prompt_name || log.selected_prompt_version) && ( - {selectedPromptDisplayName} - {selectedPromptDisplayName && log.selected_prompt_version ? " · " : ""} - {log.selected_prompt_version ? <>v{log.selected_prompt_version} : null} - + + + {selectedPromptDisplayName} + {selectedPromptDisplayName && log.selected_prompt_version ? " · " : ""} + {log.selected_prompt_version ? <>v{log.selected_prompt_version} : null} + + } /> )} {log.number_of_retries > 0 && ( )} - {log.team_id && ( + {(log.team_ids?.length || log.team_id) && ( 1 ? "Teams" : "Team"} value={ - - {log.team_name || log.team_id} - + + {(log.team_ids?.length + ? log.team_ids.map((id, i) => ({ id, name: log.team_names?.[i] || id })) + : [{ id: log.team_id!, name: log.team_name || log.team_id! }] + ).map((t, i, arr) => ( + + {t.name} + {i < arr.length - 1 ? "," : ""} + + ))} + } /> )} - {log.customer_id && ( + {(log.customer_ids?.length || log.customer_id) && ( 1 ? "Customers" : "Customer"} value={ - - {log.customer_name || log.customer_id} - + + {(log.customer_ids?.length + ? log.customer_ids.map((id, i) => ({ id, name: log.customer_names?.[i] || id })) + : [{ id: log.customer_id!, name: log.customer_name || log.customer_id! }] + ).map((c, i, arr) => ( + + {c.name} + {i < arr.length - 1 ? "," : ""} + + ))} + } /> )} - {log.business_unit_id && ( + {(log.business_unit_ids?.length || log.business_unit_id) && ( 1 ? "Business Units" : "Business Unit"} value={ - - {log.business_unit_name || log.business_unit_id} - + + {(log.business_unit_ids?.length + ? log.business_unit_ids.map((id, i) => ({ id, name: log.business_unit_names?.[i] || id })) + : [{ id: log.business_unit_id!, name: log.business_unit_name || log.business_unit_id! }] + ).map((b, i, arr) => ( + + {b.name} + {i < arr.length - 1 ? "," : ""} + + ))} + } /> )} @@ -985,7 +1055,7 @@ export function LogDetailView({ {log.user_name || log.user_id} @@ -997,7 +1067,22 @@ export function LogDetailView({ /> )} {log.fallback_index > 0 && } - {log.virtual_key && } + {log.virtual_key && ( + + {log.virtual_key.name} + + } + /> + )} {log.routing_engines_used && log.routing_engines_used.length > 0 && ( )} - {log.routing_rule && } + {log.routing_rule && ( + + {log.routing_rule.name} + + } + /> + )} {(log.params as any)?.audio && ( <> @@ -1626,11 +1726,11 @@ export function LogDetailView({
{(visibleRoles.size < allRoles.length ? log.input_history?.filter((m) => { - if (!m) return false; - const mainRole = ((m.role as string) || "user") as MessageRole; - const hasReasoning = !!extractChatReasoning(m); - return visibleRoles.has(mainRole) || (hasReasoning && visibleRoles.has("reasoning")); - }) + if (!m) return false; + const mainRole = ((m.role as string) || "user") as MessageRole; + const hasReasoning = !!extractChatReasoning(m); + return visibleRoles.has(mainRole) || (hasReasoning && visibleRoles.has("reasoning")); + }) : log.input_history?.filter(Boolean) )?.flatMap((message, index) => { const role = ((message.role as string) || "user") as MessageRole; diff --git a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx index 0ae95db0491..04482d434ae 100644 --- a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx +++ b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx @@ -24,6 +24,7 @@ import { Status, StatusColors, Statuses } from "@/lib/constants/logs"; import { useGetMCPLogByIdQuery } from "@/lib/store"; import type { MCPToolLogEntry } from "@/lib/types/logs"; import { downloadAsJson } from "@/lib/utils/browser-download"; +import { Link } from "@tanstack/react-router"; import { addMilliseconds, format, isValid } from "date-fns"; import { SheetNavigationButtons } from "@/components/sheetNavigationButtons"; import { useSheetNavigation } from "@/hooks/useSheetNavigation"; @@ -233,27 +234,66 @@ export function MCPLogDetailSheet({ {displayLog.tool_name}} + value={ + + {displayLog.tool_name} + + } /> - {displayLog.server_label} - + + + {displayLog.server_label} + + ) : ( "-" ) } /> - {displayLog.virtual_key && } + {displayLog.virtual_key && ( + + {displayLog.virtual_key.name} + + } + /> + )} {displayLog.llm_request_id && ( {displayLog.llm_request_id}} + value={ + + {displayLog.llm_request_id} + + } /> )}
diff --git a/ui/app/workspace/model-limits/views/modelLimitSheet.tsx b/ui/app/workspace/model-limits/views/modelLimitSheet.tsx index efcab3d9806..ba0b0496abc 100644 --- a/ui/app/workspace/model-limits/views/modelLimitSheet.tsx +++ b/ui/app/workspace/model-limits/views/modelLimitSheet.tsx @@ -322,18 +322,28 @@ export default function ModelLimitSheet({ modelConfig, onSave, onCancel }: Model Model Name -
- -
+ {isEditing ? ( + + ) : ( +
+ +
+ )}
diff --git a/ui/app/workspace/scim/page.tsx b/ui/app/workspace/scim/page.tsx index eda1936e098..a1f6455c261 100644 --- a/ui/app/workspace/scim/page.tsx +++ b/ui/app/workspace/scim/page.tsx @@ -1,9 +1,12 @@ +import { ScrollArea } from "@/components/ui/scrollArea"; import SCIMView from "@enterprise/components/scim/scimView"; export default function SCIMPage() { return ( -
- -
+ +
+ +
+
); } \ No newline at end of file diff --git a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx index 88df95d0a48..916403b1785 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx @@ -140,8 +140,8 @@ export default function VirtualKeyDetailSheet({ return ( - - + +
{virtualKey.name} @@ -158,7 +158,7 @@ export default function VirtualKeyDetailSheet({ /> -
+
{assignedUsers.length > 0 ? ( @@ -645,7 +645,7 @@ export default function VirtualKeyDetailSheet({ {/* Token Limits */} {displayRateLimit.token_max_limit != null ? (
- Token Limits + Token Limits - Request Limits + Request Limits 0 ? virtualKey.budgets.map((b) => ({ - id: b.id, - max_limit: b.max_limit, - reset_duration: b.reset_duration ?? "1M", - })) + id: b.id, + max_limit: b.max_limit, + reset_duration: b.reset_duration ?? "1M", + })) : [], budgetCalendarAligned: virtualKey?.calendar_aligned ?? false, tokenMaxLimit: virtualKey?.rate_limit?.token_max_limit ?? undefined, @@ -840,9 +840,9 @@ export default function VirtualKeySheet({ // Normalize provider configs to ensure weights are numbers and handle budget/rate limits const normalizedProviderConfigs = data.providerConfigs ? normalizeProviderConfigs( - data.providerConfigs, - virtualKey?.provider_configs, - ) + data.providerConfigs, + virtualKey?.provider_configs, + ) : []; if (isEditing && virtualKey) { // Update existing virtual key @@ -853,16 +853,16 @@ export default function VirtualKeySheet({ mcp_configs: data.mcpConfigs, team_id: data.entityType === "team" && - data.teamId && - data.teamId.trim() !== "" + data.teamId && + data.teamId.trim() !== "" ? data.teamId : data.entityType === "none" ? null : undefined, customer_id: data.entityType === "customer" && - data.customerId && - data.customerId.trim() !== "" + data.customerId && + data.customerId.trim() !== "" ? data.customerId : data.entityType === "none" ? null @@ -920,14 +920,14 @@ export default function VirtualKeySheet({ mcp_configs: data.mcpConfigs, team_id: data.entityType === "team" && - data.teamId && - data.teamId.trim() !== "" + data.teamId && + data.teamId.trim() !== "" ? data.teamId : undefined, customer_id: data.entityType === "customer" && - data.customerId && - data.customerId.trim() !== "" + data.customerId && + data.customerId.trim() !== "" ? data.customerId : undefined, is_active: data.isActive, @@ -1002,8 +1002,8 @@ export default function VirtualKeySheet({ onEscapeKeyDown={() => handleClose()} > {isEditing ? virtualKey?.name : "Create Virtual Key"} @@ -1221,7 +1221,7 @@ export default function VirtualKeySheet({ /> { ProviderLabels[ - provider.name as ProviderName + provider.name as ProviderName ] } @@ -1283,8 +1283,8 @@ export default function VirtualKeySheet({ {providerConfig?.custom_provider_config ? providerConfig.name : ProviderLabels[ - config.provider as ProviderName - ]} + config.provider as ProviderName + ]}
+ + + ); + })} + + +
+ )} +
+ )} {/* Budget Configuration */}
@@ -2342,19 +2342,19 @@ export default function VirtualKeySheet({ { value: "none", label: "No Assignment" }, ...(teams?.length > 0 ? [ - { - value: "team", - label: "Assign to Team", - }, - ] + { + value: "team", + label: "Assign to Team", + }, + ] : []), ...(customers?.length > 0 ? [ - { - value: "customer", - label: "Assign to Customer", - }, - ] + { + value: "customer", + label: "Assign to Customer", + }, + ] : []), ]} value={field.value} diff --git a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx index cde9d79a37e..5e4b4f34ad4 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx @@ -1,3 +1,4 @@ +import { BudgetDisplay } from "@/components/budgetDisplay"; import { RateLimitDisplay } from "@/components/rateLimitDisplay"; import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning"; import { @@ -22,7 +23,7 @@ import { Switch } from "@/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; -import { resetDurationLabels, supportsCalendarAlignment } from "@/lib/constants/governance"; +import { resetDurationLabels } from "@/lib/constants/governance"; import { getErrorMessage, useBulkRotateVirtualKeysMutation, @@ -35,6 +36,7 @@ import { Customer, Team, VirtualKey } from "@/lib/types/governance"; import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils/governance"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { Link } from "@tanstack/react-router"; import { ArrowDown, ArrowUp, @@ -52,6 +54,7 @@ import { RotateCcw, Search, ShieldCheck, + ScrollText, Trash2, } from "lucide-react"; import { useQueryState } from "nuqs"; @@ -103,26 +106,7 @@ function downloadCSV(content: string) { function VKBudgetCell({ vk }: { vk: VirtualKey }) { const { displayBudgets } = useVirtualKeyUsage(vk); - - if (!displayBudgets || displayBudgets.length === 0) { - return -; - } - - return ( -
- {displayBudgets.map((b, idx) => ( -
- = b.max_limit && "text-red-400")}> - {formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)} - - - Resets {formatResetDuration(b.reset_duration)} - {vk.calendar_aligned && supportsCalendarAlignment(b.reset_duration) && " (calendar)"} - -
- ))} -
- ); + return ; } function VKAssignedToCell({ vk }: { vk: VirtualKey }) { @@ -233,6 +217,16 @@ function VKActionsMenu({ Edit + + setIsOpen(false)}> + + View logs + + ); -} \ No newline at end of file +} diff --git a/ui/components/budgetDisplay.tsx b/ui/components/budgetDisplay.tsx new file mode 100644 index 00000000000..0ac9233b15e --- /dev/null +++ b/ui/components/budgetDisplay.tsx @@ -0,0 +1,69 @@ +import { Progress } from "@/components/ui/progress"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { resetDurationLabels, supportsCalendarAlignment } from "@/lib/constants/governance"; +import { Budget } from "@/lib/types/governance"; +import { cn } from "@/lib/utils"; +import { formatCurrency } from "@/lib/utils/governance"; + +interface BudgetDisplayProps { + budgets: Budget[] | null | undefined; + /** When true, alignable durations (day/week/month/year) get a "(calendar)" suffix. */ + calendarAligned?: boolean; +} + +const formatResetDuration = (duration?: string | null, calendarAligned?: boolean) => { + if (!duration) return ""; + const label = resetDurationLabels[duration] || duration; + return calendarAligned && supportsCalendarAlignment(duration) ? `${label} (calendar)` : label; +}; + +/** + * Renders a team-style usage bar per budget line: max limit + reset period on top, a + * color-coded progress bar (emerald < 80% < amber < exhausted = red), and a tooltip with + * the exact current/max spend. Mirrors RateLimitDisplay for visual consistency across tables. + */ +export function BudgetDisplay({ budgets, calendarAligned }: BudgetDisplayProps) { + if (!budgets || budgets.length === 0) { + return -; + } + + return ( +
+ {budgets.map((b, idx) => { + const pct = b.max_limit > 0 ? Math.min((b.current_usage / b.max_limit) * 100, 100) : 0; + const isExhausted = b.max_limit > 0 && b.current_usage >= b.max_limit; + const barClass = isExhausted + ? "[&>div]:bg-red-500/70" + : pct > 80 + ? "[&>div]:bg-amber-500/70" + : "[&>div]:bg-emerald-500/70"; + + return ( + + +
+
+ {formatCurrency(b.max_limit)} + + {formatResetDuration(b.reset_duration, calendarAligned)} + +
+ +
+
+ +

+ {formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)} +

+ {b.reset_duration ? ( +

+ Resets {formatResetDuration(b.reset_duration, calendarAligned)} +

+ ) : null} +
+
+ ); + })} +
+ ); +} diff --git a/ui/components/ui/modelMultiselect.tsx b/ui/components/ui/modelMultiselect.tsx index f879b2b26c1..d9755f4cf2e 100644 --- a/ui/components/ui/modelMultiselect.tsx +++ b/ui/components/ui/modelMultiselect.tsx @@ -91,7 +91,7 @@ export function ModelMultiselect(props: ModelMultiselectProps) { const arrayValue = value as string[]; const selectedOptions: ModelOption[] = isSingleSelect ? stringValue - ? [{ label: stringValue, value: stringValue }] + ? [stringValue === "*" ? ALL_MODELS_OPTION : { label: stringValue, value: stringValue }] : [] : arrayValue.map((model) => (model === "*" ? ALL_MODELS_OPTION : { label: model, value: model })); diff --git a/ui/index.html b/ui/index.html index 0da42fe151b..d809e98a053 100644 --- a/ui/index.html +++ b/ui/index.html @@ -10,7 +10,47 @@ -
+
+ + + +
\ No newline at end of file diff --git a/ui/lib/registries/modelLimitScopes.tsx b/ui/lib/registries/modelLimitScopes.tsx index 4d54fadf88b..8f127eba3cd 100644 --- a/ui/lib/registries/modelLimitScopes.tsx +++ b/ui/lib/registries/modelLimitScopes.tsx @@ -73,9 +73,7 @@ function VirtualKeyPicker({ value, onChange, disabled, fallbackOption }: ScopePi const virtualKeys = vksData?.virtual_keys ?? []; const options = [ ...(fallbackOption && !virtualKeys.some((vk) => vk.id === fallbackOption.value) ? [fallbackOption] : []), - ...virtualKeys - .filter((vk) => vk.id !== fallbackOption?.value) - .map((vk) => ({ label: vk.name, value: vk.id })), + ...virtualKeys.map((vk) => ({ label: vk.name, value: vk.id })), ]; return (