Files
bifrost/framework/configstore/migrations.go
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

7005 lines
251 KiB
Go

package configstore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"slices"
"strconv"
"strings"
"unicode"
"github.com/google/uuid"
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/configstore/tables"
"github.com/maximhq/bifrost/framework/encrypt"
"github.com/maximhq/bifrost/framework/migrator"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
// migrationAdvisoryLockKey is used for PostgreSQL advisory locks
// to serialize migrations across cluster nodes
migrationAdvisoryLockKey = 1000001
)
// migrationLock holds a dedicated connection for the advisory lock.
// This ensures the lock is held on the same connection throughout migrations,
// preventing race conditions caused by GORM's connection pooling.
type migrationLock struct {
conn *sql.Conn
}
// acquireMigrationLock gets a dedicated connection and acquires an advisory lock.
// For non-PostgreSQL databases, returns a no-op lock.
func acquireMigrationLock(ctx context.Context, db *gorm.DB) (*migrationLock, error) {
if db.Dialector.Name() != "postgres" {
return &migrationLock{}, nil
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
}
// Get a dedicated connection (not returned to pool until Close())
conn, err := sqlDB.Conn(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get dedicated connection: %w", err)
}
// Acquire advisory lock on this dedicated connection.
// This will BLOCK if another node holds the lock.
_, err = conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrationAdvisoryLockKey)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to acquire migration advisory lock: %w", err)
}
return &migrationLock{conn: conn}, nil
}
// release unlocks and closes the dedicated connection
func (l *migrationLock) release(ctx context.Context) {
if l.conn == nil {
return
}
// Release lock on the SAME connection that acquired it
_, _ = l.conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", migrationAdvisoryLockKey)
l.conn.Close()
}
// RunSingleMigration applies a single gormigrate migration on the given
// *gorm.DB. Mirrors (*RDBConfigStore).RunMigration but takes the *gorm.DB
// directly, so downstream consumers (bifrost-enterprise, plugins) can run
// their migrations inside a MigrateOnFreshConnection callback without having
// to reach the throwaway pool through the ConfigStore abstraction.
func RunSingleMigration(ctx context.Context, db *gorm.DB, migration *migrator.Migration) error {
if db == nil {
return fmt.Errorf("db cannot be nil")
}
if migration == nil {
return fmt.Errorf("migration cannot be nil")
}
m := migrator.New(db.WithContext(ctx), migrator.DefaultOptions, []*migrator.Migration{migration})
return m.Migrate()
}
type legacyBudgetVirtualKey struct {
tables.TableVirtualKey
BudgetID *string `gorm:"column:budget_id;type:varchar(255);index"`
}
func (legacyBudgetVirtualKey) TableName() string { return "governance_virtual_keys" }
type legacyBudgetVirtualKeyProviderConfig struct {
tables.TableVirtualKeyProviderConfig
BudgetID *string `gorm:"column:budget_id;type:varchar(255);index"`
}
func (legacyBudgetVirtualKeyProviderConfig) TableName() string {
return "governance_virtual_key_provider_configs"
}
type legacyBudgetTeam struct {
tables.TableTeam
BudgetID *string `gorm:"column:budget_id;type:varchar(255);index"`
}
func (legacyBudgetTeam) TableName() string { return "governance_teams" }
type sqliteColumnInfo struct {
Name string `gorm:"column:name"`
}
func legacyBudgetColumnModel(tableName string) (any, error) {
switch tableName {
case "governance_virtual_keys":
return &legacyBudgetVirtualKey{}, nil
case "governance_virtual_key_provider_configs":
return &legacyBudgetVirtualKeyProviderConfig{}, nil
case "governance_teams":
return &legacyBudgetTeam{}, nil
default:
return nil, fmt.Errorf("unsupported legacy budget column drop table: %s", tableName)
}
}
func currentBudgetOwnerModel(tableName string) (any, error) {
switch tableName {
case "governance_virtual_keys":
return &tables.TableVirtualKey{}, nil
case "governance_virtual_key_provider_configs":
return &tables.TableVirtualKeyProviderConfig{}, nil
case "governance_teams":
return &tables.TableTeam{}, nil
default:
return nil, fmt.Errorf("unsupported legacy budget column drop table: %s", tableName)
}
}
func quoteSQLiteIdentifier(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
func sqliteTableColumns(tx *gorm.DB, tableName string) ([]string, error) {
var columns []sqliteColumnInfo
query := fmt.Sprintf("PRAGMA table_info(%s)", quoteSQLiteIdentifier(tableName))
if err := tx.Raw(query).Scan(&columns).Error; err != nil {
return nil, err
}
result := make([]string, 0, len(columns))
for _, column := range columns {
result = append(result, column.Name)
}
return result, nil
}
func sqliteTableHasColumn(tx *gorm.DB, tableName, columnName string) (bool, error) {
columns, err := sqliteTableColumns(tx, tableName)
if err != nil {
return false, err
}
if slices.Contains(columns, columnName) {
return true, nil
}
return false, nil
}
// sqliteDropLegacyBudgetColumn removes the legacy budget_id column from a
// SQLite table by dumping data, recreating the table from the current GORM
// model, and copying data back.
//
// Strategy: dump-data → drop-original → create-clean → restore-data.
// We never RENAME the original table because SQLite propagates ALTER TABLE
// RENAME into FK references in OTHER tables, corrupting them.
func sqliteDropLegacyBudgetColumn(tx *gorm.DB, tableName string) error {
model, err := currentBudgetOwnerModel(tableName)
if err != nil {
return err
}
columns, err := sqliteTableColumns(tx, tableName)
if err != nil {
return fmt.Errorf("failed to inspect SQLite columns for %s: %w", tableName, err)
}
preservedColumns := make([]string, 0, len(columns))
for _, column := range columns {
if column != "budget_id" {
preservedColumns = append(preservedColumns, column)
}
}
if len(preservedColumns) == len(columns) {
return nil // budget_id column not present, nothing to do
}
// Build the column list for data transfer.
quotedColumns := make([]string, 0, len(preservedColumns))
for _, column := range preservedColumns {
quotedColumns = append(quotedColumns, quoteSQLiteIdentifier(column))
}
columnList := strings.Join(quotedColumns, ", ")
// Dump existing data into a temporary table (data-only, no constraints).
dumpTable := tableName + "__dump"
if err := tx.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", quoteSQLiteIdentifier(dumpTable))).Error; err != nil {
return fmt.Errorf("failed to drop stale dump table %s: %w", dumpTable, err)
}
dumpSQL := fmt.Sprintf("CREATE TABLE %s AS SELECT %s FROM %s",
quoteSQLiteIdentifier(dumpTable), columnList, quoteSQLiteIdentifier(tableName))
if err := tx.Exec(dumpSQL).Error; err != nil {
return fmt.Errorf("failed to dump %s data: %w", tableName, err)
}
// Drop the original table. Safe because PRAGMA foreign_keys is OFF.
// This also removes all indexes and FK definitions cleanly.
if err := tx.Exec(fmt.Sprintf("DROP TABLE %s", quoteSQLiteIdentifier(tableName))).Error; err != nil {
return fmt.Errorf("failed to drop original SQLite table %s: %w", tableName, err)
}
// Recreate the table from the current GORM model (no budget_id column,
// proper indexes and constraints). The original table name is now free.
if err := tx.Migrator().CreateTable(model); err != nil {
return fmt.Errorf("failed to recreate SQLite table %s: %w", tableName, err)
}
// Restore data from the dump.
restoreSQL := fmt.Sprintf("INSERT INTO %s (%s) SELECT %s FROM %s",
quoteSQLiteIdentifier(tableName), columnList, columnList, quoteSQLiteIdentifier(dumpTable))
if err := tx.Exec(restoreSQL).Error; err != nil {
return fmt.Errorf("failed to restore data into %s: %w", tableName, err)
}
// Clean up the dump table.
if err := tx.Exec(fmt.Sprintf("DROP TABLE %s", quoteSQLiteIdentifier(dumpTable))).Error; err != nil {
return fmt.Errorf("failed to drop dump table %s: %w", dumpTable, err)
}
return nil
}
func dropLegacyBudgetColumn(tx *gorm.DB, tableName string) error {
mg := tx.Migrator()
if !mg.HasColumn(tableName, "budget_id") {
return nil
}
if tx.Dialector.Name() == "sqlite" {
if err := sqliteDropLegacyBudgetColumn(tx, tableName); err != nil {
return err
}
} else {
model, err := legacyBudgetColumnModel(tableName)
if err != nil {
return err
}
if err := mg.DropColumn(model, "budget_id"); err != nil {
return fmt.Errorf("failed to drop legacy %s.budget_id column: %w", tableName, err)
}
}
var stillExists bool
var err error
if tx.Dialector.Name() == "sqlite" {
stillExists, err = sqliteTableHasColumn(tx, tableName, "budget_id")
if err != nil {
return fmt.Errorf("failed to verify legacy %s.budget_id column drop: %w", tableName, err)
}
} else {
stillExists = mg.HasColumn(tableName, "budget_id")
}
if stillExists {
return fmt.Errorf("legacy %s.budget_id column still exists after migration", tableName)
}
return nil
}
// Migrate performs the necessary database migrations.
func triggerMigrations(ctx context.Context, db *gorm.DB) error {
// Acquire advisory lock to serialize migrations across cluster nodes.
// This prevents race conditions when multiple nodes start simultaneously
// and try to create the same tables in parallel.
lock, err := acquireMigrationLock(ctx, db)
if err != nil {
return err
}
defer lock.release(ctx)
if err := migrationInit(ctx, db); err != nil {
return err
}
if err := migrationMany2ManyJoinTable(ctx, db); err != nil {
return err
}
if err := migrationAddCustomProviderConfigJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddVirtualKeyProviderConfigTable(ctx, db); err != nil {
return err
}
if err := migrationAddAllowedOriginsJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddAllowDirectKeysColumn(ctx, db); err != nil {
return err
}
if err := migrationAddEnableLiteLLMFallbacksColumn(ctx, db); err != nil {
return err
}
if err := migrationTeamsTableUpdates(ctx, db); err != nil {
return err
}
if err := migrationAddKeyNameColumn(ctx, db); err != nil {
return err
}
if err := migrationAddFrameworkConfigsTable(ctx, db); err != nil {
return err
}
if err := migrationCleanupMCPClientToolsConfig(ctx, db); err != nil {
return err
}
if err := migrationAddVirtualKeyMCPConfigsTable(ctx, db); err != nil {
return err
}
if err := migrationAddPluginPathColumn(ctx, db); err != nil {
return err
}
if err := migrationAddProviderConfigBudgetRateLimit(ctx, db); err != nil {
return err
}
if err := migrationAddSessionsTable(ctx, db); err != nil {
return err
}
if err := migrationAddHeadersJSONColumnIntoMCPClient(ctx, db); err != nil {
return err
}
if err := migrationAddDisableContentLoggingColumn(ctx, db); err != nil {
return err
}
if err := migrationAddMCPClientIDColumn(ctx, db); err != nil {
return err
}
if err := migrationAddVertexProjectNumberColumn(ctx, db); err != nil {
return err
}
if err := migrationAddVertexDeploymentsJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationMissingProviderColumnInKeyTable(ctx, db); err != nil {
return err
}
if err := migrationAddToolsToAutoExecuteJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddIsCodeModeClientColumn(ctx, db); err != nil {
return err
}
if err := migrationAddLogRetentionDaysColumn(ctx, db); err != nil {
return err
}
if err := migrationAddEnabledColumnToKeyTable(ctx, db); err != nil {
return err
}
if err := migrationAddBatchAndCachePricingColumns(ctx, db); err != nil {
return err
}
if err := migrationAddMCPAgentDepthAndMCPToolExecutionTimeoutColumns(ctx, db); err != nil {
return err
}
if err := migrationAddMCPCodeModeBindingLevelColumn(ctx, db); err != nil {
return err
}
if err := migrationNormalizeMCPClientNames(ctx, db); err != nil {
return err
}
if err := migrationMoveKeysToProviderConfig(ctx, db); err != nil {
return err
}
if err := migrationAddPluginVersionColumn(ctx, db); err != nil {
return err
}
if err := migrationAddSendBackRawRequestColumns(ctx, db); err != nil {
return err
}
if err := migrationAddConfigHashColumn(ctx, db); err != nil {
return err
}
if err := migrationAddVirtualKeyConfigHashColumn(ctx, db); err != nil {
return err
}
if err := migrationAddAdditionalConfigHashColumns(ctx, db); err != nil {
return err
}
if err := migrationAdd200kTokenPricingColumns(ctx, db); err != nil {
return err
}
if err := migrationAddImagePricingColumns(ctx, db); err != nil {
return err
}
if err := migrationAddUseForBatchAPIColumnAndS3BucketsConfig(ctx, db); err != nil {
return err
}
if err := migrationAddHeaderFilterConfigJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddAzureClientIDAndClientSecretAndTenantIDColumns(ctx, db); err != nil {
return err
}
if err := migrationAddDistributedLocksTable(ctx, db); err != nil {
return err
}
if err := migrationAddModelConfigTable(ctx, db); err != nil {
return err
}
if err := migrationAddProviderGovernanceColumns(ctx, db); err != nil {
return err
}
if err := migrationAddAllowedHeadersJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddDisableDBPingsInHealthColumn(ctx, db); err != nil {
return err
}
if err := migrationAddIsPingAvailableColumnToMCPClientTable(ctx, db); err != nil {
return err
}
if err := migrationAddToolPricingJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationRemoveServerPrefixFromMCPTools(ctx, db); err != nil {
return err
}
if err := migrationAddOAuthTables(ctx, db); err != nil {
return err
}
if err := migrationAddToolSyncIntervalColumns(ctx, db); err != nil {
return err
}
if err := migrationAddMCPClientConfigToOAuthConfig(ctx, db); err != nil {
return err
}
if err := migrationAddRoutingRulesTable(ctx, db); err != nil {
return err
}
if err := migrationAddBaseModelPricingColumn(ctx, db); err != nil {
return err
}
if err := migrationAddAzureScopesColumn(ctx, db); err != nil {
return err
}
if err := migrationAddReplicateDeploymentsJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddKeyStatusColumns(ctx, db); err != nil {
return err
}
if err := migrationAddProviderStatusColumns(ctx, db); err != nil {
return err
}
if err := migrationAddRateLimitToTeamsAndCustomers(ctx, db); err != nil {
return err
}
if err := migrationAddAsyncJobResultTTLColumn(ctx, db); err != nil {
return err
}
if err := migrationAddRequiredHeadersJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddLoggingHeadersJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddHideDeletedVirtualKeysInFiltersColumn(ctx, db); err != nil {
return err
}
if err := migrationAddEnforceSCIMAuthColumn(ctx, db); err != nil {
return err
}
if err := migrationAddEnforceAuthOnInferenceColumn(ctx, db); err != nil {
return err
}
if err := migrationReconcilePricingOverridesTable(ctx, db); err != nil {
return err
}
if err := migrationAddEncryptionColumns(ctx, db); err != nil {
return err
}
if err := migrationAddOutputCostPerVideoPerSecond(ctx, db); err != nil {
return err
}
if err := migrationDropEnableGovernanceColumn(ctx, db); err != nil {
return err
}
if err := migrationAddVLLMKeyConfigColumns(ctx, db); err != nil {
return err
}
if err := migrationWidenEncryptedVarcharColumns(ctx, db); err != nil {
return err
}
if err := migrationAddBedrockAssumeRoleColumns(ctx, db); err != nil {
return err
}
if err := migrationAddStoreRawRequestResponseColumn(ctx, db); err != nil {
return err
}
if err := migrationAddPricingRefactorColumns(ctx, db); err != nil {
return err
}
if err := migrationRenameTruncatedPricingColumn(ctx, db); err != nil {
return err
}
if err := migrationAddImageQualityPricingColumns(ctx, db); err != nil {
return err
}
if err := migrationAddRoutingTargetsTable(ctx, db); err != nil {
return err
}
if err := migrationAddPromptRepoTables(ctx, db); err != nil {
return err
}
if err := migrationAddPluginOrderColumns(ctx, db); err != nil {
return err
}
if err := migrationAddAllowAllKeysToProviderConfig(ctx, db); err != nil {
return err
}
if err := migrationBackfillEmptyVirtualKeyConfigs(ctx, db); err != nil {
return err
}
if err := migrationAddMCPDisableAutoToolInjectColumn(ctx, db); err != nil {
return err
}
if err := migrationBackfillAllowedModelsWildcard(ctx, db); err != nil {
return err
}
if err := migrationAddMCPClientAllowedExtraHeadersJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationMakeBasePricingColumnsNullable(ctx, db); err != nil {
return err
}
if err := migrationAddAllowOnAllVirtualKeysColumn(ctx, db); err != nil {
return err
}
if err := migrationAddOpenAIConfigJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddKeyBlacklistedModelsJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationAddChainRuleColumnToRoutingRules(ctx, db); err != nil {
return err
}
if err := migrationDropDeploymentColumnsAndAddAliases(ctx, db); err != nil {
return err
}
if err := migrationAddReplicateKeyConfigColumn(ctx, db); err != nil {
return err
}
if err := migrationAddBudgetCalendarAlignedColumn(ctx, db); err != nil {
return err
}
if err := migrationAddRoutingChainMaxDepthColumn(ctx, db); err != nil {
return err
}
if err := migrationAddPromptVariablesColumns(ctx, db); err != nil {
return err
}
if err := migrationAddModelCapabilityColumns(ctx, db); err != nil {
return err
}
if err := migrationAddOllamaSGLConfigColumns(ctx, db); err != nil {
return err
}
if err := migrationAddMultiBudgetTables(ctx, db); err != nil {
return err
}
if err := migrationAddPerUserOAuthTables(ctx, db); err != nil {
return err
}
if err := migrationAddMCPClientDiscoveredToolsColumns(ctx, db); err != nil {
return err
}
if err := migrationAddWhitelistedRoutesJSONColumn(ctx, db); err != nil {
return err
}
if err := migrationReplaceEnableLiteLLMWithCompatColumns(ctx, db); err != nil {
return err
}
if err := migrationAddModelPricingUniqueIndex(ctx, db); err != nil {
return err
}
if err := migrationDefaultCompatShouldConvertParamsFalse(ctx, db); err != nil {
return err
}
if err := migrationAddPriorityTierPricingColumns(ctx, db); err != nil {
return err
}
if err := migrationAddFlexTierPricingColumns(ctx, db); err != nil {
return err
}
if err := migrationNormalizeOtelTraceType(ctx, db); err != nil {
return err
}
if err := migrateCalendarAlignedToBudgetsAndRateLimitsTable(ctx, db); err != nil {
return err
}
if err := migrationAddTeamBudgetsToBudgetsTable(ctx, db); err != nil {
return err
}
if err := migrationAddOCRPricingColumns(ctx, db); err != nil {
return err
}
return nil
}
func migrationAddStoreRawRequestResponseColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_store_raw_request_response_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableProvider{}, "store_raw_request_response") {
if err := migrator.AddColumn(&tables.TableProvider{}, "store_raw_request_response"); err != nil {
return err
}
}
// Backfill config_hash for existing providers so they don't appear
// dirty after upgrade. StoreRawRequestResponse is now part of the
// hash input; rows written before this migration have stale hashes.
var providers []tables.TableProvider
if err := tx.
Select(
"id",
"name",
"network_config_json",
"concurrency_buffer_json",
"proxy_config_json",
"custom_provider_config_json",
"send_back_raw_request",
"send_back_raw_response",
"store_raw_request_response",
"encryption_status",
).
Find(&providers).Error; err != nil {
return fmt.Errorf("failed to fetch providers for hash backfill: %w", err)
}
for _, provider := range providers {
providerConfig := ProviderConfig{
NetworkConfig: provider.NetworkConfig,
ConcurrencyAndBufferSize: provider.ConcurrencyAndBufferSize,
ProxyConfig: provider.ProxyConfig,
SendBackRawRequest: provider.SendBackRawRequest,
SendBackRawResponse: provider.SendBackRawResponse,
StoreRawRequestResponse: provider.StoreRawRequestResponse,
CustomProviderConfig: provider.CustomProviderConfig,
}
// Here the default value of store_raw_request_response should be based on the default value of SendBackRawRequest and SendBackRawResponse
if provider.SendBackRawRequest || provider.SendBackRawResponse {
providerConfig.StoreRawRequestResponse = true
}
hash, err := providerConfig.GenerateConfigHash(provider.Name)
if err != nil {
return fmt.Errorf("failed to generate hash for provider %s: %w", provider.Name, err)
}
if err := tx.Model(&provider).Updates(map[string]interface{}{
"config_hash": hash,
"store_raw_request_response": providerConfig.StoreRawRequestResponse,
}).Error; err != nil {
return fmt.Errorf("failed to update hash for provider %s: %w", provider.Name, err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableProvider{}, "store_raw_request_response"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running add store raw request response column migration: %s", err.Error())
}
return nil
}
// migrationInit is the first migration
func migrationInit(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "init",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasTable(&tables.TableConfigHash{}) {
if err := migrator.CreateTable(&tables.TableConfigHash{}); err != nil {
return err
}
}
// TableBudget and TableRateLimit must be created before TableProvider
// because TableProvider has FK references to them
if !migrator.HasTable(&tables.TableBudget{}) {
if err := migrator.CreateTable(&tables.TableBudget{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableRateLimit{}) {
if err := migrator.CreateTable(&tables.TableRateLimit{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableProvider{}) {
if err := migrator.CreateTable(&tables.TableProvider{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableKey{}) {
if err := migrator.CreateTable(&tables.TableKey{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableModel{}) {
if err := migrator.CreateTable(&tables.TableModel{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableOauthConfig{}) {
if err := migrator.CreateTable(&tables.TableOauthConfig{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableOauthToken{}) {
if err := migrator.CreateTable(&tables.TableOauthToken{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableMCPClient{}) {
if err := migrator.CreateTable(&tables.TableMCPClient{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableClientConfig{}) {
if err := migrator.CreateTable(&tables.TableClientConfig{}); err != nil {
return err
}
} else if !migrator.HasColumn(&tables.TableClientConfig{}, "max_request_body_size_mb") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "max_request_body_size_mb"); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableEnvKey{}) {
if err := migrator.CreateTable(&tables.TableEnvKey{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableVectorStoreConfig{}) {
if err := migrator.CreateTable(&tables.TableVectorStoreConfig{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableLogStoreConfig{}) {
if err := migrator.CreateTable(&tables.TableLogStoreConfig{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableCustomer{}) {
if err := migrator.CreateTable(&tables.TableCustomer{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableTeam{}) {
if err := migrator.CreateTable(&tables.TableTeam{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableVirtualKey{}) {
if err := migrator.CreateTable(&tables.TableVirtualKey{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableGovernanceConfig{}) {
if err := migrator.CreateTable(&tables.TableGovernanceConfig{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TableModelPricing{}) {
if err := migrator.CreateTable(&tables.TableModelPricing{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TablePricingOverride{}) {
if err := migrator.CreateTable(&tables.TablePricingOverride{}); err != nil {
return err
}
}
if !migrator.HasTable(&tables.TablePlugin{}) {
if err := migrator.CreateTable(&tables.TablePlugin{}); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Drop children first, then parents (adjust if your actual FKs differ)
if err := migrator.DropTable(&tables.TableVirtualKey{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableKey{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableTeam{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableProvider{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableCustomer{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableBudget{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableRateLimit{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableModel{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableMCPClient{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableClientConfig{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableEnvKey{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableVectorStoreConfig{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableLogStoreConfig{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableGovernanceConfig{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableModelPricing{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TablePricingOverride{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TablePlugin{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableConfigHash{}); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// createMany2ManyJoinTable creates a many-to-many join table for the given tables.
func migrationMany2ManyJoinTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "many2manyjoin",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// create the many-to-many join table for virtual keys and keys
if !migrator.HasTable("governance_virtual_key_keys") {
createJoinTableSQL := `
CREATE TABLE IF NOT EXISTS governance_virtual_key_keys (
table_virtual_key_id VARCHAR(255) NOT NULL,
table_key_id INTEGER NOT NULL,
PRIMARY KEY (table_virtual_key_id, table_key_id),
FOREIGN KEY (table_virtual_key_id) REFERENCES governance_virtual_keys(id) ON DELETE CASCADE,
FOREIGN KEY (table_key_id) REFERENCES config_keys(id) ON DELETE CASCADE
)
`
if err := tx.Exec(createJoinTableSQL).Error; err != nil {
return fmt.Errorf("failed to create governance_virtual_key_keys table: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
if err := tx.Exec("DROP TABLE IF EXISTS governance_virtual_key_keys").Error; err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddCustomProviderConfigJSONColumn adds the custom_provider_config_json column to the provider table
func migrationAddCustomProviderConfigJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "addcustomproviderconfigjsoncolumn",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableProvider{}, "custom_provider_config_json") {
if err := migrator.AddColumn(&tables.TableProvider{}, "custom_provider_config_json"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddVirtualKeyProviderConfigTable adds the virtual_key_provider_config table
func migrationAddVirtualKeyProviderConfigTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "addvirtualkeyproviderconfig",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasTable(&tables.TableVirtualKeyProviderConfig{}) {
if err := migrator.CreateTable(&tables.TableVirtualKeyProviderConfig{}); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropTable(&tables.TableVirtualKeyProviderConfig{}); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddAllowedOriginsJSONColumn adds the allowed_origins_json column to the client config table
func migrationAddAllowedOriginsJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_allowed_origins_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "allowed_origins_json") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "allowed_origins_json"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddAllowDirectKeysColumn adds the allow_direct_keys column to the client config table
func migrationAddAllowDirectKeysColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_allow_direct_keys_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "allow_direct_keys") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "allow_direct_keys"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddEnableLiteLLMFallbacksColumn adds the enable_litellm_fallbacks column to the client config table
func migrationAddEnableLiteLLMFallbacksColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_enable_litellm_fallbacks_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
// Use raw SQL since the struct field was removed in a later migration.
// This column is subsequently dropped by migrationReplaceEnableLiteLLMWithCompatColumns.
if !tx.Migrator().HasColumn(&tables.TableClientConfig{}, "enable_litellm_fallbacks") {
if err := tx.Exec("ALTER TABLE config_client ADD COLUMN enable_litellm_fallbacks BOOLEAN DEFAULT FALSE").Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if err := tx.Exec("ALTER TABLE config_client DROP COLUMN IF EXISTS enable_litellm_fallbacks").Error; err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationTeamsTableUpdates adds profile, config, and claims columns to the team table
func migrationTeamsTableUpdates(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_profile_config_claims_columns_to_team_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableTeam{}, "profile") {
if err := migrator.AddColumn(&tables.TableTeam{}, "profile"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TableTeam{}, "config") {
if err := migrator.AddColumn(&tables.TableTeam{}, "config"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TableTeam{}, "claims") {
if err := migrator.AddColumn(&tables.TableTeam{}, "claims"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddFrameworkConfigsTable adds the framework_configs table
func migrationAddFrameworkConfigsTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_framework_configs_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasTable(&tables.TableFrameworkConfig{}) {
if err := migrator.CreateTable(&tables.TableFrameworkConfig{}); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddKeyNameColumn adds the name column to the key table and populates unique names
func migrationAddKeyNameColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_key_name_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableKey{}, "name") {
// Step 1: Add the column as nullable first
if err := tx.Exec("ALTER TABLE config_keys ADD COLUMN name VARCHAR(255)").Error; err != nil {
return fmt.Errorf("failed to add name column: %w", err)
}
// Step 2: Populate unique names for all existing keys
var keys []tables.TableKey
if err := tx.Find(&keys).Error; err != nil {
return fmt.Errorf("failed to fetch keys: %w", err)
}
for _, key := range keys {
// Create unique name: provider_name-key-{first8chars_of_key_id}-{key_index}
keyIDShort := key.KeyID
if len(keyIDShort) > 8 {
keyIDShort = keyIDShort[:8]
}
keyName := keyIDShort + "-" + strconv.Itoa(int(key.ID))
uniqueName := fmt.Sprintf("%s-key-%s", key.Provider, keyName)
// Update the key with the unique name
if err := tx.Model(&key).Update("name", uniqueName).Error; err != nil {
return fmt.Errorf("failed to update key %s with name %s: %w", key.KeyID, uniqueName, err)
}
}
// Step 3: Add unique index (SQLite compatible)
if err := tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_key_name ON config_keys (name)").Error; err != nil {
return fmt.Errorf("failed to create unique index on name: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Drop the unique index first to avoid orphaned index artifacts
if err := tx.Exec("DROP INDEX IF EXISTS idx_key_name").Error; err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableKey{}, "name"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationCleanupMCPClientToolsConfig removes ToolsToSkipJSON column and converts empty ToolsToExecuteJSON to wildcard
func migrationCleanupMCPClientToolsConfig(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "cleanup_mcp_client_tools_config",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Step 1: Remove ToolsToSkipJSON column if it exists (cleanup from old versions)
if migrator.HasColumn(&tables.TableMCPClient{}, "tools_to_skip_json") {
if err := migrator.DropColumn(&tables.TableMCPClient{}, "tools_to_skip_json"); err != nil {
return fmt.Errorf("failed to drop tools_to_skip_json column: %w", err)
}
}
// Alternative column name variations that might exist
if migrator.HasColumn(&tables.TableMCPClient{}, "ToolsToSkipJSON") {
if err := migrator.DropColumn(&tables.TableMCPClient{}, "ToolsToSkipJSON"); err != nil {
return fmt.Errorf("failed to drop ToolsToSkipJSON column: %w", err)
}
}
// Step 2: Update empty ToolsToExecuteJSON arrays to wildcard ["*"]
// Convert "[]" (empty array) to "[\"*\"]" (wildcard array) for backward compatibility
updateSQL := `
UPDATE config_mcp_clients
SET tools_to_execute_json = '["*"]'
WHERE tools_to_execute_json = '[]' OR tools_to_execute_json = '' OR tools_to_execute_json IS NULL
`
if err := tx.Exec(updateSQL).Error; err != nil {
return fmt.Errorf("failed to update empty ToolsToExecuteJSON to wildcard: %w", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// For rollback, we could add the column back, but since we're moving away from this
// functionality, we'll just revert the wildcard changes back to empty arrays
tx = tx.WithContext(ctx)
revertSQL := `
UPDATE config_mcp_clients
SET tools_to_execute_json = '[]'
WHERE tools_to_execute_json = '["*"]'
`
if err := tx.Exec(revertSQL).Error; err != nil {
return fmt.Errorf("failed to revert wildcard ToolsToExecuteJSON to empty arrays: %w", err)
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running MCP client tools cleanup migration: %s", err.Error())
}
return nil
}
// migrationAddVirtualKeyMCPConfigsTable adds the virtual_key_mcp_configs table
func migrationAddVirtualKeyMCPConfigsTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_vk_mcp_configs_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasTable(&tables.TableVirtualKeyMCPConfig{}) {
if err := migrator.CreateTable(&tables.TableVirtualKeyMCPConfig{}); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropTable(&tables.TableVirtualKeyMCPConfig{}); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddProviderConfigBudgetRateLimit adds budget_id and rate_limit_id columns with proper foreign key constraints
func migrationAddProviderConfigBudgetRateLimit(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_provider_config_budget_rate_limit",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Add budget_id and rate_limit_id columns if they don't exist
// Note: budget_id is added via raw SQL because the field was later removed from the struct
// (migrated to governance_budgets.provider_config_id in add_multi_budget_tables)
if migrator.HasTable(&tables.TableVirtualKeyProviderConfig{}) {
if err := tx.Exec("ALTER TABLE governance_virtual_key_provider_configs ADD COLUMN IF NOT EXISTS budget_id VARCHAR(255)").Error; err != nil {
// Ignore error for databases that don't support IF NOT EXISTS (e.g., SQLite)
// The column may already exist from a previous run
}
// Add RateLimitID column if it doesn't exist
if !migrator.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id") {
if err := migrator.AddColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id"); err != nil {
return fmt.Errorf("failed to add rate_limit_id column: %w", err)
}
}
// Create foreign key indexes for better performance
if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_config_budget ON governance_virtual_key_provider_configs (budget_id)").Error; err != nil {
// Ignore - index may already exist or column may not exist yet
}
if !migrator.HasIndex(&tables.TableVirtualKeyProviderConfig{}, "idx_provider_config_rate_limit") {
if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_config_rate_limit ON governance_virtual_key_provider_configs (rate_limit_id)").Error; err != nil {
return fmt.Errorf("failed to create rate_limit_id index: %w", err)
}
}
// Create FK constraint for RateLimit (Budget FK is no longer needed - budgets use direct FK on budget table)
if !migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit") {
if err := migrator.CreateConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit"); err != nil {
return fmt.Errorf("failed to create RateLimit FK constraint: %w", err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Drop indexes
_ = tx.Exec("DROP INDEX IF EXISTS idx_provider_config_budget")
_ = tx.Exec("DROP INDEX IF EXISTS idx_provider_config_rate_limit")
// Drop FK constraints
if migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit") {
if err := migrator.DropConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit"); err != nil {
return fmt.Errorf("failed to drop RateLimit FK constraint: %w", err)
}
}
// Drop columns via raw SQL (budget_id no longer on struct)
_ = tx.Exec("ALTER TABLE governance_virtual_key_provider_configs DROP COLUMN IF EXISTS budget_id")
if migrator.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id") {
if err := migrator.DropColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id"); err != nil {
return fmt.Errorf("failed to drop rate_limit_id column: %w", err)
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running provider config budget/rate limit migration: %s", err.Error())
}
return nil
}
// migrationAddPluginPathColumn adds the path column to the plugin table
func migrationAddPluginPathColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "update_plugins_table_for_custom_plugins",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TablePlugin{}, "path") {
if err := migrator.AddColumn(&tables.TablePlugin{}, "path"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TablePlugin{}, "is_custom") {
if err := migrator.AddColumn(&tables.TablePlugin{}, "is_custom"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TablePlugin{}, "path"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TablePlugin{}, "is_custom"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running plugin path migration: %s", err.Error())
}
return nil
}
// migrationAddSessionsTable adds the sessions table
func migrationAddSessionsTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_sessions_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasTable(&tables.SessionsTable{}) {
if err := migrator.CreateTable(&tables.SessionsTable{}); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropTable(&tables.SessionsTable{}); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddHeadersJSONColumnIntoMCPClient adds the headers_json column to the mcp_client table
func migrationAddHeadersJSONColumnIntoMCPClient(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_headers_json_column_into_mcp_client",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableMCPClient{}, "headers_json") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "headers_json"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableMCPClient{}, "headers_json"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddDisableContentLoggingColumn adds the disable_content_logging column to the client config table
func migrationAddDisableContentLoggingColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_disable_content_logging_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "disable_content_logging") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "disable_content_logging"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableClientConfig{}, "disable_content_logging"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddMCPClientIDColumn adds the client_id column to the mcp_clients table and populates unique client IDs
func migrationAddMCPClientIDColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_mcp_client_id_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableMCPClient{}, "client_id") {
// Add the column as nullable first
if err := tx.Exec("ALTER TABLE config_mcp_clients ADD COLUMN client_id VARCHAR(255)").Error; err != nil {
return fmt.Errorf("failed to add client_id column: %w", err)
}
// Populate unique client_ids (UUIDs) for all existing MCP clients
var mcpClients []tables.TableMCPClient
if err := tx.Find(&mcpClients).Error; err != nil {
return fmt.Errorf("failed to fetch MCP clients: %w", err)
}
for _, client := range mcpClients {
// Generate a UUID for the client_id
clientID := uuid.New().String()
// Update the client with the generated client_id
if err := tx.Model(&client).Update("client_id", clientID).Error; err != nil {
return fmt.Errorf("failed to update MCP client %d with client_id %s: %w", client.ID, clientID, err)
}
}
// Create unique index on client_id
if err := tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_client_id ON config_mcp_clients (client_id)").Error; err != nil {
return fmt.Errorf("failed to create unique index on client_id: %w", err)
}
// Enforce NOT NULL in Postgres to guarantee ID presence on new rows
if tx.Dialector.Name() == "postgres" {
if err := tx.Exec("ALTER TABLE config_mcp_clients ALTER COLUMN client_id SET NOT NULL").Error; err != nil {
return fmt.Errorf("failed to set client_id NOT NULL: %w", err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Drop the unique index first to avoid orphaned index artifacts
if err := tx.Exec("DROP INDEX IF EXISTS idx_mcp_client_id").Error; err != nil {
return fmt.Errorf("failed to drop client_id index: %w", err)
}
if err := migrator.DropColumn(&tables.TableMCPClient{}, "client_id"); err != nil {
return fmt.Errorf("failed to drop client_id column: %w", err)
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running MCP client_id migration: %s", err.Error())
}
return nil
}
// migrationAddVertexProjectNumberColumn adds the vertex_project_number column to the key table
func migrationAddVertexProjectNumberColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_vertex_project_number_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableKey{}, "vertex_project_number") {
if err := migrator.AddColumn(&tables.TableKey{}, "vertex_project_number"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableKey{}, "vertex_project_number"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running vertex project number migration: %s", err.Error())
}
return nil
}
// migrationAddVertexDeploymentsJSONColumn adds the vertex_deployments_json column to the key table.
// This column is later dropped by migrationDropDeploymentColumnsAndAddAliases after data is migrated.
func migrationAddVertexDeploymentsJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_vertex_deployments_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if !tx.Migrator().HasColumn(&tables.TableKey{}, "vertex_deployments_json") {
if err := tx.Exec("ALTER TABLE config_keys ADD COLUMN vertex_deployments_json TEXT").Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if tx.Migrator().HasColumn(&tables.TableKey{}, "vertex_deployments_json") {
if err := tx.Exec("ALTER TABLE config_keys DROP COLUMN vertex_deployments_json").Error; err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running vertex deployments JSON migration: %s", err.Error())
}
return nil
}
func migrationMissingProviderColumnInKeyTable(ctx context.Context, db *gorm.DB) error {
options := &migrator.Options{
TableName: migrator.DefaultOptions.TableName,
IDColumnName: migrator.DefaultOptions.IDColumnName,
IDColumnSize: migrator.DefaultOptions.IDColumnSize,
UseTransaction: true,
ValidateUnknownMigrations: migrator.DefaultOptions.ValidateUnknownMigrations,
}
m := migrator.New(db, options, []*migrator.Migration{{
ID: "add_and_fill_provider_column_in_key_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Step 1: Add the provider column if it doesn't exist
if migrator.HasColumn(&tables.TableKey{}, "provider") {
return nil
}
if err := migrator.AddColumn(&tables.TableKey{}, "provider"); err != nil {
return fmt.Errorf("failed to add provider column: %w", err)
}
// Step 2: Find all keys where provider is empty/null but provider_id is set
var keys []tables.TableKey
if err := tx.Where("provider IS NULL OR provider = ''").Find(&keys).Error; err != nil {
return fmt.Errorf("failed to fetch keys with missing provider: %w", err)
}
// Step 3: Update each key with the provider name from the provider table
for _, key := range keys {
var provider tables.TableProvider
if err := tx.First(&provider, key.ProviderID).Error; err != nil {
// Skip keys with invalid provider_id
if err == gorm.ErrRecordNotFound {
continue
}
return fmt.Errorf("failed to fetch provider %d for key %s: %w", key.ProviderID, key.KeyID, err)
}
// Update the key with the provider name
if err := tx.Model(&key).Update("provider", provider.Name).Error; err != nil {
return fmt.Errorf("failed to update key %s with provider %s: %w", key.KeyID, provider.Name, err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableKey{}, "provider"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running add and fill provider column migration: %s", err.Error())
}
return nil
}
// migrationAddToolsToAutoExecuteJSONColumn adds the tools_to_auto_execute_json column to the mcp_client table
func migrationAddToolsToAutoExecuteJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_tools_to_auto_execute_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableMCPClient{}, "tools_to_auto_execute_json") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "tools_to_auto_execute_json"); err != nil {
return err
}
// Initialize existing rows with empty array
if err := tx.Exec("UPDATE config_mcp_clients SET tools_to_auto_execute_json = '[]' WHERE tools_to_auto_execute_json IS NULL OR tools_to_auto_execute_json = ''").Error; err != nil {
return fmt.Errorf("failed to initialize tools_to_auto_execute_json: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableMCPClient{}, "tools_to_auto_execute_json"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddIsCodeModeClientColumn adds the is_code_mode_client column to the config_mcp_clients table
func migrationAddIsCodeModeClientColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_is_code_mode_client_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableMCPClient{}, "is_code_mode_client") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "is_code_mode_client"); err != nil {
return err
}
// Initialize existing rows with false (default value)
if err := tx.Exec("UPDATE config_mcp_clients SET is_code_mode_client = false WHERE is_code_mode_client IS NULL").Error; err != nil {
return fmt.Errorf("failed to initialize is_code_mode_client: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableMCPClient{}, "is_code_mode_client"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddLogRetentionDaysColumn adds the log_retention_days column to the client config table
func migrationAddLogRetentionDaysColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_log_retention_days_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "log_retention_days") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "log_retention_days"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableClientConfig{}, "log_retention_days"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddEnabledColumnToKeyTable adds the enabled column to the config_keys table
func migrationAddEnabledColumnToKeyTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_enabled_column_to_key_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
// Check if column already exists
if !mg.HasColumn(&tables.TableKey{}, "enabled") {
// Add the column
if err := mg.AddColumn(&tables.TableKey{}, "enabled"); err != nil {
return fmt.Errorf("failed to add enabled column: %w", err)
}
}
// Set default = true for existing rows
if err := tx.Exec("UPDATE config_keys SET enabled = TRUE WHERE enabled IS NULL").Error; err != nil {
return fmt.Errorf("failed to backfill enabled column: %w", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableKey{}, "enabled") {
if err := mg.DropColumn(&tables.TableKey{}, "enabled"); err != nil {
return fmt.Errorf("failed to drop enabled column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running enabled column migration: %s", err.Error())
}
return nil
}
// migrationAddBatchAndCachePricingColumns adds the cache_read_input_token_cost, cache_creation_input_token_cost, input_cost_per_token_batches, and output_cost_per_token_batches columns to the model_pricing table
func migrationAddBatchAndCachePricingColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "update_model_pricing_table_to_add_cache_and_batch_pricing",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableModelPricing{}, "cache_read_input_token_cost") {
if err := migrator.AddColumn(&tables.TableModelPricing{}, "cache_read_input_token_cost"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TableModelPricing{}, "cache_creation_input_token_cost") {
if err := migrator.AddColumn(&tables.TableModelPricing{}, "cache_creation_input_token_cost"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TableModelPricing{}, "input_cost_per_token_batches") {
if err := migrator.AddColumn(&tables.TableModelPricing{}, "input_cost_per_token_batches"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TableModelPricing{}, "output_cost_per_token_batches") {
if err := migrator.AddColumn(&tables.TableModelPricing{}, "output_cost_per_token_batches"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableModelPricing{}, "cache_read_input_token_cost"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableModelPricing{}, "cache_creation_input_token_cost"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableModelPricing{}, "input_cost_per_token_batches"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableModelPricing{}, "output_cost_per_token_batches"); err != nil {
return err
}
return nil
},
}})
return m.Migrate()
}
func migrationAddMCPAgentDepthAndMCPToolExecutionTimeoutColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_mcp_agent_depth_and_mcp_tool_execution_timeout_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "mcp_agent_depth") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "mcp_agent_depth"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TableClientConfig{}, "mcp_tool_execution_timeout") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "mcp_tool_execution_timeout"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableClientConfig{}, "mcp_agent_depth"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableClientConfig{}, "mcp_tool_execution_timeout"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddMCPCodeModeBindingLevelColumn adds the mcp_code_mode_binding_level column to the client config table.
// This column stores the code mode binding level preference (server or tool).
func migrationAddMCPCodeModeBindingLevelColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_mcp_code_mode_binding_level_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migratorInstance := tx.Migrator()
if !migratorInstance.HasColumn(&tables.TableClientConfig{}, "mcp_code_mode_binding_level") {
if err := migratorInstance.AddColumn(&tables.TableClientConfig{}, "mcp_code_mode_binding_level"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migratorInstance := tx.Migrator()
if err := migratorInstance.DropColumn(&tables.TableClientConfig{}, "mcp_code_mode_binding_level"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// normalizeMCPClientName normalizes an MCP client name by:
// 1. Replacing hyphens and spaces with underscores
// 2. Removing leading digits
// 3. Using a default name if the result is empty
func normalizeMCPClientName(name string) string {
// Replace hyphens and spaces with underscores
normalized := strings.ReplaceAll(name, "-", "_")
normalized = strings.ReplaceAll(normalized, " ", "_")
// Remove leading digits
normalized = strings.TrimLeftFunc(normalized, func(r rune) bool {
return unicode.IsDigit(r)
})
// If name becomes empty after normalization, use a default name
if normalized == "" {
normalized = "mcp_client"
}
return normalized
}
// migrationNormalizeMCPClientNames normalizes MCP client names by:
// 1. Replacing hyphens and spaces with underscores
// 2. Removing leading digits
// 3. Adding number suffix if name already exists
func migrationNormalizeMCPClientNames(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "normalize_mcp_client_names",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
// Fetch all MCP clients
var mcpClients []tables.TableMCPClient
if err := tx.Find(&mcpClients).Error; err != nil {
return fmt.Errorf("failed to fetch MCP clients: %w", err)
}
// Track assigned names in memory to avoid transaction visibility issues
// and ensure we see all updates made during this migration
assignedNames := make(map[string]bool)
// Helper function to find a unique name
findUniqueName := func(baseName string, originalName string, excludeID uint, tx *gorm.DB, assignedNames map[string]bool) (string, error) {
// First check if base name is already assigned in this migration
if !assignedNames[baseName] {
// Also check database for existing names (excluding current client)
var existing tables.TableMCPClient
err := tx.Where("name = ? AND id != ?", baseName, excludeID).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// Name is available
assignedNames[baseName] = true
// Log normalization even when no collision
if originalName != baseName {
log.Printf("MCP Client Name Normalized: '%s' -> '%s'", originalName, baseName)
}
return baseName, nil
} else if err != nil {
return "", fmt.Errorf("failed to check name availability: %w", err)
}
}
// Name exists (either assigned in this migration or in database), try with number suffix starting from 2
// (base name is conceptually "1", so collisions start from "2")
suffix := 2
const maxSuffix = 1000 // Safety limit to prevent infinite loops
for {
if suffix > maxSuffix {
return "", fmt.Errorf("could not find unique name after %d attempts for base name: %s", maxSuffix, baseName)
}
candidateName := baseName + strconv.Itoa(suffix)
// Check both in-memory map and database
if !assignedNames[candidateName] {
var existing tables.TableMCPClient
err := tx.Where("name = ? AND id != ?", candidateName, excludeID).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// Found available name - log the transformation
assignedNames[candidateName] = true
log.Printf("MCP Client Name Normalized: '%s' -> '%s'", originalName, candidateName)
return candidateName, nil
} else if err != nil {
return "", fmt.Errorf("failed to check name availability: %w", err)
}
}
suffix++
}
}
// Process each client
for _, client := range mcpClients {
originalName := client.Name
needsUpdate := false
// Check if name needs normalization
if strings.Contains(originalName, "-") || strings.Contains(originalName, " ") {
needsUpdate = true
} else if len(originalName) > 0 && unicode.IsDigit(rune(originalName[0])) {
needsUpdate = true
}
if needsUpdate {
// Normalize the name
normalizedName := normalizeMCPClientName(originalName)
// Find a unique name (pass assignedNames map to track names in this migration)
uniqueName, err := findUniqueName(normalizedName, originalName, client.ID, tx, assignedNames)
if err != nil {
return fmt.Errorf("failed to find unique name for client %d (original: %s): %w", client.ID, originalName, err)
}
// Update the client name
if err := tx.Model(&client).Update("name", uniqueName).Error; err != nil {
return fmt.Errorf("failed to update MCP client %d name from %s to %s: %w", client.ID, originalName, uniqueName, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Rollback is not possible as we don't store the original names
// This migration is one-way
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running MCP client name normalization migration: %s", err.Error())
}
return nil
}
// migrationMoveKeysToProviderConfig migrates keys from virtual key level to provider config level
func migrationMoveKeysToProviderConfig(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "move_keys_to_provider_config",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
gormMigrator := tx.Migrator()
// Step 1: Create the new join table for provider config -> keys relationship
// Setup the join table so GORM knows about the custom structure
if err := tx.SetupJoinTable(&tables.TableVirtualKeyProviderConfig{}, "Keys", &tables.TableVirtualKeyProviderConfigKey{}); err != nil {
return fmt.Errorf("failed to setup join table for provider config keys: %w", err)
}
// Create the join table if it doesn't exist
if !gormMigrator.HasTable(&tables.TableVirtualKeyProviderConfigKey{}) {
if err := gormMigrator.CreateTable(&tables.TableVirtualKeyProviderConfigKey{}); err != nil {
return fmt.Errorf("failed to create join table for provider config keys: %w", err)
}
}
// Step 2: Migrate existing key associations from virtual key to provider config level
// Check if old join table exists
hasOldTable := gormMigrator.HasTable("governance_virtual_key_keys")
if hasOldTable {
// Get all existing associations from old table using GORM's Table method
type OldAssociation struct {
VirtualKeyID string `gorm:"column:table_virtual_key_id"`
KeyID uint `gorm:"column:table_key_id"`
}
var oldAssociations []OldAssociation
if err := tx.Table("governance_virtual_key_keys").Find(&oldAssociations).Error; err == nil {
// Process each association
for _, assoc := range oldAssociations {
// Get only the key ID and provider - using a minimal struct to avoid
// querying columns that may not exist yet (added by later migrations)
type KeyMinimal struct {
ID uint
Provider string
}
var keyData KeyMinimal
if err := tx.Table("config_keys").Select("id, provider").Where("id = ?", assoc.KeyID).First(&keyData).Error; err != nil {
// Key might have been deleted, skip
continue
}
// Find existing provider config for this virtual key and provider
var providerConfig tables.TableVirtualKeyProviderConfig
result := tx.Where("virtual_key_id = ? AND provider = ?", assoc.VirtualKeyID, keyData.Provider).First(&providerConfig)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
// Create a new provider config for this provider
providerConfig = tables.TableVirtualKeyProviderConfig{
VirtualKeyID: assoc.VirtualKeyID,
Provider: keyData.Provider,
Weight: bifrost.Ptr(1.0),
AllowedModels: []string{},
}
if err := tx.Create(&providerConfig).Error; err != nil {
return fmt.Errorf("failed to create provider config for migration: %w", err)
}
} else {
return fmt.Errorf("failed to query provider config: %w", result.Error)
}
}
// Insert directly into the join table using clause.OnConflict for
// database-agnostic duplicate handling (works for SQLite and PostgreSQL)
joinEntry := tables.TableVirtualKeyProviderConfigKey{
TableVirtualKeyProviderConfigID: providerConfig.ID,
TableKeyID: keyData.ID,
}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&joinEntry).Error; err != nil {
return fmt.Errorf("failed to associate key %d with provider config %d: %w", keyData.ID, providerConfig.ID, err)
}
}
}
// Step 3: Drop the old join table
if err := gormMigrator.DropTable("governance_virtual_key_keys"); err != nil {
return fmt.Errorf("failed to drop old governance_virtual_key_keys table: %w", err)
}
}
// Note: Empty keys in provider config means all keys are allowed at runtime
// We don't pre-populate keys here - this is handled at runtime
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
gormMigrator := tx.Migrator()
// Recreate the old join table structure
type OldJoinTable struct {
VirtualKeyID string `gorm:"column:table_virtual_key_id;primaryKey"`
KeyID uint `gorm:"column:table_key_id;primaryKey"`
}
if err := gormMigrator.CreateTable(&OldJoinTable{}); err != nil {
// Table might already exist, ignore error
_ = err
}
// Rename to correct table name if needed
if gormMigrator.HasTable(&OldJoinTable{}) && !gormMigrator.HasTable("governance_virtual_key_keys") {
if err := gormMigrator.RenameTable(&OldJoinTable{}, "governance_virtual_key_keys"); err != nil {
return fmt.Errorf("failed to rename old join table: %w", err)
}
}
// Note: We cannot fully rollback the data migration as it would require
// reconstructing which keys belonged to which virtual keys
// Drop the new join table
if err := gormMigrator.DropTable("governance_virtual_key_provider_config_keys"); err != nil {
return fmt.Errorf("failed to drop governance_virtual_key_provider_config_keys table: %w", err)
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running move keys to provider config migration: %s", err.Error())
}
return nil
}
// migrationAddPluginVersionColumn adds the version column to the plugin table
func migrationAddPluginVersionColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_plugin_version_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TablePlugin{}, "version") {
if err := migrator.AddColumn(&tables.TablePlugin{}, "version"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TablePlugin{}, "version"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running add plugin version column migration: %s", err.Error())
}
return nil
}
func migrationAddSendBackRawRequestColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_send_back_raw_request_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableProvider{}, "send_back_raw_request") {
if err := migrator.AddColumn(&tables.TableProvider{}, "send_back_raw_request"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableProvider{}, "send_back_raw_request"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running add send back raw request columns migration: %s", err.Error())
}
return nil
}
// migrationAddConfigHashColumn adds the config_hash column to the provider and key tables
func migrationAddConfigHashColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_config_hash_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Add config_hash to providers table
if !migrator.HasColumn(&tables.TableProvider{}, "config_hash") {
if err := migrator.AddColumn(&tables.TableProvider{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing providers
var providers []tables.TableProvider
if err := tx.Find(&providers).Error; err != nil {
return fmt.Errorf("failed to fetch providers for hash migration: %w", err)
}
for _, provider := range providers {
if provider.ConfigHash == "" {
// Convert to ProviderConfig and generate hash
providerConfig := ProviderConfig{
NetworkConfig: provider.NetworkConfig,
ConcurrencyAndBufferSize: provider.ConcurrencyAndBufferSize,
ProxyConfig: provider.ProxyConfig,
SendBackRawRequest: provider.SendBackRawRequest,
SendBackRawResponse: provider.SendBackRawResponse,
CustomProviderConfig: provider.CustomProviderConfig,
}
hash, err := providerConfig.GenerateConfigHash(provider.Name)
if err != nil {
return fmt.Errorf("failed to generate hash for provider %s: %w", provider.Name, err)
}
if err := tx.Model(&provider).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for provider %s: %w", provider.Name, err)
}
}
}
}
// Add config_hash to keys table
if !migrator.HasColumn(&tables.TableKey{}, "config_hash") {
if err := migrator.AddColumn(&tables.TableKey{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing keys
var keys []tables.TableKey
if err := tx.Find(&keys).Error; err != nil {
return fmt.Errorf("failed to fetch keys for hash migration: %w", err)
}
for _, key := range keys {
if key.ConfigHash == "" {
// Convert to schemas.Key and generate hash
schemaKey := schemas.Key{
Name: key.Name,
Value: key.Value,
Models: key.Models,
Weight: getWeight(key.Weight),
AzureKeyConfig: key.AzureKeyConfig,
VertexKeyConfig: key.VertexKeyConfig,
BedrockKeyConfig: key.BedrockKeyConfig,
Aliases: key.Aliases,
}
hash, err := GenerateKeyHash(schemaKey)
if err != nil {
return fmt.Errorf("failed to generate hash for key %s: %w", key.Name, err)
}
if err := tx.Model(&key).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for key %s: %w", key.Name, err)
}
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableProvider{}, "config_hash"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableKey{}, "config_hash"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running add config hash column migration: %s", err.Error())
}
return nil
}
// migrationAddVirtualKeyConfigHashColumn adds the config_hash column to the virtual keys table
func migrationAddVirtualKeyConfigHashColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_virtual_key_config_hash_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Add config_hash to virtual keys table
if !migrator.HasColumn(&tables.TableVirtualKey{}, "config_hash") {
if err := migrator.AddColumn(&tables.TableVirtualKey{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing virtual keys
var virtualKeys []tables.TableVirtualKey
if err := tx.Preload("ProviderConfigs").Preload("ProviderConfigs.Keys").Preload("MCPConfigs").Find(&virtualKeys).Error; err != nil {
return fmt.Errorf("failed to fetch virtual keys for hash migration: %w", err)
}
for _, vk := range virtualKeys {
if vk.ConfigHash == "" {
hash, err := GenerateVirtualKeyHash(vk)
if err != nil {
return fmt.Errorf("failed to generate hash for virtual key %s: %w", vk.ID, err)
}
if err := tx.Model(&vk).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for virtual key %s: %w", vk.ID, err)
}
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableVirtualKey{}, "config_hash"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running add virtual key config hash column migration: %s", err.Error())
}
return nil
}
// migrationAddAdditionalConfigHashColumns adds config_hash columns to client config, budget, rate limit,
// customer, team, MCP client, and plugin tables for reconciliation support
func migrationAddAdditionalConfigHashColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_additional_config_hash_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Add config_hash to client config table
if !migrator.HasColumn(&tables.TableClientConfig{}, "config_hash") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing client configs
var clientConfigs []tables.TableClientConfig
if err := tx.Find(&clientConfigs).Error; err != nil {
return fmt.Errorf("failed to fetch client configs for hash migration: %w", err)
}
for _, cc := range clientConfigs {
if cc.ConfigHash == "" {
clientConfig := ClientConfig{
DropExcessRequests: cc.DropExcessRequests,
InitialPoolSize: cc.InitialPoolSize,
PrometheusLabels: cc.PrometheusLabels,
EnableLogging: cc.EnableLogging,
DisableContentLogging: cc.DisableContentLogging,
LogRetentionDays: cc.LogRetentionDays,
EnforceGovernanceHeader: cc.EnforceGovernanceHeader,
AllowDirectKeys: cc.AllowDirectKeys,
AllowedOrigins: cc.AllowedOrigins,
MaxRequestBodySizeMB: cc.MaxRequestBodySizeMB,
}
hash, err := clientConfig.GenerateClientConfigHash()
if err != nil {
return fmt.Errorf("failed to generate hash for client config %d: %w", cc.ID, err)
}
if err := tx.Model(&cc).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for client config %d: %w", cc.ID, err)
}
}
}
}
// Add config_hash to budgets table
if !migrator.HasColumn(&tables.TableBudget{}, "config_hash") {
if err := migrator.AddColumn(&tables.TableBudget{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing budgets
var budgets []tables.TableBudget
if err := tx.Find(&budgets).Error; err != nil {
return fmt.Errorf("failed to fetch budgets for hash migration: %w", err)
}
for _, budget := range budgets {
if budget.ConfigHash == "" {
hash, err := GenerateBudgetHash(budget)
if err != nil {
return fmt.Errorf("failed to generate hash for budget %s: %w", budget.ID, err)
}
if err := tx.Model(&budget).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for budget %s: %w", budget.ID, err)
}
}
}
}
// Add config_hash to rate limits table
if !migrator.HasColumn(&tables.TableRateLimit{}, "config_hash") {
if err := migrator.AddColumn(&tables.TableRateLimit{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing rate limits
var rateLimits []tables.TableRateLimit
if err := tx.Find(&rateLimits).Error; err != nil {
return fmt.Errorf("failed to fetch rate limits for hash migration: %w", err)
}
for _, rl := range rateLimits {
if rl.ConfigHash == "" {
hash, err := GenerateRateLimitHash(rl)
if err != nil {
return fmt.Errorf("failed to generate hash for rate limit %s: %w", rl.ID, err)
}
if err := tx.Model(&rl).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for rate limit %s: %w", rl.ID, err)
}
}
}
}
// Add config_hash to customers table
if !migrator.HasColumn(&tables.TableCustomer{}, "config_hash") {
if err := migrator.AddColumn(&tables.TableCustomer{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing customers
var customers []tables.TableCustomer
if err := tx.Find(&customers).Error; err != nil {
return fmt.Errorf("failed to fetch customers for hash migration: %w", err)
}
for _, customer := range customers {
if customer.ConfigHash == "" {
hash, err := GenerateCustomerHash(customer)
if err != nil {
return fmt.Errorf("failed to generate hash for customer %s: %w", customer.ID, err)
}
if err := tx.Model(&customer).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for customer %s: %w", customer.ID, err)
}
}
}
}
// Add config_hash to teams table
if !migrator.HasColumn(&tables.TableTeam{}, "config_hash") {
if err := migrator.AddColumn(&tables.TableTeam{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing teams
var teams []tables.TableTeam
if err := tx.Find(&teams).Error; err != nil {
return fmt.Errorf("failed to fetch teams for hash migration: %w", err)
}
for _, team := range teams {
if team.ConfigHash == "" {
hash, err := GenerateTeamHash(team)
if err != nil {
return fmt.Errorf("failed to generate hash for team %s: %w", team.ID, err)
}
if err := tx.Model(&team).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for team %s: %w", team.ID, err)
}
}
}
}
// Add config_hash to MCP clients table
if !migrator.HasColumn(&tables.TableMCPClient{}, "config_hash") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing MCP clients
var mcpClients []tables.TableMCPClient
if err := tx.Find(&mcpClients).Error; err != nil {
return fmt.Errorf("failed to fetch MCP clients for hash migration: %w", err)
}
for _, mcp := range mcpClients {
if mcp.ConfigHash == "" {
hash, err := GenerateMCPClientHash(mcp)
if err != nil {
return fmt.Errorf("failed to generate hash for MCP client %s: %w", mcp.Name, err)
}
if err := tx.Model(&mcp).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for MCP client %s: %w", mcp.Name, err)
}
}
}
}
// Add config_hash to plugins table
if !migrator.HasColumn(&tables.TablePlugin{}, "config_hash") {
if err := migrator.AddColumn(&tables.TablePlugin{}, "config_hash"); err != nil {
return err
}
// Pre-populate hashes for existing plugins
var plugins []tables.TablePlugin
if err := tx.Find(&plugins).Error; err != nil {
return fmt.Errorf("failed to fetch plugins for hash migration: %w", err)
}
for _, plugin := range plugins {
if plugin.ConfigHash == "" {
hash, err := GeneratePluginHash(plugin)
if err != nil {
return fmt.Errorf("failed to generate hash for plugin %s: %w", plugin.Name, err)
}
if err := tx.Model(&plugin).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for plugin %s: %w", plugin.Name, err)
}
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableClientConfig{}, "config_hash"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableBudget{}, "config_hash"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableRateLimit{}, "config_hash"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableCustomer{}, "config_hash"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableTeam{}, "config_hash"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableMCPClient{}, "config_hash"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TablePlugin{}, "config_hash"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running add additional config hash columns migration: %s", err.Error())
}
return nil
}
// migrationAdd200kTokenPricingColumns adds pricing columns for 200k token tier models
func migrationAdd200kTokenPricingColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_200k_token_pricing_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
columns := []string{
"input_cost_per_token_above_200k_tokens",
"output_cost_per_token_above_200k_tokens",
"cache_creation_input_token_cost_above_200k_tokens",
"cache_read_input_token_cost_above_200k_tokens",
}
for _, field := range columns {
if !migrator.HasColumn(&tables.TableModelPricing{}, field) {
if err := migrator.AddColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to add column %s: %w", field, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
columns := []string{
"input_cost_per_token_above_200k_tokens",
"output_cost_per_token_above_200k_tokens",
"cache_creation_input_token_cost_above_200k_tokens",
"cache_read_input_token_cost_above_200k_tokens",
}
for _, field := range columns {
if migrator.HasColumn(&tables.TableModelPricing{}, field) {
if err := migrator.DropColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to drop column %s: %w", field, err)
}
}
}
return nil
},
}})
return m.Migrate()
}
// migrationAddImagePricingColumns adds the image generation pricing columns to the model_pricing table
func migrationAddImagePricingColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_image_pricing_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
columns := []string{
"input_cost_per_image_token",
"output_cost_per_image_token",
"input_cost_per_image",
"output_cost_per_image",
"cache_read_input_image_token_cost",
}
for _, field := range columns {
if !migrator.HasColumn(&tables.TableModelPricing{}, field) {
if err := migrator.AddColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to add column %s: %w", field, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
columns := []string{
"input_cost_per_image_token",
"output_cost_per_image_token",
"input_cost_per_image",
"output_cost_per_image",
"cache_read_input_image_token_cost",
}
for _, field := range columns {
if migrator.HasColumn(&tables.TableModelPricing{}, field) {
if err := migrator.DropColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to drop column %s: %w", field, err)
}
}
}
return nil
},
}})
return m.Migrate()
}
// migrationAddUseForBatchAPIColumnAndS3BucketsConfig adds the use_for_batch_api and bedrock_batch_s3_config_json columns to the config_keys table
// Existing keys are backfilled with use_for_batch_api = TRUE to preserve current behavior
func migrationAddUseForBatchAPIColumnAndS3BucketsConfig(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_use_for_batch_api_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
// Add use_for_batch_api column
if !mg.HasColumn(&tables.TableKey{}, "use_for_batch_api") {
if err := mg.AddColumn(&tables.TableKey{}, "use_for_batch_api"); err != nil {
return fmt.Errorf("failed to add use_for_batch_api column: %w", err)
}
}
// Add bedrock_batch_s3_config_json column
if !mg.HasColumn(&tables.TableKey{}, "bedrock_batch_s3_config_json") {
if err := mg.AddColumn(&tables.TableKey{}, "bedrock_batch_s3_config_json"); err != nil {
return fmt.Errorf("failed to add bedrock_batch_s3_config_json column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableKey{}, "use_for_batch_api") {
if err := mg.DropColumn(&tables.TableKey{}, "use_for_batch_api"); err != nil {
return fmt.Errorf("failed to drop use_for_batch_api column: %w", err)
}
}
if mg.HasColumn(&tables.TableKey{}, "bedrock_batch_s3_config_json") {
if err := mg.DropColumn(&tables.TableKey{}, "bedrock_batch_s3_config_json"); err != nil {
return fmt.Errorf("failed to drop bedrock_batch_s3_config_json column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running use_for_batch_api migration: %s", err.Error())
}
return nil
}
// migrationAddHeaderFilterConfigJSONColumn adds the header_filter_config_json column to the config_client table
func migrationAddHeaderFilterConfigJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_header_filter_config_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasColumn(&tables.TableClientConfig{}, "header_filter_config_json") {
if err := mg.AddColumn(&tables.TableClientConfig{}, "header_filter_config_json"); err != nil {
return fmt.Errorf("failed to add header_filter_config_json column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableClientConfig{}, "header_filter_config_json") {
if err := mg.DropColumn(&tables.TableClientConfig{}, "header_filter_config_json"); err != nil {
return fmt.Errorf("failed to drop header_filter_config_json column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running header_filter_config_json migration: %s", err.Error())
}
return nil
}
// migrationAddAzureClientIDAndClientSecretAndTenantIDColumns adds the azure_client_id, azure_client_secret, and azure_tenant_id columns to the key table
func migrationAddAzureClientIDAndClientSecretAndTenantIDColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_azure_client_id_and_client_secret_and_tenant_id_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableKey{}, "azure_client_id") {
if err := migrator.AddColumn(&tables.TableKey{}, "azure_client_id"); err != nil {
return fmt.Errorf("failed to add azure_client_id column: %w", err)
}
}
if !migrator.HasColumn(&tables.TableKey{}, "azure_client_secret") {
if err := migrator.AddColumn(&tables.TableKey{}, "azure_client_secret"); err != nil {
return fmt.Errorf("failed to add azure_client_secret column: %w", err)
}
}
if !migrator.HasColumn(&tables.TableKey{}, "azure_tenant_id") {
if err := migrator.AddColumn(&tables.TableKey{}, "azure_tenant_id"); err != nil {
return fmt.Errorf("failed to add azure_tenant_id column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableKey{}, "azure_client_id"); err != nil {
return fmt.Errorf("failed to drop azure_client_id column: %w", err)
}
if err := migrator.DropColumn(&tables.TableKey{}, "azure_client_secret"); err != nil {
return fmt.Errorf("failed to drop azure_client_secret column: %w", err)
}
if err := migrator.DropColumn(&tables.TableKey{}, "azure_tenant_id"); err != nil {
return fmt.Errorf("failed to drop azure_tenant_id column: %w", err)
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running azure_client_id_and_client_secret_and_tenant_id migration: %s", err.Error())
}
return nil
}
func migrationAddToolPricingJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_tool_pricing_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableMCPClient{}, "tool_pricing_json") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "tool_pricing_json"); err != nil {
return fmt.Errorf("failed to add tool_pricing_json column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableMCPClient{}, "tool_pricing_json"); err != nil {
return fmt.Errorf("failed to drop tool_pricing_json column: %w", err)
}
return nil
},
}})
return m.Migrate()
}
// migrationRemoveServerPrefixFromMCPTools removes the server name prefix from tool names
// in tools_to_execute_json, tools_to_auto_execute_json, and tool_pricing_json columns
// in both config_mcp_clients and governance_virtual_key_mcp_configs tables.
//
// This migration converts:
// - tools_to_execute_json: ["calculator_add", "calculator_subtract"] → ["add", "subtract"]
// - tools_to_auto_execute_json: ["calculator_multiply"] → ["multiply"]
// - tool_pricing_json: {"calculator_add": 0.001, "calculator_subtract": 0.001} → {"add": 0.001, "subtract": 0.001}
func migrationRemoveServerPrefixFromMCPTools(ctx context.Context, db *gorm.DB) error {
// Helper function to check if a tool name has a prefix matching the client name
// Handles both exact matches and legacy normalized forms
hasClientPrefix := func(toolName, clientName string) (bool, string) {
prefix := clientName + "_"
if strings.HasPrefix(toolName, prefix) {
return true, strings.TrimPrefix(toolName, prefix)
}
// Legacy prefix: normalize the substring before first underscore
if idx := strings.IndexByte(toolName, '_'); idx > 0 {
toolPrefix := toolName[:idx]
unprefixed := toolName[idx+1:]
if normalizeMCPClientName(toolPrefix) == clientName {
return true, unprefixed
}
}
return false, ""
}
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "remove_server_prefix_from_mcp_tools",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
// ============================================================
// Step 1: Migrate config_mcp_clients table
// ============================================================
// Fetch all MCP clients
var mcpClients []tables.TableMCPClient
if err := tx.Find(&mcpClients).Error; err != nil {
return fmt.Errorf("failed to fetch MCP clients: %w", err)
}
// Process each MCP client
for i := range mcpClients {
client := &mcpClients[i]
clientName := client.Name
needsUpdate := false
// Process tools_to_execute_json
var toolsToExecute []string
if client.ToolsToExecuteJSON != "" && client.ToolsToExecuteJSON != "null" {
if err := json.Unmarshal([]byte(client.ToolsToExecuteJSON), &toolsToExecute); err != nil {
return fmt.Errorf("failed to unmarshal tools_to_execute_json for client %s: %w", clientName, err)
}
// Strip prefix from each tool
updatedTools := make([]string, 0, len(toolsToExecute))
seenTools := make(map[string]bool)
for _, tool := range toolsToExecute {
// Check if tool has client prefix (handles both current and legacy normalized forms)
if hasPrefix, unprefixedTool := hasClientPrefix(tool, clientName); hasPrefix {
// Check for collision: if unprefixed tool already exists in the list
if seenTools[unprefixedTool] {
log.Printf("Collision detected when stripping prefix from tool '%s' for client '%s': unprefixed name '%s' already exists. Keeping unprefixed value.", tool, clientName, unprefixedTool)
needsUpdate = true
continue
}
seenTools[unprefixedTool] = true
updatedTools = append(updatedTools, unprefixedTool)
needsUpdate = true
} else {
// Tool already unprefixed or is wildcard "*"
if seenTools[tool] {
log.Printf("Duplicate tool name '%s' found for client '%s'. Keeping first occurrence.", tool, clientName)
continue
}
seenTools[tool] = true
updatedTools = append(updatedTools, tool)
}
}
// Update the JSON
if needsUpdate {
updatedJSON, err := json.Marshal(updatedTools)
if err != nil {
return fmt.Errorf("failed to marshal updated tools_to_execute for client %s: %w", clientName, err)
}
client.ToolsToExecuteJSON = string(updatedJSON)
}
}
// Process tools_to_auto_execute_json
var toolsToAutoExecute []string
if client.ToolsToAutoExecuteJSON != "" && client.ToolsToAutoExecuteJSON != "null" {
if err := json.Unmarshal([]byte(client.ToolsToAutoExecuteJSON), &toolsToAutoExecute); err != nil {
return fmt.Errorf("failed to unmarshal tools_to_auto_execute_json for client %s: %w", clientName, err)
}
// Strip prefix from each tool
updatedAutoTools := make([]string, 0, len(toolsToAutoExecute))
seenAutoTools := make(map[string]bool)
for _, tool := range toolsToAutoExecute {
// Check if tool has client prefix (handles both current and legacy normalized forms)
if hasPrefix, unprefixedTool := hasClientPrefix(tool, clientName); hasPrefix {
// Check for collision: if unprefixed tool already exists in the list
if seenAutoTools[unprefixedTool] {
log.Printf("Collision detected when stripping prefix from auto-execute tool '%s' for client '%s': unprefixed name '%s' already exists. Keeping unprefixed value.", tool, clientName, unprefixedTool)
needsUpdate = true
continue
}
seenAutoTools[unprefixedTool] = true
updatedAutoTools = append(updatedAutoTools, unprefixedTool)
needsUpdate = true
} else {
// Tool already unprefixed or is wildcard "*"
if seenAutoTools[tool] {
log.Printf("Duplicate auto-execute tool name '%s' found for client '%s'. Keeping first occurrence.", tool, clientName)
continue
}
seenAutoTools[tool] = true
updatedAutoTools = append(updatedAutoTools, tool)
}
}
// Update the JSON
if needsUpdate {
updatedJSON, err := json.Marshal(updatedAutoTools)
if err != nil {
return fmt.Errorf("failed to marshal updated tools_to_auto_execute for client %s: %w", clientName, err)
}
client.ToolsToAutoExecuteJSON = string(updatedJSON)
}
}
// Process tool_pricing_json
var toolPricing map[string]float64
if client.ToolPricingJSON != "" && client.ToolPricingJSON != "null" {
if err := json.Unmarshal([]byte(client.ToolPricingJSON), &toolPricing); err != nil {
return fmt.Errorf("failed to unmarshal tool_pricing_json for client %s: %w", clientName, err)
}
// Strip prefix from each tool name key
updatedPricing := make(map[string]float64)
for toolName, price := range toolPricing {
// Check if tool has client prefix (handles both current and legacy normalized forms)
if hasPrefix, unprefixedTool := hasClientPrefix(toolName, clientName); hasPrefix {
// Check for collision: if unprefixed key already exists
if existingPrice, exists := updatedPricing[unprefixedTool]; exists {
log.Printf("Collision detected when stripping prefix from pricing key '%s' for client '%s': unprefixed key '%s' already exists with price %.6f. Keeping existing unprefixed value (%.6f), discarding prefixed value (%.6f).", toolName, clientName, unprefixedTool, existingPrice, existingPrice, price)
needsUpdate = true
continue
}
updatedPricing[unprefixedTool] = price
needsUpdate = true
} else {
// Check for collision: if unprefixed key already exists (from a previously processed prefixed entry)
if existingPrice, exists := updatedPricing[toolName]; exists {
log.Printf("Collision detected for pricing key '%s' for client '%s': key already exists with price %.6f. Keeping first value (%.6f), discarding duplicate (%.6f).", toolName, clientName, existingPrice, existingPrice, price)
continue
}
updatedPricing[toolName] = price
}
}
// Update the JSON
if needsUpdate {
updatedJSON, err := json.Marshal(updatedPricing)
if err != nil {
return fmt.Errorf("failed to marshal updated tool_pricing for client %s: %w", clientName, err)
}
client.ToolPricingJSON = string(updatedJSON)
}
}
// Save the updated client if any changes were made
if needsUpdate {
// Use Model + Updates to ensure changes are persisted
result := tx.Model(&tables.TableMCPClient{}).Where("id = ?", client.ID).Updates(map[string]interface{}{
"tools_to_execute_json": client.ToolsToExecuteJSON,
"tools_to_auto_execute_json": client.ToolsToAutoExecuteJSON,
"tool_pricing_json": client.ToolPricingJSON,
})
if result.Error != nil {
return fmt.Errorf("failed to save updated MCP client %s: %w", clientName, result.Error)
}
}
}
// ============================================================
// Step 2: Migrate governance_virtual_key_mcp_configs table
// ============================================================
// Fetch all virtual key MCP configs with their associated MCP client
var vkMCPConfigs []tables.TableVirtualKeyMCPConfig
if err := tx.Preload("MCPClient").Find(&vkMCPConfigs).Error; err != nil {
return fmt.Errorf("failed to fetch virtual key MCP configs: %w", err)
}
// Process each VK MCP config
for i := range vkMCPConfigs {
vkConfig := &vkMCPConfigs[i]
if vkConfig.MCPClient.Name == "" {
// Skip if MCP client is not loaded
continue
}
clientName := vkConfig.MCPClient.Name
needsUpdate := false
// Process tools_to_execute (this is a JSON array stored in GORM's serializer format)
if len(vkConfig.ToolsToExecute) > 0 {
updatedTools := make([]string, 0, len(vkConfig.ToolsToExecute))
seen := make(map[string]bool, len(vkConfig.ToolsToExecute))
for _, tool := range vkConfig.ToolsToExecute {
var finalTool string
// Check if tool has client prefix (handles both current and legacy normalized forms)
if hasPrefix, unprefixedTool := hasClientPrefix(tool, clientName); hasPrefix {
finalTool = unprefixedTool
} else {
finalTool = tool
}
// Skip if we've already added this tool (collision detection)
if !seen[finalTool] {
seen[finalTool] = true
updatedTools = append(updatedTools, finalTool)
}
}
// Only update if the final list differs from the original
needsUpdate = len(updatedTools) != len(vkConfig.ToolsToExecute)
if !needsUpdate {
// Check if any tools actually changed
for j, tool := range vkConfig.ToolsToExecute {
if tool != updatedTools[j] {
needsUpdate = true
break
}
}
}
if needsUpdate {
vkConfig.ToolsToExecute = updatedTools
}
}
// Save the updated VK config if any changes were made
if needsUpdate {
if err := tx.Save(vkConfig).Error; err != nil {
return fmt.Errorf("failed to save updated VK MCP config ID %d: %w", vkConfig.ID, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Rollback is complex because we need to re-add the prefix
// This requires knowing the client name for each tool
tx = tx.WithContext(ctx)
// ============================================================
// Step 1: Rollback config_mcp_clients table
// ============================================================
var mcpClients []tables.TableMCPClient
if err := tx.Find(&mcpClients).Error; err != nil {
return fmt.Errorf("failed to fetch MCP clients for rollback: %w", err)
}
for _, client := range mcpClients {
clientName := client.Name
needsUpdate := false
// Rollback tools_to_execute_json
var toolsToExecute []string
if client.ToolsToExecuteJSON != "" && client.ToolsToExecuteJSON != "null" {
if err := json.Unmarshal([]byte(client.ToolsToExecuteJSON), &toolsToExecute); err != nil {
return fmt.Errorf("failed to unmarshal tools_to_execute_json for rollback: %w", err)
}
prefixedTools := make([]string, 0, len(toolsToExecute))
for _, tool := range toolsToExecute {
// Skip wildcard
if tool == "*" {
prefixedTools = append(prefixedTools, tool)
continue
}
// Add prefix if not already present
prefix := clientName + "_"
if !strings.HasPrefix(tool, prefix) {
prefixedTools = append(prefixedTools, prefix+tool)
needsUpdate = true
} else {
prefixedTools = append(prefixedTools, tool)
}
}
if needsUpdate {
updatedJSON, err := json.Marshal(prefixedTools)
if err != nil {
return fmt.Errorf("failed to marshal rollback tools_to_execute: %w", err)
}
client.ToolsToExecuteJSON = string(updatedJSON)
}
}
// Rollback tools_to_auto_execute_json
var toolsToAutoExecute []string
if client.ToolsToAutoExecuteJSON != "" && client.ToolsToAutoExecuteJSON != "null" {
if err := json.Unmarshal([]byte(client.ToolsToAutoExecuteJSON), &toolsToAutoExecute); err != nil {
return fmt.Errorf("failed to unmarshal tools_to_auto_execute_json for rollback: %w", err)
}
prefixedAutoTools := make([]string, 0, len(toolsToAutoExecute))
for _, tool := range toolsToAutoExecute {
if tool == "*" {
prefixedAutoTools = append(prefixedAutoTools, tool)
continue
}
prefix := clientName + "_"
if !strings.HasPrefix(tool, prefix) {
prefixedAutoTools = append(prefixedAutoTools, prefix+tool)
needsUpdate = true
} else {
prefixedAutoTools = append(prefixedAutoTools, tool)
}
}
if needsUpdate {
updatedJSON, err := json.Marshal(prefixedAutoTools)
if err != nil {
return fmt.Errorf("failed to marshal rollback tools_to_auto_execute: %w", err)
}
client.ToolsToAutoExecuteJSON = string(updatedJSON)
}
}
// Rollback tool_pricing_json
var toolPricing map[string]float64
if client.ToolPricingJSON != "" && client.ToolPricingJSON != "null" {
if err := json.Unmarshal([]byte(client.ToolPricingJSON), &toolPricing); err != nil {
return fmt.Errorf("failed to unmarshal tool_pricing_json for rollback: %w", err)
}
prefixedPricing := make(map[string]float64)
for toolName, price := range toolPricing {
prefix := clientName + "_"
if !strings.HasPrefix(toolName, prefix) {
prefixedPricing[prefix+toolName] = price
needsUpdate = true
} else {
prefixedPricing[toolName] = price
}
}
if needsUpdate {
updatedJSON, err := json.Marshal(prefixedPricing)
if err != nil {
return fmt.Errorf("failed to marshal rollback tool_pricing: %w", err)
}
client.ToolPricingJSON = string(updatedJSON)
}
}
if needsUpdate {
if err := tx.Save(&client).Error; err != nil {
return fmt.Errorf("failed to save rollback MCP client: %w", err)
}
}
}
// ============================================================
// Step 2: Rollback governance_virtual_key_mcp_configs table
// ============================================================
var vkMCPConfigs []tables.TableVirtualKeyMCPConfig
if err := tx.Preload("MCPClient").Find(&vkMCPConfigs).Error; err != nil {
return fmt.Errorf("failed to fetch virtual key MCP configs for rollback: %w", err)
}
for _, vkConfig := range vkMCPConfigs {
if vkConfig.MCPClient.Name == "" {
continue
}
clientName := vkConfig.MCPClient.Name
needsUpdate := false
if len(vkConfig.ToolsToExecute) > 0 {
prefixedTools := make([]string, 0, len(vkConfig.ToolsToExecute))
for _, tool := range vkConfig.ToolsToExecute {
if tool == "*" {
prefixedTools = append(prefixedTools, tool)
continue
}
prefix := clientName + "_"
if !strings.HasPrefix(tool, prefix) {
prefixedTools = append(prefixedTools, prefix+tool)
needsUpdate = true
} else {
prefixedTools = append(prefixedTools, tool)
}
}
if needsUpdate {
vkConfig.ToolsToExecute = prefixedTools
}
}
if needsUpdate {
if err := tx.Save(&vkConfig).Error; err != nil {
return fmt.Errorf("failed to save rollback VK MCP config: %w", err)
}
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running migration to remove server prefix from MCP tools: %s", err.Error())
}
return nil
}
// migrationAddDistributedLocksTable adds the distributed_locks table for distributed locking
func migrationAddDistributedLocksTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_distributed_locks_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
// Use raw SQL with IF NOT EXISTS for atomic, race-condition-safe table creation
createTableSQL := `
CREATE TABLE IF NOT EXISTS distributed_locks (
lock_key VARCHAR(255) PRIMARY KEY,
holder_id VARCHAR(255) NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`
if err := tx.Exec(createTableSQL).Error; err != nil {
return fmt.Errorf("failed to create distributed_locks table: %w", err)
}
// Create index on expires_at for efficient cleanup queries
createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_distributed_locks_expires_at ON distributed_locks (expires_at)`
if err := tx.Exec(createIndexSQL).Error; err != nil {
return fmt.Errorf("failed to create expires_at index: %w", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if err := tx.Exec("DROP TABLE IF EXISTS distributed_locks").Error; err != nil {
return fmt.Errorf("failed to drop distributed_locks table: %w", err)
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running distributed_locks table migration: %s", err.Error())
}
return nil
}
// migrationAddModelConfigTable adds the governance_model_configs table
func migrationAddModelConfigTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_model_config_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasTable(&tables.TableModelConfig{}) {
if err := migrator.CreateTable(&tables.TableModelConfig{}); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropTable(&tables.TableModelConfig{}); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running add model config table migration: %s", err.Error())
}
return nil
}
// migrationAddProviderGovernanceColumns adds budget_id and rate_limit_id columns to config_providers table
func migrationAddProviderGovernanceColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_provider_governance_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
provider := &tables.TableProvider{}
// Add budget_id column if it doesn't exist
if !migrator.HasColumn(provider, "budget_id") {
if err := migrator.AddColumn(provider, "budget_id"); err != nil {
return fmt.Errorf("failed to add budget_id column: %w", err)
}
}
// Create index for budget_id (outside HasColumn to handle reruns where column exists but index doesn't)
if !migrator.HasIndex(provider, "idx_provider_budget") {
if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_budget ON config_providers (budget_id)").Error; err != nil {
return fmt.Errorf("failed to create budget_id index: %w", err)
}
}
// Add rate_limit_id column if it doesn't exist
if !migrator.HasColumn(provider, "rate_limit_id") {
if err := migrator.AddColumn(provider, "rate_limit_id"); err != nil {
return fmt.Errorf("failed to add rate_limit_id column: %w", err)
}
}
// Create index for rate_limit_id (outside HasColumn to handle reruns where column exists but index doesn't)
if !migrator.HasIndex(provider, "idx_provider_rate_limit") {
if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_rate_limit ON config_providers (rate_limit_id)").Error; err != nil {
return fmt.Errorf("failed to create rate_limit_id index: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
provider := &tables.TableProvider{}
// Drop indexes first
if migrator.HasIndex(provider, "idx_provider_rate_limit") {
if err := tx.Exec("DROP INDEX IF EXISTS idx_provider_rate_limit").Error; err != nil {
return fmt.Errorf("failed to drop rate_limit_id index: %w", err)
}
}
if migrator.HasIndex(provider, "idx_provider_budget") {
if err := tx.Exec("DROP INDEX IF EXISTS idx_provider_budget").Error; err != nil {
return fmt.Errorf("failed to drop budget_id index: %w", err)
}
}
// Drop rate_limit_id column if it exists
if migrator.HasColumn(provider, "rate_limit_id") {
if err := migrator.DropColumn(provider, "rate_limit_id"); err != nil {
return fmt.Errorf("failed to drop rate_limit_id column: %w", err)
}
}
// Drop budget_id column if it exists
if migrator.HasColumn(provider, "budget_id") {
if err := migrator.DropColumn(provider, "budget_id"); err != nil {
return fmt.Errorf("failed to drop budget_id column: %w", err)
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running add provider governance columns migration: %s", err.Error())
}
return nil
}
// migrationAddAllowedHeadersJSONColumn adds the allowed_headers_json column to the client config table
func migrationAddAllowedHeadersJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_allowed_headers_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "allowed_headers_json") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "allowed_headers_json"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "allowed_headers_json") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "allowed_headers_json"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddDisableDBPingsInHealthColumn adds the disable_db_pings_in_health column to the client config table
func migrationAddDisableDBPingsInHealthColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_disable_db_pings_in_health_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "disable_db_pings_in_health") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "disable_db_pings_in_health"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "disable_db_pings_in_health") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "disable_db_pings_in_health"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running db migration: %s", err.Error())
}
return nil
}
// migrationAddIsPingAvailableColumnToMCPClientTable adds the is_ping_available column to the config_mcp_clients table
func migrationAddIsPingAvailableColumnToMCPClientTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_is_ping_available_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableMCPClient{}, "is_ping_available") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "is_ping_available"); err != nil {
return err
}
// Set default value for existing rows
if err := tx.Model(&tables.TableMCPClient{}).Where("is_ping_available IS NULL").Update("is_ping_available", true).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableMCPClient{}, "is_ping_available") {
if err := migrator.DropColumn(&tables.TableMCPClient{}, "is_ping_available"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running is_ping_available migration: %s", err.Error())
}
return nil
}
// migrationAddRoutingRulesTable adds the routing rules table for intelligent request routing
func migrationAddRoutingRulesTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_routing_rules_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasTable(&tables.TableRoutingRule{}) {
if err := migrator.CreateTable(&tables.TableRoutingRule{}); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropTable(&tables.TableRoutingRule{}); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running routing_rules_table migration: %s", err.Error())
}
return nil
}
// migrationAddOAuthTables creates the oauth_configs and oauth_tokens tables
func migrationAddOAuthTables(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_oauth_tables",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Create oauth_configs table FIRST (before adding FK columns that reference it)
if !migrator.HasTable(&tables.TableOauthConfig{}) {
if err := migrator.CreateTable(&tables.TableOauthConfig{}); err != nil {
return fmt.Errorf("failed to create oauth_configs table: %w", err)
}
}
// Create oauth_tokens table
if !migrator.HasTable(&tables.TableOauthToken{}) {
if err := migrator.CreateTable(&tables.TableOauthToken{}); err != nil {
return fmt.Errorf("failed to create oauth_tokens table: %w", err)
}
}
// IF MCPClient table is not present, create it first
if !migrator.HasTable(&tables.TableMCPClient{}) {
if err := migrator.CreateTable(&tables.TableMCPClient{}); err != nil {
return fmt.Errorf("failed to create mcp_clients table: %w", err)
}
}
// Now update MCPClient table to add auth_type, oauth_config_id columns
// (oauth_config_id has FK constraint to oauth_configs table created above)
if !migrator.HasColumn(&tables.TableMCPClient{}, "auth_type") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "auth_type"); err != nil {
return fmt.Errorf("failed to add auth_type column: %w", err)
}
}
if !migrator.HasColumn(&tables.TableMCPClient{}, "oauth_config_id") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "oauth_config_id"); err != nil {
return fmt.Errorf("failed to add oauth_config_id column: %w", err)
}
}
// Set default value for auth_type column
if err := tx.Model(&tables.TableMCPClient{}).Where("auth_type IS NULL").Update("auth_type", "headers").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Drop tables in reverse order
if migrator.HasTable(&tables.TableOauthToken{}) {
if err := migrator.DropTable(&tables.TableOauthToken{}); err != nil {
return fmt.Errorf("failed to drop oauth_tokens table: %w", err)
}
}
if migrator.HasTable(&tables.TableOauthConfig{}) {
if err := migrator.DropTable(&tables.TableOauthConfig{}); err != nil {
return fmt.Errorf("failed to drop oauth_configs table: %w", err)
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running oauth tables migration: %s", err.Error())
}
return nil
}
// migrationAddToolSyncIntervalColumns adds the tool_sync_interval columns to config_client and config_mcp_clients tables
func migrationAddToolSyncIntervalColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_tool_sync_interval_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Add mcp_tool_sync_interval column to config_client table (global setting)
if !migrator.HasColumn(&tables.TableClientConfig{}, "mcp_tool_sync_interval") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "mcp_tool_sync_interval"); err != nil {
return err
}
}
// Add tool_sync_interval column to config_mcp_clients table (per-client setting)
if !migrator.HasColumn(&tables.TableMCPClient{}, "tool_sync_interval") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "tool_sync_interval"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if err := migrator.DropColumn(&tables.TableClientConfig{}, "mcp_tool_sync_interval"); err != nil {
return err
}
if err := migrator.DropColumn(&tables.TableMCPClient{}, "tool_sync_interval"); err != nil {
return err
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running tool sync interval migration: %s", err.Error())
}
return nil
}
// migrationAddMCPClientConfigToOAuthConfig adds the mcp_client_config_json column to oauth_configs table
// This enables multi-instance support by storing pending MCP client config in the database
// instead of in-memory, so OAuth callbacks can be handled by any server instance
func migrationAddMCPClientConfigToOAuthConfig(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_mcp_client_config_to_oauth_config",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableOauthConfig{}, "mcp_client_config_json") {
if err := migrator.AddColumn(&tables.TableOauthConfig{}, "mcp_client_config_json"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableOauthConfig{}, "mcp_client_config_json") {
if err := migrator.DropColumn(&tables.TableOauthConfig{}, "mcp_client_config_json"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running mcp client config oauth migration: %s", err.Error())
}
return nil
}
// migrationAddBaseModelPricingColumn adds the base_model column to the model_pricing table
func migrationAddBaseModelPricingColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_base_model_pricing_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableModelPricing{}, "base_model") {
if err := migrator.AddColumn(&tables.TableModelPricing{}, "base_model"); err != nil {
return fmt.Errorf("failed to add column base_model: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableModelPricing{}, "base_model") {
if err := migrator.DropColumn(&tables.TableModelPricing{}, "base_model"); err != nil {
return fmt.Errorf("failed to drop column base_model: %w", err)
}
}
return nil
},
}})
return m.Migrate()
}
// migrationAddAzureScopesColumn adds the azure_scopes column to the key table for Entra ID OAuth scopes
func migrationAddAzureScopesColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_azure_scopes_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableKey{}, "azure_scopes") {
if err := migrator.AddColumn(&tables.TableKey{}, "azure_scopes"); err != nil {
return fmt.Errorf("failed to add azure_scopes column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableKey{}, "azure_scopes") {
if err := migrator.DropColumn(&tables.TableKey{}, "azure_scopes"); err != nil {
return fmt.Errorf("failed to drop azure_scopes column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running azure_scopes migration: %s", err.Error())
}
return nil
}
// migrationAddReplicateDeploymentsJSONColumn adds the replicate_deployments_json column to the key table.
// This column is later dropped by migrationDropDeploymentColumnsAndAddAliases after data is migrated.
func migrationAddReplicateDeploymentsJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_replicate_deployments_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if !tx.Migrator().HasColumn(&tables.TableKey{}, "replicate_deployments_json") {
if err := tx.Exec("ALTER TABLE config_keys ADD COLUMN replicate_deployments_json TEXT").Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if tx.Migrator().HasColumn(&tables.TableKey{}, "replicate_deployments_json") {
if err := tx.Exec("ALTER TABLE config_keys DROP COLUMN replicate_deployments_json").Error; err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running replicate deployments JSON migration: %s", err.Error())
}
return nil
}
// migrationDropDeploymentColumnsAndAddAliases adds the unified aliases_json column, migrates
// existing per-provider deployment data into it, then drops the legacy columns.
// Only one deployment column will be populated per row (they were mutually exclusive).
func migrationDropDeploymentColumnsAndAddAliases(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "drop_deployment_columns_and_add_aliases",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
m := tx.Migrator()
// Add aliases_json column first
if !m.HasColumn(&tables.TableKey{}, "aliases_json") {
if err := m.AddColumn(&tables.TableKey{}, "aliases_json"); err != nil {
return err
}
}
// Copy data from whichever legacy deployment column is populated into aliases_json.
// Only rows where aliases_json is not already set are touched.
// Exactly one deployment column will be non-null per row (they were mutually exclusive).
for _, col := range []string{
"azure_deployments_json",
"vertex_deployments_json",
"bedrock_deployments_json",
"replicate_deployments_json",
} {
if !m.HasColumn(&tables.TableKey{}, col) {
continue
}
if err := tx.Exec(
"UPDATE config_keys SET aliases_json = " + col +
" WHERE aliases_json IS NULL AND " + col + " IS NOT NULL AND " + col + " != ''",
).Error; err != nil {
return err
}
}
// Drop legacy deployment columns
for _, col := range []string{
"azure_deployments_json",
"vertex_deployments_json",
"bedrock_deployments_json",
"replicate_deployments_json",
} {
if m.HasColumn(&tables.TableKey{}, col) {
if err := tx.Exec("ALTER TABLE config_keys DROP COLUMN " + col).Error; err != nil {
return err
}
}
}
// Fail fast if there are encrypted rows that need fixup but encryption isn't initialized.
// The migration drops legacy deployment columns below, so skipping this fixup
// would silently lose the ability to recover those values.
// This case will ideally never happen
var encryptedAliasCount int64
if err := tx.Table("config_keys").
Where(
"encryption_status = ? AND aliases_json IS NOT NULL AND aliases_json != '' AND aliases_json != '{}'",
tables.EncryptionStatusEncrypted,
).
Count(&encryptedAliasCount).Error; err != nil {
return fmt.Errorf("failed to count encrypted aliases for fixup: %w", err)
}
if encryptedAliasCount > 0 && !encrypt.IsEnabled() {
return fmt.Errorf("encryption must be enabled before migrating encrypted aliases")
}
// Encrypt aliases_json for rows where encryption_status is already 'encrypted'.
// The raw SQL copy above preserved the original column's encryption state:
// - bedrock_deployments_json was encrypted -> aliases_json is already encrypted
// - azure/vertex/replicate were never encrypted -> aliases_json is plaintext
// AfterFind will try to decrypt aliases_json for encrypted rows, so we must
// encrypt any plaintext values first.
if encrypt.IsEnabled() {
type aliasRow struct {
ID uint
AliasesJSON *string
}
var plainRows []aliasRow
if err := tx.Raw(
"SELECT id, aliases_json FROM config_keys WHERE encryption_status = ? AND aliases_json IS NOT NULL AND aliases_json != '' AND aliases_json != '{}'",
tables.EncryptionStatusEncrypted,
).Scan(&plainRows).Error; err != nil {
return fmt.Errorf("failed to fetch aliases for encryption fixup: %w", err)
}
for _, row := range plainRows {
if row.AliasesJSON == nil || *row.AliasesJSON == "" {
continue
}
// If Decrypt succeeds, the value is already encrypted — skip it (bedrock case).
// If Decrypt fails, the value is plaintext — encrypt it.
if _, err := encrypt.Decrypt(*row.AliasesJSON); err != nil {
if !json.Valid([]byte(*row.AliasesJSON)) {
return fmt.Errorf("failed to decrypt aliases for key %d: %w", row.ID, err)
}
encrypted, encErr := encrypt.Encrypt(*row.AliasesJSON)
if encErr != nil {
return fmt.Errorf("failed to encrypt aliases for key %d: %w", row.ID, encErr)
}
if err := tx.Exec(
"UPDATE config_keys SET aliases_json = ? WHERE id = ?",
encrypted, row.ID,
).Error; err != nil {
return fmt.Errorf("failed to update encrypted aliases for key %d: %w", row.ID, err)
}
}
}
}
// Recompute config_hash for keys that had aliases_json populated above,
// since aliases_json is part of the hash input and these rows now have stale hashes.
var affectedKeys []tables.TableKey
if err := tx.Where(
"aliases_json IS NOT NULL AND aliases_json != ? AND aliases_json != ?", "", "{}",
).Find(&affectedKeys).Error; err != nil {
return fmt.Errorf("failed to fetch keys for hash recomputation: %w", err)
}
for _, key := range affectedKeys {
schemaKey := schemas.Key{
Name: key.Name,
Value: key.Value,
Models: key.Models,
BlacklistedModels: key.BlacklistedModels,
Weight: getWeight(key.Weight),
AzureKeyConfig: key.AzureKeyConfig,
VertexKeyConfig: key.VertexKeyConfig,
BedrockKeyConfig: key.BedrockKeyConfig,
Aliases: key.Aliases,
VLLMKeyConfig: key.VLLMKeyConfig,
ReplicateKeyConfig: key.ReplicateKeyConfig,
Enabled: key.Enabled,
UseForBatchAPI: key.UseForBatchAPI,
}
hash, err := GenerateKeyHash(schemaKey)
if err != nil {
return fmt.Errorf("failed to generate hash for key %s: %w", key.Name, err)
}
if err := tx.Model(&key).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update config_hash for key %s: %w", key.Name, err)
}
log.Printf("[Migration] Recomputed config_hash for key '%s' after aliases migration", key.Name)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
m := tx.Migrator()
if m.HasColumn(&tables.TableKey{}, "aliases_json") {
if err := m.DropColumn(&tables.TableKey{}, "aliases_json"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running drop deployment columns and add aliases migration: %s", err.Error())
}
return nil
}
// migrationAddKeyStatusColumns adds status and description columns to config_keys table
// These columns track the status and description of each individual key
func migrationAddKeyStatusColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_key_status_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Add status column
if !migrator.HasColumn(&tables.TableKey{}, "status") {
if err := migrator.AddColumn(&tables.TableKey{}, "status"); err != nil {
return err
}
}
// Add description column
if !migrator.HasColumn(&tables.TableKey{}, "description") {
if err := migrator.AddColumn(&tables.TableKey{}, "description"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Drop description column
if migrator.HasColumn(&tables.TableKey{}, "description") {
if err := migrator.DropColumn(&tables.TableKey{}, "description"); err != nil {
return err
}
}
// Drop status column
if migrator.HasColumn(&tables.TableKey{}, "status") {
if err := migrator.DropColumn(&tables.TableKey{}, "status"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running key model discovery status migration: %s", err.Error())
}
return nil
}
// migrationAddProviderStatusColumns adds status and description columns to config_providers table
// These columns track the status of model discovery attempts for keyless providers
func migrationAddProviderStatusColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_provider_status_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Add status column
if !migrator.HasColumn(&tables.TableProvider{}, "status") {
if err := migrator.AddColumn(&tables.TableProvider{}, "status"); err != nil {
return err
}
}
// Add description column
if !migrator.HasColumn(&tables.TableProvider{}, "description") {
if err := migrator.AddColumn(&tables.TableProvider{}, "description"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Drop description column
if migrator.HasColumn(&tables.TableProvider{}, "description") {
if err := migrator.DropColumn(&tables.TableProvider{}, "description"); err != nil {
return err
}
}
// Drop status column
if migrator.HasColumn(&tables.TableProvider{}, "status") {
if err := migrator.DropColumn(&tables.TableProvider{}, "status"); err != nil {
return err
}
}
return nil
},
}})
err := m.Migrate()
if err != nil {
return fmt.Errorf("error while running provider model discovery status migration: %s", err.Error())
}
return nil
}
// migrationAddAsyncJobResultTTLColumn adds async_job_result_ttl column to config_client table
func migrationAddAsyncJobResultTTLColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_async_job_result_ttl_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "async_job_result_ttl") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "AsyncJobResultTTL"); err != nil {
return fmt.Errorf("failed to add async_job_result_ttl column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "async_job_result_ttl") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "async_job_result_ttl"); err != nil {
return fmt.Errorf("failed to drop async_job_result_ttl column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running async_job_result_ttl migration: %s", err.Error())
}
return nil
}
// migrationAddRateLimitToTeamsAndCustomers adds rate_limit_id column to governance_teams and governance_customers tables
func migrationAddRateLimitToTeamsAndCustomers(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_rate_limit_to_teams_and_customers",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Add rate_limit_id to governance_teams table
if !migrator.HasColumn(&tables.TableTeam{}, "rate_limit_id") {
if err := migrator.AddColumn(&tables.TableTeam{}, "rate_limit_id"); err != nil {
return fmt.Errorf("failed to add rate_limit_id column to teams: %w", err)
}
}
// Add rate_limit_id to governance_customers table
if !migrator.HasColumn(&tables.TableCustomer{}, "rate_limit_id") {
if err := migrator.AddColumn(&tables.TableCustomer{}, "rate_limit_id"); err != nil {
return fmt.Errorf("failed to add rate_limit_id column to customers: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableTeam{}, "rate_limit_id") {
if err := migrator.DropColumn(&tables.TableTeam{}, "rate_limit_id"); err != nil {
return fmt.Errorf("failed to drop rate_limit_id column from teams: %w", err)
}
}
if migrator.HasColumn(&tables.TableCustomer{}, "rate_limit_id") {
if err := migrator.DropColumn(&tables.TableCustomer{}, "rate_limit_id"); err != nil {
return fmt.Errorf("failed to drop rate_limit_id column from customers: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running rate limit migration for teams and customers: %s", err.Error())
}
return nil
}
// migrationBackfillEmptyVirtualKeyConfigs backfills existing virtual keys that have
// empty ProviderConfigs or MCPConfigs with all available providers/MCP clients.
// This preserves the previous "empty means all" behavior for existing VKs after
// the semantic change to "empty means none" (deny-by-default).
func migrationBackfillEmptyVirtualKeyConfigs(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "backfill_empty_virtual_key_configs",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
// Step 1: Backfill ProviderConfigs for VKs that have none
// Find all virtual keys
var allVKs []tables.TableVirtualKey
if err := tx.Find(&allVKs).Error; err != nil {
return fmt.Errorf("failed to query virtual keys: %w", err)
}
// Get all available providers
var allProviders []tables.TableProvider
if err := tx.Find(&allProviders).Error; err != nil {
return fmt.Errorf("failed to query providers: %w", err)
}
// Track which VK IDs were modified so we can recompute their config_hash
modifiedVKIDs := make(map[string]struct{})
for _, vk := range allVKs {
// Check if this VK has any provider configs
var providerConfigCount int64
if err := tx.Model(&tables.TableVirtualKeyProviderConfig{}).Where("virtual_key_id = ?", vk.ID).Count(&providerConfigCount).Error; err != nil {
return fmt.Errorf("failed to count provider configs for VK %s: %w", vk.ID, err)
}
if providerConfigCount == 0 && len(allProviders) > 0 {
// VK has no provider configs - backfill with all available providers
for _, provider := range allProviders {
providerConfig := tables.TableVirtualKeyProviderConfig{
VirtualKeyID: vk.ID,
Provider: provider.Name,
Weight: bifrost.Ptr(1.0),
AllowedModels: []string{},
AllowAllKeys: true,
}
if err := tx.Create(&providerConfig).Error; err != nil {
return fmt.Errorf("failed to create provider config for VK %s, provider %s: %w", vk.ID, provider.Name, err)
}
}
modifiedVKIDs[vk.ID] = struct{}{}
log.Printf("[Migration] Backfilled VK '%s' with %d provider configs", vk.Name, len(allProviders))
}
}
// Step 2: Backfill MCPConfigs for VKs that have none
// Get all available MCP clients
var allMCPClients []tables.TableMCPClient
if err := tx.Find(&allMCPClients).Error; err != nil {
return fmt.Errorf("failed to query MCP clients: %w", err)
}
for _, vk := range allVKs {
// Check if this VK has any MCP configs
var mcpConfigCount int64
if err := tx.Model(&tables.TableVirtualKeyMCPConfig{}).Where("virtual_key_id = ?", vk.ID).Count(&mcpConfigCount).Error; err != nil {
return fmt.Errorf("failed to count MCP configs for VK %s: %w", vk.ID, err)
}
if mcpConfigCount == 0 && len(allMCPClients) > 0 {
// VK has no MCP configs - backfill with all available MCP clients with wildcard
for _, mcpClient := range allMCPClients {
mcpConfig := tables.TableVirtualKeyMCPConfig{
VirtualKeyID: vk.ID,
MCPClientID: mcpClient.ID,
ToolsToExecute: []string{"*"},
}
if err := tx.Create(&mcpConfig).Error; err != nil {
return fmt.Errorf("failed to create MCP config for VK %s, client %d: %w", vk.ID, mcpClient.ID, err)
}
}
modifiedVKIDs[vk.ID] = struct{}{}
log.Printf("[Migration] Backfilled VK '%s' with %d MCP client configs", vk.Name, len(allMCPClients))
}
}
// Step 3: Recompute and persist config_hash for every VK that was modified.
// Without this, subsequent config-sync diff logic would see a stale hash and
// attempt to re-reconcile the VK (potentially undoing the backfill).
for vkID := range modifiedVKIDs {
var vk tables.TableVirtualKey
if err := tx.
Preload("ProviderConfigs").
Preload("ProviderConfigs.Keys").
Preload("MCPConfigs").
First(&vk, "id = ?", vkID).Error; err != nil {
return fmt.Errorf("failed to reload VK %s for hash recomputation: %w", vkID, err)
}
newHash, err := GenerateVirtualKeyHash(vk)
if err != nil {
return fmt.Errorf("failed to generate hash for VK %s: %w", vkID, err)
}
if err := tx.Model(&tables.TableVirtualKey{}).
Where("id = ?", vkID).
Update("config_hash", newHash).Error; err != nil {
return fmt.Errorf("failed to update config_hash for VK %s: %w", vkID, err)
}
log.Printf("[Migration] Recomputed config_hash for VK '%s'", vk.Name)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// No rollback needed - the backfilled configs are valid data
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running backfill empty virtual key configs migration: %s", err.Error())
}
return nil
}
// migrationAddRequiredHeadersJSONColumn adds the required_headers_json column to the config_client table
func migrationAddRequiredHeadersJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_required_headers_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "required_headers_json") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "RequiredHeadersJSON"); err != nil {
return fmt.Errorf("failed to add required_headers_json column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "required_headers_json") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "required_headers_json"); err != nil {
return fmt.Errorf("failed to drop required_headers_json column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running required_headers_json migration: %s", err.Error())
}
return nil
}
// migrationAddOutputCostPerVideoPerSecond adds output_cost_per_video_per_second column to governance_model_pricing table
func migrationAddOutputCostPerVideoPerSecond(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_output_cost_per_video_per_second_and_output_cost_per_second_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableModelPricing{}, "output_cost_per_video_per_second") {
if err := migrator.AddColumn(&tables.TableModelPricing{}, "output_cost_per_video_per_second"); err != nil {
return fmt.Errorf("failed to add output_cost_per_video_per_second column: %w", err)
}
}
if !migrator.HasColumn(&tables.TableModelPricing{}, "output_cost_per_second") {
if err := migrator.AddColumn(&tables.TableModelPricing{}, "output_cost_per_second"); err != nil {
return fmt.Errorf("failed to add output_cost_per_second column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableModelPricing{}, "output_cost_per_video_per_second") {
if err := migrator.DropColumn(&tables.TableModelPricing{}, "output_cost_per_video_per_second"); err != nil {
return fmt.Errorf("failed to drop output_cost_per_video_per_second column: %w", err)
}
}
if migrator.HasColumn(&tables.TableModelPricing{}, "output_cost_per_second") {
if err := migrator.DropColumn(&tables.TableModelPricing{}, "output_cost_per_second"); err != nil {
return fmt.Errorf("failed to drop output_cost_per_second column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running output_cost_per_video_per_second migration: %s", err.Error())
}
return nil
}
// migrationAddLoggingHeadersJSONColumn adds the logging_headers_json column to the config_client table
func migrationAddLoggingHeadersJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_logging_headers_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "logging_headers_json") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "LoggingHeadersJSON"); err != nil {
return fmt.Errorf("failed to add logging_headers_json column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "logging_headers_json") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "logging_headers_json"); err != nil {
return fmt.Errorf("failed to drop logging_headers_json column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running logging_headers_json migration: %s", err.Error())
}
return nil
}
// migrationAddHideDeletedVirtualKeysInFiltersColumn adds the hide_deleted_virtual_keys_in_filters column to config_client.
func migrationAddHideDeletedVirtualKeysInFiltersColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_hide_deleted_virtual_keys_in_filters_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "hide_deleted_virtual_keys_in_filters") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "HideDeletedVirtualKeysInFilters"); err != nil {
return fmt.Errorf("failed to add hide_deleted_virtual_keys_in_filters column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "hide_deleted_virtual_keys_in_filters") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "hide_deleted_virtual_keys_in_filters"); err != nil {
return fmt.Errorf("failed to drop hide_deleted_virtual_keys_in_filters column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running hide_deleted_virtual_keys_in_filters migration: %s", err.Error())
}
return nil
}
// migrationAddEnforceSCIMAuthColumn adds the enforce_scim_auth column to the client config table
func migrationAddEnforceSCIMAuthColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_enforce_scim_auth_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "enforce_scim_auth") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "enforce_scim_auth"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "enforce_scim_auth") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "enforce_scim_auth"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running enforce SCIM auth column migration: %s", err.Error())
}
return nil
}
// migrationAddEnforceAuthOnInferenceColumn adds the enforce_auth_on_inference column to the config_client table
func migrationAddEnforceAuthOnInferenceColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_enforce_auth_on_inference_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "enforce_auth_on_inference") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "enforce_auth_on_inference"); err != nil {
return err
}
}
// Populate from old fields: set to true if either old flag was true
if err := tx.Exec("UPDATE config_client SET enforce_auth_on_inference = true WHERE enforce_governance_header = true OR enforce_scim_auth = true").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "enforce_auth_on_inference") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "enforce_auth_on_inference"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running enforce auth on inference column migration: %s", err.Error())
}
return nil
}
func migrationReconcilePricingOverridesTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "reconcile_pricing_overrides_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mgr := tx.Migrator()
if !mgr.HasTable(&tables.TablePricingOverride{}) {
if err := mgr.CreateTable(&tables.TablePricingOverride{}); err != nil {
return fmt.Errorf("failed to create governance_pricing_overrides table: %w", err)
}
return nil
}
if err := tx.AutoMigrate(&tables.TablePricingOverride{}); err != nil {
return fmt.Errorf("failed to automigrate governance_pricing_overrides table: %w", err)
}
for _, indexName := range []string{"idx_pricing_override_scope", "idx_pricing_override_match"} {
if mgr.HasIndex(&tables.TablePricingOverride{}, indexName) {
continue
}
if err := mgr.CreateIndex(&tables.TablePricingOverride{}, indexName); err != nil {
return fmt.Errorf("failed to create pricing override index %s: %w", indexName, err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mgr := tx.Migrator()
if mgr.HasTable(&tables.TablePricingOverride{}) {
if err := mgr.DropTable(&tables.TablePricingOverride{}); err != nil {
return fmt.Errorf("failed to drop governance_pricing_overrides table: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running pricing overrides table reconcile migration: %s", err.Error())
}
return nil
}
// migrationAddEncryptionColumns adds the encryption_status column to the config_keys, governance_virtual_keys, sessions, oauth_configs, oauth_tokens, config_mcp_clients, config_providers, config_vector_store, and config_plugins tables
func migrationAddEncryptionColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_encryption_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mgr := tx.Migrator()
type encryptionTable struct {
table interface{}
columns []string
}
targets := []encryptionTable{
{&tables.TableKey{}, []string{"encryption_status"}},
{&tables.TableVirtualKey{}, []string{"encryption_status", "value_hash"}},
{&tables.SessionsTable{}, []string{"encryption_status", "token_hash"}},
{&tables.TableOauthConfig{}, []string{"encryption_status"}},
{&tables.TableOauthToken{}, []string{"encryption_status"}},
{&tables.TableMCPClient{}, []string{"encryption_status"}},
{&tables.TableProvider{}, []string{"encryption_status"}},
{&tables.TableVectorStoreConfig{}, []string{"encryption_status"}},
{&tables.TablePlugin{}, []string{"encryption_status"}},
}
for _, t := range targets {
for _, col := range t.columns {
if !mgr.HasColumn(t.table, col) {
if err := mgr.AddColumn(t.table, col); err != nil {
return fmt.Errorf("failed to add column %s: %w", col, err)
}
}
}
}
// Backfill encryption_status for all tables that have the column
backfillTables := []string{
"config_keys",
"governance_virtual_keys",
"sessions",
"oauth_configs",
"oauth_tokens",
"config_mcp_clients",
"config_providers",
"config_vector_store",
"config_plugins",
}
for _, table := range backfillTables {
if err := tx.Exec(fmt.Sprintf(
"UPDATE %s SET encryption_status = 'plain_text' WHERE encryption_status IS NULL OR encryption_status = ''",
table,
)).Error; err != nil {
return fmt.Errorf("failed to backfill encryption_status in %s: %w", table, err)
}
}
// Backfill value_hash for existing virtual keys
// Use NULL instead of '' to avoid unique constraint violations
// (multiple rows with '' would violate the unique index, but NULLs are excluded)
if err := tx.Exec(`
UPDATE governance_virtual_keys
SET value_hash = NULL
WHERE value_hash IS NULL OR value_hash = ''
`).Error; err != nil {
return fmt.Errorf("failed to initialize value_hash: %w", err)
}
// Backfill token_hash for existing sessions
// Use NULL instead of '' to avoid unique constraint violations
if err := tx.Exec(`
UPDATE sessions
SET token_hash = NULL
WHERE token_hash IS NULL OR token_hash = ''
`).Error; err != nil {
return fmt.Errorf("failed to initialize token_hash: %w", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mgr := tx.Migrator()
type dropInfo struct {
table interface{}
columns []string
}
drops := []dropInfo{
{&tables.TableKey{}, []string{"encryption_status"}},
{&tables.TableVirtualKey{}, []string{"encryption_status", "value_hash"}},
{&tables.SessionsTable{}, []string{"encryption_status", "token_hash"}},
{&tables.TableOauthConfig{}, []string{"encryption_status"}},
{&tables.TableOauthToken{}, []string{"encryption_status"}},
{&tables.TableMCPClient{}, []string{"encryption_status"}},
{&tables.TableProvider{}, []string{"encryption_status"}},
{&tables.TableVectorStoreConfig{}, []string{"encryption_status"}},
{&tables.TablePlugin{}, []string{"encryption_status"}},
}
for _, d := range drops {
for _, col := range d.columns {
if mgr.HasColumn(d.table, col) {
if err := mgr.DropColumn(d.table, col); err != nil {
return err
}
}
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running encryption columns migration: %s", err.Error())
}
return nil
}
// migrationDropEnableGovernanceColumn drops the enable_governance column from the config_client table
func migrationDropEnableGovernanceColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "drop_enable_governance_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "enable_governance") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "enable_governance"); err != nil {
return fmt.Errorf("failed to drop enable_governance column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running drop enable governance column rollback: %s", err.Error())
}
return nil
}
// migrationAddVLLMKeyConfigColumns adds vllm_url and vllm_model_name columns to the key table
func migrationAddVLLMKeyConfigColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_vllm_key_config_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableKey{}, "vllm_url") {
if err := migrator.AddColumn(&tables.TableKey{}, "vllm_url"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TableKey{}, "vllm_model_name") {
if err := migrator.AddColumn(&tables.TableKey{}, "vllm_model_name"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableKey{}, "vllm_url") {
if err := migrator.DropColumn(&tables.TableKey{}, "vllm_url"); err != nil {
return err
}
}
if migrator.HasColumn(&tables.TableKey{}, "vllm_model_name") {
if err := migrator.DropColumn(&tables.TableKey{}, "vllm_model_name"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running vllm key config columns migration: %s", err.Error())
}
return nil
}
// migrationWidenEncryptedVarcharColumns widens varchar columns that store AES-256-GCM
// encrypted values to TEXT. Encryption adds ~28 bytes of overhead plus base64 expansion (4/3x),
// so a varchar(255) can only hold ~153-char plaintext. Using TEXT removes any size constraints.
// SQLite does not enforce varchar(n) size constraints, so no migration is needed there.
func migrationWidenEncryptedVarcharColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "widen_encrypted_varchar_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if tx.Dialector.Name() != "postgres" {
return nil
}
stmts := []string{
// config_keys table - all encrypted EnvVar fields
"ALTER TABLE config_keys ALTER COLUMN azure_api_version TYPE TEXT",
"ALTER TABLE config_keys ALTER COLUMN azure_client_id TYPE TEXT",
"ALTER TABLE config_keys ALTER COLUMN azure_tenant_id TYPE TEXT",
"ALTER TABLE config_keys ALTER COLUMN vertex_project_id TYPE TEXT",
"ALTER TABLE config_keys ALTER COLUMN vertex_project_number TYPE TEXT",
"ALTER TABLE config_keys ALTER COLUMN vertex_region TYPE TEXT",
"ALTER TABLE config_keys ALTER COLUMN bedrock_access_key TYPE TEXT",
"ALTER TABLE config_keys ALTER COLUMN bedrock_region TYPE TEXT",
// sessions table
"ALTER TABLE sessions ALTER COLUMN token TYPE TEXT",
// governance_virtual_keys table
"ALTER TABLE governance_virtual_keys ALTER COLUMN value TYPE TEXT",
// oauth_configs table
"ALTER TABLE oauth_configs ALTER COLUMN code_verifier TYPE TEXT",
}
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil {
return fmt.Errorf("failed to widen column (%s): %w", stmt, err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running widen encrypted varchar columns migration: %s", err.Error())
}
return nil
}
// migrationAddBedrockAssumeRoleColumns adds bedrock_role_arn, bedrock_external_id, and bedrock_role_session_name
// columns to the config_keys table for STS AssumeRole support in Bedrock keys.
func migrationAddBedrockAssumeRoleColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_bedrock_assume_role_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasColumn(&tables.TableKey{}, "bedrock_role_arn") {
if err := mg.AddColumn(&tables.TableKey{}, "bedrock_role_arn"); err != nil {
return fmt.Errorf("failed to add bedrock_role_arn column: %w", err)
}
}
if !mg.HasColumn(&tables.TableKey{}, "bedrock_external_id") {
if err := mg.AddColumn(&tables.TableKey{}, "bedrock_external_id"); err != nil {
return fmt.Errorf("failed to add bedrock_external_id column: %w", err)
}
}
if !mg.HasColumn(&tables.TableKey{}, "bedrock_role_session_name") {
if err := mg.AddColumn(&tables.TableKey{}, "bedrock_role_session_name"); err != nil {
return fmt.Errorf("failed to add bedrock_role_session_name column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableKey{}, "bedrock_role_arn") {
if err := mg.DropColumn(&tables.TableKey{}, "bedrock_role_arn"); err != nil {
return fmt.Errorf("failed to drop bedrock_role_arn column: %w", err)
}
}
if mg.HasColumn(&tables.TableKey{}, "bedrock_external_id") {
if err := mg.DropColumn(&tables.TableKey{}, "bedrock_external_id"); err != nil {
return fmt.Errorf("failed to drop bedrock_external_id column: %w", err)
}
}
if mg.HasColumn(&tables.TableKey{}, "bedrock_role_session_name") {
if err := mg.DropColumn(&tables.TableKey{}, "bedrock_role_session_name"); err != nil {
return fmt.Errorf("failed to drop bedrock_role_session_name column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running bedrock assume role columns migration: %s", err.Error())
}
return nil
}
// migrationAddAllowAllKeysToProviderConfig adds the allow_all_keys column to the provider config table
// and backfills existing rows: any provider config with no keys in the join table previously meant
// "allow all keys" (old semantic), so they get allow_all_keys = true to preserve behaviour.
func migrationAddAllowAllKeysToProviderConfig(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_allow_all_keys_to_provider_config",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migratorInstance := tx.Migrator()
// Add the column if it doesn't exist
if !migratorInstance.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "allow_all_keys") {
if err := migratorInstance.AddColumn(&tables.TableVirtualKeyProviderConfig{}, "allow_all_keys"); err != nil {
return fmt.Errorf("failed to add allow_all_keys column: %w", err)
}
}
// Backfill: find all provider configs that have no keys in the join table.
// These previously meant "allow all keys", so set allow_all_keys = true.
var allConfigs []tables.TableVirtualKeyProviderConfig
if err := tx.Find(&allConfigs).Error; err != nil {
return fmt.Errorf("failed to query provider configs: %w", err)
}
// Track which VK IDs were modified so we can recompute their config_hash.
// Without this, subsequent config-sync diff logic would see a stale hash
// and attempt to re-reconcile the VK (potentially undoing the backfill).
modifiedVKIDs := make(map[string]struct{})
for _, pc := range allConfigs {
var keyCount int64
if err := tx.Table("governance_virtual_key_provider_config_keys").
Where("table_virtual_key_provider_config_id = ?", pc.ID).
Count(&keyCount).Error; err != nil {
return fmt.Errorf("failed to count keys for provider config %d: %w", pc.ID, err)
}
if keyCount == 0 {
if err := tx.Model(&tables.TableVirtualKeyProviderConfig{}).
Where("id = ?", pc.ID).
Update("allow_all_keys", true).Error; err != nil {
return fmt.Errorf("failed to backfill allow_all_keys for provider config %d: %w", pc.ID, err)
}
modifiedVKIDs[pc.VirtualKeyID] = struct{}{}
}
}
// Recompute and persist config_hash for every VK that was modified.
for vkID := range modifiedVKIDs {
var vk tables.TableVirtualKey
if err := tx.
Preload("ProviderConfigs").
Preload("ProviderConfigs.Keys").
Preload("MCPConfigs").
First(&vk, "id = ?", vkID).Error; err != nil {
return fmt.Errorf("failed to reload VK %s for hash recomputation: %w", vkID, err)
}
newHash, err := GenerateVirtualKeyHash(vk)
if err != nil {
return fmt.Errorf("failed to generate hash for VK %s: %w", vkID, err)
}
if err := tx.Model(&tables.TableVirtualKey{}).
Where("id = ?", vkID).
Update("config_hash", newHash).Error; err != nil {
return fmt.Errorf("failed to update config_hash for VK %s: %w", vkID, err)
}
log.Printf("[Migration] Recomputed config_hash for VK '%s'", vk.Name)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migratorInstance := tx.Migrator()
if migratorInstance.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "allow_all_keys") {
if err := migratorInstance.DropColumn(&tables.TableVirtualKeyProviderConfig{}, "allow_all_keys"); err != nil {
return fmt.Errorf("failed to drop allow_all_keys column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running allow_all_keys migration: %s", err.Error())
}
return nil
}
// migrationAddMCPDisableAutoToolInjectColumn adds the mcp_disable_auto_tool_inject column to the client config table.
// When true, MCP tools are not automatically injected into requests; only explicit context filters apply.
func migrationAddMCPDisableAutoToolInjectColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_mcp_disable_auto_tool_inject_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migratorInstance := tx.Migrator()
if !migratorInstance.HasColumn(&tables.TableClientConfig{}, "mcp_disable_auto_tool_inject") {
if err := migratorInstance.AddColumn(&tables.TableClientConfig{}, "mcp_disable_auto_tool_inject"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migratorInstance := tx.Migrator()
if err := migratorInstance.DropColumn(&tables.TableClientConfig{}, "mcp_disable_auto_tool_inject"); err != nil {
return err
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running mcp disable auto tool inject migration: %s", err.Error())
}
return nil
}
// migrationAddPricingRefactorColumns adds all new pricing columns introduced in the pricing module refactor
func migrationAddPricingRefactorColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_pricing_refactor_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"input_cost_per_token_priority",
"output_cost_per_token_priority",
"cache_creation_input_token_cost_above_1hr",
"cache_creation_input_token_cost_above_1hr_above_200k_tokens",
"cache_creation_input_audio_token_cost",
"cache_read_input_token_cost_priority",
"input_cost_per_pixel",
"output_cost_per_pixel",
"output_cost_per_image_premium_image",
"output_cost_per_image_above_512_and_512_pixels",
"output_cost_per_image_above_512x512_pixels_premium",
"output_cost_per_image_above_1024_and_1024_pixels",
"output_cost_per_image_above_1024x1024_pixels_premium",
"input_cost_per_audio_token",
"input_cost_per_second",
"input_cost_per_video_per_second",
"input_cost_per_audio_per_second",
"output_cost_per_audio_token",
"search_context_cost_per_query",
"code_interpreter_cost_per_session",
"input_cost_per_character",
"input_cost_per_token_above_128k_tokens",
"input_cost_per_image_above_128k_tokens",
"input_cost_per_video_per_second_above_128k_tokens",
"input_cost_per_audio_per_second_above_128k_tokens",
"output_cost_per_token_above_128k_tokens",
}
for _, field := range columns {
if !mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.AddColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to add column %s: %w", field, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"input_cost_per_token_priority",
"output_cost_per_token_priority",
"cache_creation_input_token_cost_above_1hr",
"cache_creation_input_token_cost_above_1hr_above_200k_tokens",
"cache_creation_input_audio_token_cost",
"cache_read_input_token_cost_priority",
"input_cost_per_pixel",
"output_cost_per_pixel",
"output_cost_per_image_premium_image",
"output_cost_per_image_above_512_and_512_pixels",
"output_cost_per_image_above_512x512_pixels_premium",
"output_cost_per_image_above_1024_and_1024_pixels",
"output_cost_per_image_above_1024x1024_pixels_premium",
"input_cost_per_audio_token",
"input_cost_per_second",
"input_cost_per_video_per_second",
"input_cost_per_audio_per_second",
"output_cost_per_audio_token",
"search_context_cost_per_query",
"code_interpreter_cost_per_session",
"input_cost_per_character",
"input_cost_per_token_above_128k_tokens",
"input_cost_per_image_above_128k_tokens",
"input_cost_per_video_per_second_above_128k_tokens",
"input_cost_per_audio_per_second_above_128k_tokens",
"output_cost_per_token_above_128k_tokens",
}
for _, field := range columns {
if mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.DropColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to drop column %s: %w", field, err)
}
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running pricing refactor columns migration: %s", err.Error())
}
return nil
}
// migrationRenameTruncatedPricingColumn renames the output_cost_per_image_above_512_and_512_pixels_and_premium_image
// column which at 64 chars exceeds PostgreSQL's 63-character identifier limit. PostgreSQL silently truncated
// it to output_cost_per_image_above_512_and_512_pixels_and_premium_imag (63 chars), while SQLite kept the
// full 64-char name. This migration renames whichever variant exists to the shorter canonical name.
func migrationRenameTruncatedPricingColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "rename_truncated_pricing_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
const newName = "output_cost_per_image_above_512x512_pixels_premium"
if mg.HasColumn(&tables.TableModelPricing{}, newName) {
return nil
}
// PostgreSQL truncated the 64-char name to 63 chars
const oldNamePG = "output_cost_per_image_above_512_and_512_pixels_and_premium_imag"
// SQLite kept the full 64-char name
const oldNameSQLite = "output_cost_per_image_above_512_and_512_pixels_and_premium_image"
if mg.HasColumn(&tables.TableModelPricing{}, oldNamePG) {
if err := tx.Exec("ALTER TABLE governance_model_pricing RENAME COLUMN " + oldNamePG + " TO " + newName).Error; err != nil {
return fmt.Errorf("failed to rename column %s to %s: %w", oldNamePG, newName, err)
}
} else if mg.HasColumn(&tables.TableModelPricing{}, oldNameSQLite) {
if err := tx.Exec("ALTER TABLE governance_model_pricing RENAME COLUMN " + oldNameSQLite + " TO " + newName).Error; err != nil {
return fmt.Errorf("failed to rename column %s to %s: %w", oldNameSQLite, newName, err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running rename_truncated_pricing_column migration: %s", err.Error())
}
return nil
}
// migrationAddImageQualityPricingColumns adds quality-based per-image cost columns (low, medium, high, auto).
func migrationAddImageQualityPricingColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_image_quality_pricing_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"output_cost_per_image_above_2048_and_2048_pixels",
"output_cost_per_image_above_4096_and_4096_pixels",
"output_cost_per_image_low_quality",
"output_cost_per_image_medium_quality",
"output_cost_per_image_high_quality",
"output_cost_per_image_auto_quality",
}
for _, field := range columns {
if !mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.AddColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to add column %s: %w", field, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"output_cost_per_image_above_2048_and_2048_pixels",
"output_cost_per_image_above_4096_and_4096_pixels",
"output_cost_per_image_low_quality",
"output_cost_per_image_medium_quality",
"output_cost_per_image_high_quality",
"output_cost_per_image_auto_quality",
}
for _, field := range columns {
if mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.DropColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to drop column %s: %w", field, err)
}
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running image quality pricing columns migration: %s", err.Error())
}
return nil
}
// legacyRoutingRuleColumns is a migration-only struct that represents the old routing_rules
// schema before provider/model/key_id were moved to the routing_targets table.
// GORM's SQLite DropColumn/AddColumn need a real struct (not a string table name) to
// reconstruct the table correctly, so we keep this stub around for migration use only.
type legacyRoutingRuleColumns struct {
Provider string `gorm:"column:provider;type:varchar(255)"`
Model string `gorm:"column:model;type:varchar(255)"`
}
func (legacyRoutingRuleColumns) TableName() string { return "routing_rules" }
// migrationAddRoutingTargetsTable creates the routing_targets table and seeds one target row per
// existing routing rule, migrating the legacy provider/model columns.
// After seeding, the legacy columns are dropped from routing_rules.
func migrationAddRoutingTargetsTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_routing_targets_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
// 1. Create routing_targets table
if !mg.HasTable(&tables.TableRoutingTarget{}) {
if err := mg.CreateTable(&tables.TableRoutingTarget{}); err != nil {
return fmt.Errorf("failed to create routing_targets table: %w", err)
}
}
if !mg.HasConstraint(&tables.TableRoutingRule{}, "Targets") {
if err := mg.CreateConstraint(&tables.TableRoutingRule{}, "Targets"); err != nil {
return fmt.Errorf("failed to create routing_targets foreign key: %w", err)
}
}
// 2. Read legacy data BEFORE dropping columns, then drop columns, then seed.
// Order matters: DropColumn on SQLite recreates the routing_rules table, which
// triggers the OnDelete:CASCADE on routing_targets and deletes any rows inserted
// before the drop. So we read first, drop, then insert.
type legacyRule struct {
ID string
Provider string
Model string
}
var legacyRows []legacyRule
if mg.HasColumn("routing_rules", "provider") {
if err := tx.Table("routing_rules").Select("id, provider, model").Scan(&legacyRows).Error; err != nil {
return fmt.Errorf("failed to scan routing_rules for seeding: %w", err)
}
}
// 3. Drop legacy single-target columns from routing_rules.
// Must use the struct form (not string) so SQLite can reconstruct the table correctly.
// Do this BEFORE seeding so the CASCADE triggered by table recreation hits an empty
// routing_targets table (nothing to delete yet).
legacyModel := &legacyRoutingRuleColumns{}
for _, col := range []string{"provider", "model"} {
if mg.HasColumn("routing_rules", col) {
if err := mg.DropColumn(legacyModel, col); err != nil {
return fmt.Errorf("failed to drop column %s from routing_rules: %w", col, err)
}
}
}
// 4. Seed routing_targets from the legacy data read above (idempotent).
for _, row := range legacyRows {
var count int64
if err := tx.Table("routing_targets").Where("rule_id = ?", row.ID).Count(&count).Error; err != nil {
return fmt.Errorf("failed to count targets for rule %s: %w", row.ID, err)
}
if count > 0 {
continue // already seeded
}
target := tables.TableRoutingTarget{
RuleID: row.ID,
Weight: 1.0,
}
if row.Provider != "" {
p := row.Provider
target.Provider = &p
}
if row.Model != "" {
m := row.Model
target.Model = &m
}
if err := tx.Create(&target).Error; err != nil {
return fmt.Errorf("failed to seed target for rule %s: %w", row.ID, err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasTable(&tables.TableRoutingTarget{}) {
return nil
}
// 1. Add provider and model columns back to routing_rules (before dropping targets)
legacyModel := &legacyRoutingRuleColumns{}
for _, col := range []string{"provider", "model"} {
if !mg.HasColumn("routing_rules", col) {
if err := mg.AddColumn(legacyModel, col); err != nil {
return fmt.Errorf("failed to add column %s to routing_rules: %w", col, err)
}
}
}
// 2. Backfill provider/model from routing_targets into routing_rules (join by rule_id)
type targetRow struct {
RuleID string
Provider *string
Model *string
}
var targets []targetRow
if err := tx.Table("routing_targets").Select("rule_id, provider, model").Order("rule_id").Scan(&targets).Error; err != nil {
return fmt.Errorf("failed to scan routing_targets for backfill: %w", err)
}
ruleData := make(map[string]targetRow)
for _, t := range targets {
if _, ok := ruleData[t.RuleID]; !ok {
ruleData[t.RuleID] = t
}
}
for ruleID, t := range ruleData {
provider, model := "", ""
if t.Provider != nil {
provider = *t.Provider
}
if t.Model != nil {
model = *t.Model
}
if err := tx.Table("routing_rules").Where("id = ?", ruleID).Updates(map[string]interface{}{
"provider": provider,
"model": model,
}).Error; err != nil {
return fmt.Errorf("failed to backfill routing_rule %s: %w", ruleID, err)
}
}
// 3. Drop routing_targets table
if mg.HasConstraint(&tables.TableRoutingRule{}, "Targets") {
if err := mg.DropConstraint(&tables.TableRoutingRule{}, "Targets"); err != nil {
return fmt.Errorf("failed to drop routing_targets foreign key: %w", err)
}
}
if err := mg.DropTable(&tables.TableRoutingTarget{}); err != nil {
return fmt.Errorf("failed to drop routing_targets table: %w", err)
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running routing_targets_table migration: %s", err.Error())
}
return nil
}
// migrationAddPromptRepoTables adds the prompt repository tables (folders, prompts, versions, sessions)
func migrationAddPromptRepoTables(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_prompt_repo_tables",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Create folders table
if !migrator.HasTable(&tables.TableFolder{}) {
if err := migrator.CreateTable(&tables.TableFolder{}); err != nil {
return err
}
}
// Create prompts table
if !migrator.HasTable(&tables.TablePrompt{}) {
if err := migrator.CreateTable(&tables.TablePrompt{}); err != nil {
return err
}
}
// Create prompt_versions table
if !migrator.HasTable(&tables.TablePromptVersion{}) {
if err := migrator.CreateTable(&tables.TablePromptVersion{}); err != nil {
return err
}
}
// Create prompt_version_messages table
if !migrator.HasTable(&tables.TablePromptVersionMessage{}) {
if err := migrator.CreateTable(&tables.TablePromptVersionMessage{}); err != nil {
return err
}
}
// Create prompt_sessions table
if !migrator.HasTable(&tables.TablePromptSession{}) {
if err := migrator.CreateTable(&tables.TablePromptSession{}); err != nil {
return err
}
}
// Create prompt_session_messages table
if !migrator.HasTable(&tables.TablePromptSessionMessage{}) {
if err := migrator.CreateTable(&tables.TablePromptSessionMessage{}); err != nil {
return err
}
}
// Apply schema updates (indexes, constraints) to existing tables
if err := tx.AutoMigrate(
&tables.TablePromptVersion{},
&tables.TablePromptSession{},
); err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
// Drop tables in reverse order (respecting foreign key constraints)
if err := migrator.DropTable(&tables.TablePromptSessionMessage{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TablePromptSession{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TablePromptVersionMessage{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TablePromptVersion{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TablePrompt{}); err != nil {
return err
}
if err := migrator.DropTable(&tables.TableFolder{}); err != nil {
return err
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running prompt repo tables migration: %s", err.Error())
}
// Add prompt_id column to prompt message tables
m = migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_prompt_id_to_prompt_message_tables",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TablePromptVersionMessage{}, "prompt_id") {
if err := migrator.AddColumn(&tables.TablePromptVersionMessage{}, "PromptID"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TablePromptSessionMessage{}, "prompt_id") {
if err := migrator.AddColumn(&tables.TablePromptSessionMessage{}, "PromptID"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TablePromptVersionMessage{}, "prompt_id") {
if err := migrator.DropColumn(&tables.TablePromptVersionMessage{}, "prompt_id"); err != nil {
return err
}
}
if migrator.HasColumn(&tables.TablePromptSessionMessage{}, "prompt_id") {
if err := migrator.DropColumn(&tables.TablePromptSessionMessage{}, "prompt_id"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running add_prompt_id_to_prompt_message_tables migration: %s", err.Error())
}
m = migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_model_parameters_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasTable(&tables.TableModelParameters{}) {
if err := migrator.CreateTable(&tables.TableModelParameters{}); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasTable(&tables.TableModelParameters{}) {
if err := migrator.DropTable(&tables.TableModelParameters{}); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running add_model_parameters_table migration: %s", err.Error())
}
return nil
}
// migrationBackfillAllowedModelsWildcard converts empty allowed_models on
// governance_virtual_key_provider_configs and empty models_json on keys to ["*"],
// preserving the previous "empty = allow all" semantics for existing records.
// After this migration the new convention applies: ["*"] = allow all, [] = deny all.
func migrationBackfillAllowedModelsWildcard(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "backfill_allowed_models_wildcard",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
// --- Field 1: vk.provider_config.allowed_models ---
// Rows with '[]' previously meant "allow all models"; migrate to '["*"]'.
if err := tx.Model(&tables.TableVirtualKeyProviderConfig{}).
Where("allowed_models = ? OR allowed_models IS NULL", `[]`).
Update("allowed_models", `["*"]`).Error; err != nil {
return fmt.Errorf("failed to backfill provider_config allowed_models: %w", err)
}
// Recompute config_hash for all VKs that have provider configs
// (any of them may have had their allowed_models updated above).
var modifiedVKIDs []string
if err := tx.Model(&tables.TableVirtualKeyProviderConfig{}).
Distinct("virtual_key_id").
Pluck("virtual_key_id", &modifiedVKIDs).Error; err != nil {
return fmt.Errorf("failed to query VK IDs for hash recomputation: %w", err)
}
for _, vkID := range modifiedVKIDs {
var vk tables.TableVirtualKey
if err := tx.
Preload("ProviderConfigs").
Preload("ProviderConfigs.Keys").
Preload("MCPConfigs").
First(&vk, "id = ?", vkID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Orphaned provider config row — VK was deleted; skip.
continue
}
return fmt.Errorf("failed to reload VK %s for hash recomputation: %w", vkID, err)
}
newHash, err := GenerateVirtualKeyHash(vk)
if err != nil {
return fmt.Errorf("failed to generate hash for VK %s: %w", vkID, err)
}
if err := tx.Model(&tables.TableVirtualKey{}).
Where("id = ?", vkID).
Update("config_hash", newHash).Error; err != nil {
return fmt.Errorf("failed to update config_hash for VK %s: %w", vkID, err)
}
log.Printf("[Migration] Recomputed config_hash for VK '%s' after allowed_models backfill", vk.Name)
}
// --- Field 2: provider.key.models (models_json column) ---
// Rows with '[]' or empty string previously meant "allow all models"; migrate to '["*"]'.
if err := tx.Model(&tables.TableKey{}).
Where("models_json = ? OR models_json = ? OR models_json IS NULL", `[]`, ``).
Update("models_json", `["*"]`).Error; err != nil {
return fmt.Errorf("failed to backfill key models_json: %w", err)
}
// Recompute config_hash for all keys since models_json is part of the hash input.
var keys []tables.TableKey
if err := tx.Find(&keys).Error; err != nil {
return fmt.Errorf("failed to fetch keys for hash recomputation: %w", err)
}
for _, key := range keys {
schemaKey := schemas.Key{
Name: key.Name,
Value: key.Value,
Models: key.Models,
Weight: getWeight(key.Weight),
AzureKeyConfig: key.AzureKeyConfig,
VertexKeyConfig: key.VertexKeyConfig,
BedrockKeyConfig: key.BedrockKeyConfig,
Aliases: key.Aliases,
VLLMKeyConfig: key.VLLMKeyConfig,
ReplicateKeyConfig: key.ReplicateKeyConfig,
OllamaKeyConfig: key.OllamaKeyConfig,
SGLKeyConfig: key.SGLKeyConfig,
Enabled: key.Enabled,
UseForBatchAPI: key.UseForBatchAPI,
}
hash, err := GenerateKeyHash(schemaKey)
if err != nil {
return fmt.Errorf("failed to generate hash for key %s: %w", key.Name, err)
}
if err := tx.Model(&key).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update config_hash for key %s: %w", key.Name, err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Rollback is intentionally a no-op: reverting ["*"] back to [] would
// re-introduce the ambiguous "empty = allow all" semantics on downgrade.
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running backfill_allowed_models_wildcard migration: %s", err.Error())
}
return nil
}
// migrationAddMCPClientAllowedExtraHeadersJSONColumn adds the allowed_extra_headers_json column to the mcp_client table
func migrationAddMCPClientAllowedExtraHeadersJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_mcp_client_allowed_extra_headers_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableMCPClient{}, "allowed_extra_headers_json") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "allowed_extra_headers_json"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableMCPClient{}, "allowed_extra_headers_json") {
if err := migrator.DropColumn(&tables.TableMCPClient{}, "allowed_extra_headers_json"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running add_mcp_client_allowed_extra_headers_json_column migration: %s", err.Error())
}
return nil
}
// migrationAddPluginOrderColumns adds placement and exec_order columns to config_plugins table
func migrationAddPluginOrderColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_plugin_order_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TablePlugin{}, "placement") {
if err := migrator.AddColumn(&tables.TablePlugin{}, "Placement"); err != nil {
return fmt.Errorf("failed to add placement column: %w", err)
}
}
if !migrator.HasColumn(&tables.TablePlugin{}, "exec_order") {
if err := migrator.AddColumn(&tables.TablePlugin{}, "Order"); err != nil {
return fmt.Errorf("failed to add exec_order column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TablePlugin{}, "placement") {
if err := migrator.DropColumn(&tables.TablePlugin{}, "placement"); err != nil {
return fmt.Errorf("failed to drop placement column: %w", err)
}
}
if migrator.HasColumn(&tables.TablePlugin{}, "exec_order") {
if err := migrator.DropColumn(&tables.TablePlugin{}, "exec_order"); err != nil {
return fmt.Errorf("failed to drop exec_order column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running add_plugin_order_columns migration: %s", err.Error())
}
return nil
}
// migrationMakeBasePricingColumnsNullable drops the NOT NULL constraint on
// input_cost_per_token and output_cost_per_token in governance_model_pricing,
// allowing models that only have non-token pricing (image, audio, video) to be
// stored without a placeholder zero value.
func migrationMakeBasePricingColumnsNullable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "make_base_pricing_columns_nullable",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
m := tx.Migrator()
if err := m.AlterColumn(&tables.TableModelPricing{}, "InputCostPerToken"); err != nil {
return fmt.Errorf("failed to alter input_cost_per_token: %w", err)
}
if err := m.AlterColumn(&tables.TableModelPricing{}, "OutputCostPerToken"); err != nil {
return fmt.Errorf("failed to alter output_cost_per_token: %w", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running make_base_pricing_columns_nullable migration: %s", err.Error())
}
return nil
}
// migrationAddAllowOnAllVirtualKeysColumn adds the allow_on_all_virtual_keys column to the mcp_client table
func migrationAddAllowOnAllVirtualKeysColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_allow_on_all_virtual_keys_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableMCPClient{}, "allow_on_all_virtual_keys") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "allow_on_all_virtual_keys"); err != nil {
return fmt.Errorf("failed to add allow_on_all_virtual_keys column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableMCPClient{}, "allow_on_all_virtual_keys") {
if err := migrator.DropColumn(&tables.TableMCPClient{}, "allow_on_all_virtual_keys"); err != nil {
return fmt.Errorf("failed to drop allow_on_all_virtual_keys column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running add_allow_on_all_virtual_keys_column migration: %s", err.Error())
}
return nil
}
// migrationAddOpenAIConfigJSONColumn adds the open_ai_config_json column to the provider table
func migrationAddOpenAIConfigJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_open_ai_config_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableProvider{}, "open_ai_config_json") {
if err := migrator.AddColumn(&tables.TableProvider{}, "OpenAIConfigJSON"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableProvider{}, "open_ai_config_json") {
if err := migrator.DropColumn(&tables.TableProvider{}, "open_ai_config_json"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running add_open_ai_config_json_column migration: %s", err.Error())
}
return nil
}
// migrationAddPromptVariablesColumns adds variables_json column to prompt_sessions and prompt_versions
func migrationAddPromptVariablesColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_prompt_variables_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TablePromptSession{}, "variables_json") {
if err := migrator.AddColumn(&tables.TablePromptSession{}, "VariablesJSON"); err != nil {
return fmt.Errorf("failed to add variables_json column to prompt_sessions: %w", err)
}
}
if !migrator.HasColumn(&tables.TablePromptVersion{}, "variables_json") {
if err := migrator.AddColumn(&tables.TablePromptVersion{}, "VariablesJSON"); err != nil {
return fmt.Errorf("failed to add variables_json column to prompt_versions: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TablePromptSession{}, "variables_json") {
if err := migrator.DropColumn(&tables.TablePromptSession{}, "variables_json"); err != nil {
return err
}
}
if migrator.HasColumn(&tables.TablePromptVersion{}, "variables_json") {
if err := migrator.DropColumn(&tables.TablePromptVersion{}, "variables_json"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running add_prompt_variables_columns migration: %s", err.Error())
}
return nil
}
// migrationAddKeyBlacklistedModelsJSONColumn adds blacklisted_models_json to config_keys
// for per-key model deny lists (JSON array of model ids, default []).
func migrationAddKeyBlacklistedModelsJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_key_blacklisted_models_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasColumn(&tables.TableKey{}, "blacklisted_models_json") {
if err := mg.AddColumn(&tables.TableKey{}, "blacklisted_models_json"); err != nil {
return fmt.Errorf("failed to add blacklisted_models_json column: %w", err)
}
}
if err := tx.Exec("UPDATE config_keys SET blacklisted_models_json = '[]' WHERE blacklisted_models_json IS NULL OR blacklisted_models_json = ''").Error; err != nil {
return fmt.Errorf("failed to backfill blacklisted_models_json: %w", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableKey{}, "blacklisted_models_json") {
if err := mg.DropColumn(&tables.TableKey{}, "blacklisted_models_json"); err != nil {
return fmt.Errorf("failed to drop blacklisted_models_json column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_key_blacklisted_models_json_column migration: %s", err.Error())
}
return nil
}
// migrationAddChainRuleColumnToRoutingRules adds chain_rule to routing_rules.
// When true, the routing engine re-evaluates the full rule set after this rule matches,
// using the resolved provider/model as the new context input.
func migrationAddChainRuleColumnToRoutingRules(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_chain_rule_column_to_routing_rules",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasColumn(&tables.TableRoutingRule{}, "chain_rule") {
if err := mg.AddColumn(&tables.TableRoutingRule{}, "chain_rule"); err != nil {
return fmt.Errorf("failed to add chain_rule column: %w", err)
}
}
// Backfill config_hash for all existing routing rules.
// GenerateRoutingRuleHash now includes chain_rule, so existing hashes
// (computed without it) are stale and must be recomputed to avoid
// every rule appearing as changed after this upgrade.
var rules []tables.TableRoutingRule
if err := tx.Preload("Targets").Find(&rules).Error; err != nil {
return fmt.Errorf("failed to load routing rules for config_hash backfill: %w", err)
}
for _, rule := range rules {
hash, err := GenerateRoutingRuleHash(rule)
if err != nil {
return fmt.Errorf("failed to generate config_hash for routing rule %s: %w", rule.ID, err)
}
if err := tx.Model(&tables.TableRoutingRule{}).Where("id = ?", rule.ID).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update config_hash for routing rule %s: %w", rule.ID, err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableRoutingRule{}, "chain_rule") {
if err := mg.DropColumn(&tables.TableRoutingRule{}, "chain_rule"); err != nil {
return fmt.Errorf("failed to drop chain_rule column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_chain_rule_column_to_routing_rules migration: %s", err.Error())
}
return nil
}
// migrationAddReplicateKeyConfigColumn adds the replicate_use_deployments_endpoint column to the key table
func migrationAddReplicateKeyConfigColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_replicate_key_config_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasColumn(&tables.TableKey{}, "replicate_use_deployments_endpoint") {
if err := mg.AddColumn(&tables.TableKey{}, "replicate_use_deployments_endpoint"); err != nil {
return err
}
// Backfill: Replicate keys that had deployments configured (now in aliases_json after
// migrationDropDeploymentColumnsAndAddAliases) were using the deployments endpoint.
trueVal := true
if err := tx.Model(&tables.TableKey{}).
Where("provider = ? AND aliases_json IS NOT NULL AND aliases_json != ? AND aliases_json != ?",
string(schemas.Replicate), "", "{}",
).
Update("ReplicateUseDeploymentsEndpoint", &trueVal).Error; err != nil {
return err
}
// Recompute config_hash for Replicate keys that were updated above,
// since replicate_use_deployments_endpoint is part of the hash input.
var affectedKeys []tables.TableKey
if err := tx.Where(
"provider = ? AND replicate_use_deployments_endpoint IS NOT NULL",
string(schemas.Replicate),
).Find(&affectedKeys).Error; err != nil {
return fmt.Errorf("failed to fetch replicate keys for hash recomputation: %w", err)
}
for _, key := range affectedKeys {
schemaKey := schemas.Key{
Name: key.Name,
Value: key.Value,
Models: key.Models,
BlacklistedModels: key.BlacklistedModels,
Weight: getWeight(key.Weight),
AzureKeyConfig: key.AzureKeyConfig,
VertexKeyConfig: key.VertexKeyConfig,
BedrockKeyConfig: key.BedrockKeyConfig,
Aliases: key.Aliases,
VLLMKeyConfig: key.VLLMKeyConfig,
ReplicateKeyConfig: key.ReplicateKeyConfig,
Enabled: key.Enabled,
UseForBatchAPI: key.UseForBatchAPI,
}
hash, err := GenerateKeyHash(schemaKey)
if err != nil {
return fmt.Errorf("failed to generate hash for key %s: %w", key.Name, err)
}
if err := tx.Model(&key).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update config_hash for key %s: %w", key.Name, err)
}
log.Printf("[Migration] Recomputed config_hash for replicate key '%s' after replicate config backfill", key.Name)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableKey{}, "replicate_use_deployments_endpoint") {
if err := mg.DropColumn(&tables.TableKey{}, "replicate_use_deployments_endpoint"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_replicate_key_config_column migration: %s", err.Error())
}
return nil
}
// migrationAddBudgetCalendarAlignedColumn was originally for adding calendar_aligned to governance_budgets.
// Calendar alignment is now a VK-level field (governance_virtual_keys.calendar_aligned) added in migrationAddMultiBudgetTables.
// This migration is kept as a no-op so the migrator doesn't try to re-run it.
func migrationAddBudgetCalendarAlignedColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_budget_calendar_aligned_column",
Migrate: func(tx *gorm.DB) error { return nil },
Rollback: func(tx *gorm.DB) error { return nil },
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_budget_calendar_aligned_column migration: %s", err.Error())
}
return nil
}
// migrationAddRoutingChainMaxDepthColumn adds routing_chain_max_depth to the client config table.
// Defaults to 10, which is the built-in default for routing rule chain evaluation depth.
func migrationAddRoutingChainMaxDepthColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_routing_chain_max_depth_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasColumn(&tables.TableClientConfig{}, "routing_chain_max_depth") {
if err := mg.AddColumn(&tables.TableClientConfig{}, "routing_chain_max_depth"); err != nil {
return fmt.Errorf("failed to add routing_chain_max_depth column: %w", err)
}
// Recompute config_hash for all existing client configs that have one.
// RoutingChainMaxDepth is now included in the hash (when > 0), so without
// this recompute the stored hash would mismatch on every startup after upgrade.
var clientConfigs []tables.TableClientConfig
if err := tx.Find(&clientConfigs).Error; err != nil {
return fmt.Errorf("failed to fetch client configs for hash recompute: %w", err)
}
for _, cc := range clientConfigs {
if cc.ConfigHash == "" {
continue // no stored hash to invalidate
}
depth := cc.RoutingChainMaxDepth
if depth == 0 {
// Should never happen, but just in case.
depth = 10 // DefaultRoutingChainMaxDepth
}
clientConfig := ClientConfig{
DropExcessRequests: cc.DropExcessRequests,
InitialPoolSize: cc.InitialPoolSize,
PrometheusLabels: cc.PrometheusLabels,
EnableLogging: cc.EnableLogging,
DisableContentLogging: cc.DisableContentLogging,
DisableDBPingsInHealth: cc.DisableDBPingsInHealth,
LogRetentionDays: cc.LogRetentionDays,
EnforceAuthOnInference: cc.EnforceAuthOnInference,
AllowDirectKeys: cc.AllowDirectKeys,
AllowedOrigins: cc.AllowedOrigins,
AllowedHeaders: cc.AllowedHeaders,
MaxRequestBodySizeMB: cc.MaxRequestBodySizeMB,
HideDeletedVirtualKeysInFilters: cc.HideDeletedVirtualKeysInFilters,
MCPAgentDepth: cc.MCPAgentDepth,
MCPToolExecutionTimeout: cc.MCPToolExecutionTimeout,
MCPCodeModeBindingLevel: cc.MCPCodeModeBindingLevel,
MCPToolSyncInterval: cc.MCPToolSyncInterval,
MCPDisableAutoToolInject: cc.MCPDisableAutoToolInject,
AsyncJobResultTTL: cc.AsyncJobResultTTL,
LoggingHeaders: cc.LoggingHeaders,
RequiredHeaders: cc.RequiredHeaders,
HeaderFilterConfig: cc.HeaderFilterConfig,
RoutingChainMaxDepth: depth,
}
newHash, err := clientConfig.GenerateClientConfigHash()
if err != nil {
return fmt.Errorf("failed to generate hash for client config %d: %w", cc.ID, err)
}
if err := tx.Model(&cc).Update("config_hash", newHash).Error; err != nil {
return fmt.Errorf("failed to update hash for client config %d: %w", cc.ID, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableClientConfig{}, "routing_chain_max_depth") {
if err := mg.DropColumn(&tables.TableClientConfig{}, "routing_chain_max_depth"); err != nil {
return fmt.Errorf("failed to drop routing_chain_max_depth column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_routing_chain_max_depth_column migration: %s", err.Error())
}
return nil
}
// migrationAddModelCapabilityColumns adds model capability metadata columns to governance_model_pricing.
func migrationAddModelCapabilityColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_model_capability_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"context_length",
"max_input_tokens",
"max_output_tokens",
"architecture",
}
for _, column := range columns {
if !mg.HasColumn(&tables.TableModelPricing{}, column) {
if err := mg.AddColumn(&tables.TableModelPricing{}, column); err != nil {
return fmt.Errorf("failed to add %s column: %w", column, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"context_length",
"max_input_tokens",
"max_output_tokens",
"architecture",
}
for _, column := range columns {
if mg.HasColumn(&tables.TableModelPricing{}, column) {
if err := mg.DropColumn(&tables.TableModelPricing{}, column); err != nil {
return fmt.Errorf("failed to drop %s column: %w", column, err)
}
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_model_capability_columns migration: %s", err.Error())
}
return nil
}
// migrationAddOllamaSGLConfigColumns adds ollama_url and sgl_url columns to the key table
func migrationAddOllamaSGLConfigColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_ollama_sgl_config_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableKey{}, "ollama_url") {
if err := migrator.AddColumn(&tables.TableKey{}, "ollama_url"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TableKey{}, "sgl_url") {
if err := migrator.AddColumn(&tables.TableKey{}, "sgl_url"); err != nil {
return err
}
}
// Backfill: for each ollama/sgl provider with a base_url, create a key
// with that URL and clear base_url from network_config.
var providers []tables.TableProvider
if err := tx.Where("name IN ?", []string{"ollama", "sgl"}).Find(&providers).Error; err != nil {
return fmt.Errorf("failed to fetch ollama/sgl providers for URL backfill: %w", err)
}
for _, p := range providers {
if p.NetworkConfigJSON == "" {
continue
}
var nc schemas.NetworkConfig
if err := json.Unmarshal([]byte(p.NetworkConfigJSON), &nc); err != nil {
log.Printf("[Migration] Failed to parse network_config for provider %s (id=%d), skipping: %v", p.Name, p.ID, err)
continue
}
if nc.BaseURL == "" {
continue
}
// Create a new key with the provider's base_url
urlEnvVar := schemas.EnvVar{Val: nc.BaseURL}
enabled := true
weight := 1.0
newKey := tables.TableKey{
Provider: p.Name,
ProviderID: p.ID,
KeyID: uuid.NewString(),
Weight: &weight,
Enabled: &enabled,
Models: schemas.WhiteList{"*"},
}
if strings.ToLower(p.Name) == "ollama" {
newKey.Name = "Default Ollama Key"
newKey.OllamaKeyConfig = &schemas.OllamaKeyConfig{URL: urlEnvVar}
}
if strings.ToLower(p.Name) == "sgl" {
newKey.Name = "Default SGL Key"
newKey.SGLKeyConfig = &schemas.SGLKeyConfig{URL: urlEnvVar}
}
schemaKey := schemaKeyFromTableKey(newKey)
hash, err := GenerateKeyHash(schemaKey)
if err != nil {
return fmt.Errorf("failed to generate hash for new key on provider %s: %w", p.Name, err)
}
newKey.ConfigHash = hash
if err := tx.Create(&newKey).Error; err != nil {
return fmt.Errorf("failed to create key for provider %s: %w", p.Name, err)
}
log.Printf("[Migration] Created key '%s' for provider '%s' from network_config.base_url", newKey.Name, p.Name)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableKey{}, "ollama_url") {
if err := migrator.DropColumn(&tables.TableKey{}, "ollama_url"); err != nil {
return err
}
}
if migrator.HasColumn(&tables.TableKey{}, "sgl_url") {
if err := migrator.DropColumn(&tables.TableKey{}, "sgl_url"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running ollama sgl key config columns migration: %s", err.Error())
}
return nil
}
// migrationAddMultiBudgetTables creates junction tables for multi-budget support and backfills existing data.
func migrationAddMultiBudgetTables(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_multi_budget_tables",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
// Add calendar_aligned to governance_virtual_keys (VK-level setting)
if !mg.HasColumn(&tables.TableVirtualKey{}, "calendar_aligned") {
if err := mg.AddColumn(&tables.TableVirtualKey{}, "CalendarAligned"); err != nil {
return fmt.Errorf("failed to add calendar_aligned column to governance_virtual_keys: %w", err)
}
}
// Add FK columns on governance_budgets for multi-budget ownership
if !mg.HasColumn(&tables.TableBudget{}, "virtual_key_id") {
if err := mg.AddColumn(&tables.TableBudget{}, "VirtualKeyID"); err != nil {
return fmt.Errorf("failed to add virtual_key_id column to governance_budgets: %w", err)
}
}
if !mg.HasColumn(&tables.TableBudget{}, "provider_config_id") {
if err := mg.AddColumn(&tables.TableBudget{}, "ProviderConfigID"); err != nil {
return fmt.Errorf("failed to add provider_config_id column to governance_budgets: %w", err)
}
}
// Create indexes on the new FK columns (AddColumn doesn't create indexes from struct tags)
if !mg.HasIndex(&tables.TableBudget{}, "idx_governance_budgets_virtual_key_id") {
if err := mg.CreateIndex(&tables.TableBudget{}, "VirtualKeyID"); err != nil {
return fmt.Errorf("failed to create index on governance_budgets.virtual_key_id: %w", err)
}
}
if !mg.HasIndex(&tables.TableBudget{}, "idx_governance_budgets_provider_config_id") {
if err := mg.CreateIndex(&tables.TableBudget{}, "ProviderConfigID"); err != nil {
return fmt.Errorf("failed to create index on governance_budgets.provider_config_id: %w", err)
}
}
// Backfill: set virtual_key_id from legacy VK budget_id (if column still exists)
if mg.HasColumn(&tables.TableVirtualKey{}, "budget_id") {
if err := tx.Exec(`
UPDATE governance_budgets SET virtual_key_id = (
SELECT id FROM governance_virtual_keys
WHERE governance_virtual_keys.budget_id = governance_budgets.id
) WHERE virtual_key_id IS NULL AND EXISTS (
SELECT 1 FROM governance_virtual_keys
WHERE governance_virtual_keys.budget_id = governance_budgets.id
)
`).Error; err != nil {
return fmt.Errorf("failed to backfill VK budget virtual_key_id: %w", err)
}
}
// Backfill: set provider_config_id from legacy PC budget_id (if column still exists)
if mg.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id") {
if err := tx.Exec(`
UPDATE governance_budgets SET provider_config_id = (
SELECT id FROM governance_virtual_key_provider_configs
WHERE governance_virtual_key_provider_configs.budget_id = governance_budgets.id
) WHERE provider_config_id IS NULL AND EXISTS (
SELECT 1 FROM governance_virtual_key_provider_configs
WHERE governance_virtual_key_provider_configs.budget_id = governance_budgets.id
)
`).Error; err != nil {
return fmt.Errorf("failed to backfill PC budget provider_config_id: %w", err)
}
}
// Backfill: copy calendar_aligned from legacy budget column to VK-level field
// (governance_budgets.calendar_aligned was added by add_budget_calendar_aligned_column on main)
if mg.HasColumn(&tables.TableBudget{}, "calendar_aligned") {
if err := tx.Exec(`
UPDATE governance_virtual_keys SET calendar_aligned = true
WHERE id IN (
SELECT DISTINCT virtual_key_id FROM governance_budgets
WHERE calendar_aligned = true AND virtual_key_id IS NOT NULL
) AND calendar_aligned = false
`).Error; err != nil {
return fmt.Errorf("failed to backfill calendar_aligned from budgets to virtual keys: %w", err)
}
// Drop the legacy calendar_aligned column from governance_budgets.
// Plain column with no FK references — not a correctness risk if left behind,
// but log a warning so it's not invisible.
if err := tx.Exec("ALTER TABLE governance_budgets DROP COLUMN IF EXISTS calendar_aligned").Error; err != nil {
log.Printf("[Migration] warning: could not drop legacy calendar_aligned column from governance_budgets: %v", err)
}
}
// Drop legacy budget_id columns BEFORE creating FK constraints.
// On SQLite, ALTER TABLE RENAME propagates into FK references in other tables.
// If we create FK constraints on governance_budgets first, then rename the
// parent table during the legacy column drop (table rebuild), SQLite updates
// those FK references to point at the temporary backup table name.
if err := dropLegacyBudgetColumn(tx, "governance_virtual_keys"); err != nil {
return err
}
if err := dropLegacyBudgetColumn(tx, "governance_virtual_key_provider_configs"); err != nil {
return err
}
// Create FK constraints with CASCADE delete (defined on parent structs).
// Must happen after legacy column drops so SQLite rename propagation
// cannot corrupt these FK references.
if !mg.HasConstraint(&tables.TableVirtualKey{}, "Budgets") {
if err := mg.CreateConstraint(&tables.TableVirtualKey{}, "Budgets"); err != nil {
return fmt.Errorf("failed to create FK constraint for VirtualKey -> Budgets: %w", err)
}
}
if !mg.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budgets") {
if err := mg.CreateConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budgets"); err != nil {
return fmt.Errorf("failed to create FK constraint for ProviderConfig -> Budgets: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableBudget{}, "virtual_key_id") {
if err := mg.DropColumn(&tables.TableBudget{}, "virtual_key_id"); err != nil {
return err
}
}
if mg.HasColumn(&tables.TableBudget{}, "provider_config_id") {
if err := mg.DropColumn(&tables.TableBudget{}, "provider_config_id"); err != nil {
return err
}
}
return nil
},
}})
// SQLite workaround: GORM's CreateConstraint rebuilds the table via DROP+RENAME
// inside a transaction. The DROP fails when other tables have FKs pointing at the
// target table and foreign_keys is ON. PRAGMA foreign_keys cannot be changed inside
// a transaction, so we disable it before the migrator opens its transaction.
// This only affects SQLite — Postgres supports ALTER TABLE ADD CONSTRAINT natively.
if db.Dialector.Name() == "sqlite" {
// PRAGMA foreign_keys is per-connection in SQLite. Pin the pool to a single
// connection so the PRAGMA and the migration transaction share the same one.
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) // restore default
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_multi_budget_tables migration: %s", err.Error())
}
return nil
}
// migrationAddTeamBudgetsToBudgetsTable pivots team budgets from a single-FK on
// governance_teams.budget_id to multi-budget ownership via governance_budgets.team_id,
// mirroring how VK/ProviderConfig budgets were restructured in migrationAddMultiBudgetTables.
func migrationAddTeamBudgetsToBudgetsTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_team_budgets_to_budgets_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
// Add team_id FK column on governance_budgets
if !mg.HasColumn(&tables.TableBudget{}, "team_id") {
if err := mg.AddColumn(&tables.TableBudget{}, "TeamID"); err != nil {
return fmt.Errorf("failed to add team_id column to governance_budgets: %w", err)
}
}
// Create index on the new FK column (AddColumn doesn't create indexes from struct tags)
if !mg.HasIndex(&tables.TableBudget{}, "idx_governance_budgets_team_id") {
if err := mg.CreateIndex(&tables.TableBudget{}, "TeamID"); err != nil {
return fmt.Errorf("failed to create index on governance_budgets.team_id: %w", err)
}
}
// Backfill: set team_id from legacy governance_teams.budget_id (if column still exists)
if mg.HasColumn(&tables.TableTeam{}, "budget_id") {
// Preflight: raw SQL below bypasses TableBudget.BeforeSave (which now
// enforces exactly-one-of {TeamID, VirtualKeyID, ProviderConfigID}).
// Fail fast if any team-referenced budget is already owned by a VK or
// ProviderConfig, rather than silently producing a multi-owner row
// that would later be rejected by the hook on its next update.
var conflictCount int64
if err := tx.Raw(`
SELECT COUNT(*) FROM governance_budgets b
WHERE (b.virtual_key_id IS NOT NULL OR b.provider_config_id IS NOT NULL)
AND EXISTS (SELECT 1 FROM governance_teams t WHERE t.budget_id = b.id)
`).Scan(&conflictCount).Error; err != nil {
return fmt.Errorf("failed to check for multi-owner team budget conflicts: %w", err)
}
if conflictCount > 0 {
return fmt.Errorf(
"cannot migrate team budgets: %d budget row(s) referenced by a team are already owned by a virtual key or provider config; resolve manually before re-running",
conflictCount,
)
}
if err := tx.Exec(`
UPDATE governance_budgets SET team_id = (
SELECT id FROM governance_teams
WHERE governance_teams.budget_id = governance_budgets.id
) WHERE team_id IS NULL AND EXISTS (
SELECT 1 FROM governance_teams
WHERE governance_teams.budget_id = governance_budgets.id
)
`).Error; err != nil {
return fmt.Errorf("failed to backfill team budget team_id: %w", err)
}
// Drop legacy budget_id column BEFORE creating FK constraint.
// On SQLite, ALTER TABLE RENAME propagates into FK references in other
// tables. Dropping first prevents the FK on governance_budgets.team_id
// from being corrupted by the table rebuild's rename step.
if err := dropLegacyBudgetColumn(tx, "governance_teams"); err != nil {
return err
}
}
// Create FK constraint with CASCADE delete (defined on TableTeam.Budgets).
// Must happen after legacy column drop so SQLite rename propagation
// cannot corrupt this FK reference.
if !mg.HasConstraint(&tables.TableTeam{}, "Budgets") {
if err := mg.CreateConstraint(&tables.TableTeam{}, "Budgets"); err != nil {
return fmt.Errorf("failed to create FK constraint for Team -> Budgets: %w", err)
}
}
// Refresh config_hash for teams whose budgets just got linked. GenerateTeamHash
// now includes sorted budget IDs, so hashes written by the earlier
// migrationAddConfigHashColumn (which ran before budgets were associated)
// are stale and would cause phantom drift on the next config.json sync.
var teamsToRehash []tables.TableTeam
if err := tx.Preload("Budgets").Find(&teamsToRehash).Error; err != nil {
return fmt.Errorf("failed to fetch teams for hash refresh: %w", err)
}
for _, team := range teamsToRehash {
if len(team.Budgets) == 0 {
continue // hash did not change; skip
}
hash, err := GenerateTeamHash(team)
if err != nil {
return fmt.Errorf("failed to generate hash for team %s: %w", team.ID, err)
}
if err := tx.Model(&tables.TableTeam{}).Where("id = ?", team.ID).Update("config_hash", hash).Error; err != nil {
return fmt.Errorf("failed to update hash for team %s: %w", team.ID, err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasColumn(&tables.TableBudget{}, "team_id") {
if err := mg.DropColumn(&tables.TableBudget{}, "team_id"); err != nil {
return err
}
}
return nil
},
}})
// SQLite workaround — same reasoning as migrationAddMultiBudgetTables.
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_team_budgets_to_budgets_table 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{{
ID: "add_per_user_oauth_tables",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasTable(&tables.TablePerUserOAuthClient{}) {
if err := mg.CreateTable(&tables.TablePerUserOAuthClient{}); err != nil {
return fmt.Errorf("failed to create oauth_per_user_clients table: %w", err)
}
}
if !mg.HasTable(&tables.TablePerUserOAuthSession{}) {
if err := mg.CreateTable(&tables.TablePerUserOAuthSession{}); err != nil {
return fmt.Errorf("failed to create oauth_per_user_sessions table: %w", err)
}
}
if !mg.HasTable(&tables.TablePerUserOAuthCode{}) {
if err := mg.CreateTable(&tables.TablePerUserOAuthCode{}); err != nil {
return fmt.Errorf("failed to create oauth_per_user_codes table: %w", err)
}
}
if !mg.HasTable(&tables.TableOauthUserToken{}) {
if err := mg.CreateTable(&tables.TableOauthUserToken{}); err != nil {
return fmt.Errorf("failed to create oauth_user_tokens table: %w", err)
}
}
if !mg.HasTable(&tables.TableOauthUserSession{}) {
if err := mg.CreateTable(&tables.TableOauthUserSession{}); err != nil {
return fmt.Errorf("failed to create oauth_user_sessions table: %w", err)
}
}
if !mg.HasTable(&tables.TablePerUserOAuthPendingFlow{}) {
if err := mg.CreateTable(&tables.TablePerUserOAuthPendingFlow{}); err != nil {
return fmt.Errorf("failed to create oauth_per_user_pending_flows table: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
for _, table := range []any{
&tables.TablePerUserOAuthPendingFlow{},
&tables.TablePerUserOAuthCode{},
&tables.TablePerUserOAuthSession{},
&tables.TablePerUserOAuthClient{},
&tables.TableOauthUserToken{},
&tables.TableOauthUserSession{},
} {
if mg.HasTable(table) {
if err := mg.DropTable(table); err != nil {
return err
}
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_per_user_oauth_tables migration: %s", err.Error())
}
return nil
}
// migrationAddMCPClientDiscoveredToolsColumns adds discovered_tools_json and tool_name_mapping_json columns to the mcp_client table
func migrationAddMCPClientDiscoveredToolsColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_mcp_client_discovered_tools_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableMCPClient{}, "discovered_tools_json") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "discovered_tools_json"); err != nil {
return err
}
}
if !migrator.HasColumn(&tables.TableMCPClient{}, "tool_name_mapping_json") {
if err := migrator.AddColumn(&tables.TableMCPClient{}, "tool_name_mapping_json"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableMCPClient{}, "discovered_tools_json") {
if err := migrator.DropColumn(&tables.TableMCPClient{}, "discovered_tools_json"); err != nil {
return err
}
}
if migrator.HasColumn(&tables.TableMCPClient{}, "tool_name_mapping_json") {
if err := migrator.DropColumn(&tables.TableMCPClient{}, "tool_name_mapping_json"); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_mcp_client_discovered_tools_columns migration: %s", err.Error())
}
return nil
}
// migrationAddPriorityTierPricingColumns adds pricing columns for the 272k token tier
// and the 200k priority variants.
func migrationAddPriorityTierPricingColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_priority_tier_pricing_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"input_cost_per_token_above_272k_tokens",
"input_cost_per_token_above_272k_tokens_priority",
"output_cost_per_token_above_272k_tokens",
"output_cost_per_token_above_272k_tokens_priority",
"cache_read_input_token_cost_above_272k_tokens",
"cache_read_input_token_cost_above_272k_tokens_priority",
"input_cost_per_token_above_200k_tokens_priority",
"output_cost_per_token_above_200k_tokens_priority",
"cache_read_input_token_cost_above_200k_tokens_priority",
}
for _, field := range columns {
if !mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.AddColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to add column %s: %w", field, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"input_cost_per_token_above_272k_tokens",
"input_cost_per_token_above_272k_tokens_priority",
"output_cost_per_token_above_272k_tokens",
"output_cost_per_token_above_272k_tokens_priority",
"cache_read_input_token_cost_above_272k_tokens",
"cache_read_input_token_cost_above_272k_tokens_priority",
"input_cost_per_token_above_200k_tokens_priority",
"output_cost_per_token_above_200k_tokens_priority",
"cache_read_input_token_cost_above_200k_tokens_priority",
}
for _, field := range columns {
if mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.DropColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to drop column %s: %w", field, err)
}
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running priority tier pricing columns migration: %s", err.Error())
}
return nil
}
// migrationAddFlexTierPricingColumns adds pricing columns for the flex service tier
func migrationAddFlexTierPricingColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_flex_tier_pricing_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"input_cost_per_token_flex",
"output_cost_per_token_flex",
"cache_read_input_token_cost_flex",
}
for _, field := range columns {
if !mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.AddColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to add column %s: %w", field, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"input_cost_per_token_flex",
"output_cost_per_token_flex",
"cache_read_input_token_cost_flex",
}
for _, field := range columns {
if mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.DropColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to drop column %s: %w", field, err)
}
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running flex tier pricing columns migration: %s", err.Error())
}
return nil
}
// migrationAddWhitelistedRoutesJSONColumn adds the whitelisted_routes_json column to the config_client table
func migrationAddWhitelistedRoutesJSONColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_whitelisted_routes_json_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if !migrator.HasColumn(&tables.TableClientConfig{}, "whitelisted_routes_json") {
if err := migrator.AddColumn(&tables.TableClientConfig{}, "WhitelistedRoutesJSON"); err != nil {
return fmt.Errorf("failed to add whitelisted_routes_json column: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
migrator := tx.Migrator()
if migrator.HasColumn(&tables.TableClientConfig{}, "whitelisted_routes_json") {
if err := migrator.DropColumn(&tables.TableClientConfig{}, "whitelisted_routes_json"); err != nil {
return fmt.Errorf("failed to drop whitelisted_routes_json column: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running whitelisted_routes_json migration: %s", err.Error())
}
return nil
}
// migrationReplaceEnableLiteLLMWithCompatColumns replaces the single enable_litellm_fallbacks
// boolean with compat feature columns. If enable_litellm_fallbacks was true,
// only convert_text_to_chat is set to true (preserving the original behavior).
func migrationReplaceEnableLiteLLMWithCompatColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "replace_enable_litellm_with_compat_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mig := tx.Migrator()
// Add new columns
if !mig.HasColumn(&tables.TableClientConfig{}, "compat_convert_text_to_chat") {
if err := mig.AddColumn(&tables.TableClientConfig{}, "compat_convert_text_to_chat"); err != nil {
return err
}
}
if !mig.HasColumn(&tables.TableClientConfig{}, "compat_convert_chat_to_responses") {
if err := mig.AddColumn(&tables.TableClientConfig{}, "compat_convert_chat_to_responses"); err != nil {
return err
}
}
if !mig.HasColumn(&tables.TableClientConfig{}, "compat_should_drop_params") {
if err := mig.AddColumn(&tables.TableClientConfig{}, "compat_should_drop_params"); err != nil {
return err
}
}
if !mig.HasColumn(&tables.TableClientConfig{}, "compat_should_convert_params") {
if err := mig.AddColumn(&tables.TableClientConfig{}, "compat_should_convert_params"); err != nil {
return err
}
}
if err := tx.Exec("UPDATE config_client SET compat_should_convert_params = FALSE").Error; err != nil {
return err
}
// Migrate data: if enable_litellm_fallbacks was true, set convert_text_to_chat = true
if mig.HasColumn(&tables.TableClientConfig{}, "enable_litellm_fallbacks") {
if err := tx.Exec("UPDATE config_client SET compat_convert_text_to_chat = enable_litellm_fallbacks").Error; err != nil {
return err
}
if err := mig.DropColumn(&tables.TableClientConfig{}, "enable_litellm_fallbacks"); err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mig := tx.Migrator()
if tx.Migrator().HasColumn(&tables.TableClientConfig{}, "enable_litellm_fallbacks") {
if err := tx.Exec("ALTER TABLE config_client ADD COLUMN enable_litellm_fallbacks BOOLEAN DEFAULT FALSE").Error; err != nil {
return err
}
}
if mig.HasColumn(&tables.TableClientConfig{}, "compat_convert_text_to_chat") {
if err := tx.Exec("UPDATE config_client SET enable_litellm_fallbacks = COALESCE(compat_convert_text_to_chat, FALSE)").Error; err != nil {
return err
}
}
for _, col := range []string{
"compat_convert_text_to_chat",
"compat_convert_chat_to_responses",
"compat_should_drop_params",
"compat_should_convert_params",
} {
if mig.HasColumn(&tables.TableClientConfig{}, col) {
if err := mig.DropColumn(&tables.TableClientConfig{}, col); err != nil {
return err
}
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running replace_enable_litellm_with_compat_columns migration: %s", err.Error())
}
return nil
}
// migrationDefaultCompatShouldConvertParamsFalse ensures existing deployments
// converge to the new default for compat_should_convert_params. The earlier
// compat migration may already be marked as applied, so changing its body is not
// sufficient for installed databases.
func migrationDefaultCompatShouldConvertParamsFalse(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "default_compat_should_convert_params_false",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mig := tx.Migrator()
if !mig.HasColumn(&tables.TableClientConfig{}, "compat_should_convert_params") {
return nil
}
if err := tx.Exec("UPDATE config_client SET compat_should_convert_params = FALSE").Error; err != nil {
return err
}
if err := mig.AlterColumn(&tables.TableClientConfig{}, "CompatShouldConvertParams"); err != nil {
return fmt.Errorf("failed to alter compat_should_convert_params default: %w", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mig := tx.Migrator()
if !mig.HasColumn(&tables.TableClientConfig{}, "compat_should_convert_params") {
return nil
}
switch tx.Dialector.Name() {
case "postgres":
if err := tx.Exec("ALTER TABLE config_client ALTER COLUMN compat_should_convert_params SET DEFAULT FALSE").Error; err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running default_compat_should_convert_params_false migration: %s", err.Error())
}
return nil
}
// migrationAddModelPricingUniqueIndex ensures the composite unique index (model, provider, mode)
// exists on governance_model_pricing so that atomic ON CONFLICT upserts work correctly.
func migrationAddModelPricingUniqueIndex(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_model_pricing_unique_index",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
// Remove duplicate rows before creating the unique index.
// The old find-then-insert path could have produced duplicates on
// multinode deployments, and CREATE UNIQUE INDEX will fail on a table
// that still contains them. Keep the row with the lowest ID for each
// (model, provider, mode) combination.
result := tx.Exec(`
DELETE FROM governance_model_pricing
WHERE id NOT IN (
SELECT MIN(id)
FROM governance_model_pricing
GROUP BY model, provider, mode
)
`)
if result.Error != nil {
return fmt.Errorf("failed to deduplicate model pricing rows: %w", result.Error)
}
if result.RowsAffected > 0 {
log.Printf("[migration] removed %d duplicate row(s) from governance_model_pricing before creating unique index", result.RowsAffected)
}
if !mg.HasIndex(&tables.TableModelPricing{}, "idx_model_provider_mode") {
if err := mg.CreateIndex(&tables.TableModelPricing{}, "idx_model_provider_mode"); err != nil {
return fmt.Errorf("failed to create unique index idx_model_provider_mode: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if mg.HasIndex(&tables.TableModelPricing{}, "idx_model_provider_mode") {
if err := mg.DropIndex(&tables.TableModelPricing{}, "idx_model_provider_mode"); err != nil {
return fmt.Errorf("failed to drop unique index idx_model_provider_mode: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_model_pricing_unique_index migration: %s", err.Error())
}
return nil
}
// migrationNormalizeOtelTraceType rewrites the legacy OTEL plugin trace_type value "otel" to "genai_extension".
// No-op if the plugin row is missing or trace_type is already correct.
func migrationNormalizeOtelTraceType(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "normalize_otel_trace_type",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
var plugin tables.TablePlugin
err := tx.Where("name = ?", "otel").First(&plugin).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return fmt.Errorf("failed to load otel plugin row: %w", err)
}
cfgMap, ok := plugin.Config.(map[string]any)
if !ok || len(cfgMap) == 0 {
return nil
}
if tt, _ := cfgMap["trace_type"].(string); tt != "otel" {
return nil
}
cfgMap["trace_type"] = "genai_extension"
plugin.Config = cfgMap
plugin.ConfigJSON = ""
plugin.EncryptionStatus = tables.EncryptionStatusPlainText
if err := tx.Save(&plugin).Error; err != nil {
return fmt.Errorf("failed to save normalized otel config: %w", err)
}
log.Printf("[Migration] Normalized otel trace_type 'otel' to 'genai_extension'")
return nil
},
Rollback: func(tx *gorm.DB) error { return nil },
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running normalize_otel_trace_type migration: %s", err.Error())
}
return nil
}
// migrateCalendarAlignedToBudgetsAndRateLimitsTable
func migrateCalendarAlignedToBudgetsAndRateLimitsTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "migrate_calendar_aligned",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mig := tx.Migrator()
// Adding columns first
if !mig.HasColumn(&tables.TableBudget{}, "calendar_aligned") {
if err := mig.AddColumn(&tables.TableBudget{}, "calendar_aligned"); err != nil {
return fmt.Errorf("failed to add calendar_aligned column to budgets: %w", err)
}
}
// Adding columns first
if !mig.HasColumn(&tables.TableRateLimit{}, "calendar_aligned") {
if err := mig.AddColumn(&tables.TableRateLimit{}, "calendar_aligned"); err != nil {
return fmt.Errorf("failed to add calendar_aligned column to rate_limits: %w", err)
}
}
// Prefill calendar_aligned for existing budgets and rate_limits attached to virtual keys.
// Use subquery-based raw SQL (compatible with both PostgreSQL and SQLite) to avoid
// "cached plan must not change result type" (SQLSTATE 0A000): earlier migrations in
// the same run added columns to these tables, invalidating pgx's prepared-statement cache.
if err := tx.Exec(`
UPDATE governance_rate_limits
SET calendar_aligned = true
WHERE id IN (
SELECT rate_limit_id FROM governance_virtual_keys
WHERE calendar_aligned = true AND rate_limit_id IS NOT NULL
)
`).Error; err != nil {
return fmt.Errorf("failed to propagate calendar_aligned to rate limits: %w", err)
}
if err := tx.Exec(`
UPDATE governance_budgets
SET calendar_aligned = true
WHERE virtual_key_id IN (
SELECT id FROM governance_virtual_keys WHERE calendar_aligned = true
)
`).Error; err != nil {
return fmt.Errorf("failed to propagate calendar_aligned to budgets: %w", err)
}
log.Printf("[Migration] Prefilled calendar_aligned field for existing budgets and rate limits")
return nil
},
Rollback: func(tx *gorm.DB) error { return nil },
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running migrate_calendar_aligned migration: %s", err.Error())
}
return nil
}
func migrationAddOCRPricingColumns(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_ocr_pricing_columns",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"ocr_cost_per_page",
"annotation_cost_per_page",
}
for _, field := range columns {
if !mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.AddColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to add column %s: %w", field, err)
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
columns := []string{
"ocr_cost_per_page",
"annotation_cost_per_page",
}
for _, field := range columns {
if mg.HasColumn(&tables.TableModelPricing{}, field) {
if err := mg.DropColumn(&tables.TableModelPricing{}, field); err != nil {
return fmt.Errorf("failed to drop column %s: %w", field, err)
}
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_ocr_pricing_columns migration: %s", err.Error())
}
return nil
}