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

1240 lines
42 KiB
Go

package configstore
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"maps"
"sort"
"strconv"
"github.com/bytedance/sonic"
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/configstore/tables"
)
type EnvKeyType string
const (
EnvKeyTypeAPIKey EnvKeyType = "api_key"
EnvKeyTypeAzureConfig EnvKeyType = "azure_config"
EnvKeyTypeVertexConfig EnvKeyType = "vertex_config"
EnvKeyTypeBedrockConfig EnvKeyType = "bedrock_config"
EnvKeyTypeConnection EnvKeyType = "connection_string"
EnvKeyTypeMCPHeader EnvKeyType = "mcp_header"
)
// EnvKeyInfo stores information about a key sourced from environment
type EnvKeyInfo struct {
EnvVar string // The environment variable name (without env. prefix)
Provider schemas.ModelProvider // The provider this key belongs to (empty for core/mcp configs)
KeyType EnvKeyType // Type of key (e.g., "api_key", "azure_config", "vertex_config", "bedrock_config", "connection_string", "mcp_header")
ConfigPath string // Path in config where this env var is used
KeyID string // The key ID this env var belongs to (empty for non-key configs like bedrock_config, connection_string)
}
// CompatConfig holds the compat plugin feature flags.
type CompatConfig struct {
ConvertTextToChat bool `json:"convert_text_to_chat"`
ConvertChatToResponses bool `json:"convert_chat_to_responses"`
ShouldDropParams bool `json:"should_drop_params"`
ShouldConvertParams bool `json:"should_convert_params"`
}
// ClientConfig represents the core configuration for Bifrost HTTP transport and the Bifrost Client.
// It includes settings for excess request handling, Prometheus metrics, and initial pool size.
type ClientConfig struct {
DropExcessRequests bool `json:"drop_excess_requests"` // Drop excess requests if the provider queue is full
InitialPoolSize int `json:"initial_pool_size"` // The initial pool size for the bifrost client
PrometheusLabels []string `json:"prometheus_labels"` // The labels to be used for prometheus metrics
EnableLogging *bool `json:"enable_logging"` // Enable logging of requests and responses
DisableContentLogging bool `json:"disable_content_logging"` // Disable logging of content
DisableDBPingsInHealth bool `json:"disable_db_pings_in_health"`
LogRetentionDays int `json:"log_retention_days" validate:"min=1"` // Number of days to retain logs (minimum 1 day)
EnforceAuthOnInference bool `json:"enforce_auth_on_inference"` // Require auth (VK, API key, or user token) on inference endpoints
EnforceGovernanceHeader bool `json:"enforce_governance_header,omitempty"` // Deprecated: use EnforceAuthOnInference
EnforceSCIMAuth bool `json:"enforce_scim_auth,omitempty"` // Deprecated: use EnforceAuthOnInference
AllowDirectKeys bool `json:"allow_direct_keys"` // Allow direct keys to be used for requests
AllowedOrigins []string `json:"allowed_origins,omitempty"` // Additional allowed origins for CORS and WebSocket (localhost is always allowed)
AllowedHeaders []string `json:"allowed_headers,omitempty"` // Additional allowed headers for CORS and WebSocket
MaxRequestBodySizeMB int `json:"max_request_body_size_mb"` // The maximum request body size in MB
Compat CompatConfig `json:"compat"` // Compat plugin configuration
MCPAgentDepth int `json:"mcp_agent_depth"` // The maximum depth for MCP agent mode tool execution
MCPToolExecutionTimeout int `json:"mcp_tool_execution_timeout"` // The timeout for individual tool execution in seconds
MCPCodeModeBindingLevel string `json:"mcp_code_mode_binding_level"` // Code mode binding level: "server" or "tool"
MCPToolSyncInterval int `json:"mcp_tool_sync_interval"` // Global tool sync interval in minutes (default: 10, 0 = disabled)
MCPDisableAutoToolInject bool `json:"mcp_disable_auto_tool_inject"` // When true, MCP tools are not injected into requests by default
HeaderFilterConfig *tables.GlobalHeaderFilterConfig `json:"header_filter_config,omitempty"` // Global header filtering configuration for x-bf-eh-* headers
AsyncJobResultTTL int `json:"async_job_result_ttl"` // Default TTL for async job results in seconds (default: 3600 = 1 hour)
RequiredHeaders []string `json:"required_headers,omitempty"` // Headers that must be present on every request (case-insensitive)
LoggingHeaders []string `json:"logging_headers,omitempty"` // Headers to capture in log metadata
WhitelistedRoutes []string `json:"whitelisted_routes,omitempty"` // Routes that bypass auth middleware
HideDeletedVirtualKeysInFilters bool `json:"hide_deleted_virtual_keys_in_filters"` // Hide deleted virtual keys from logs/MCP filter data
RoutingChainMaxDepth int `json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10)
ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized)
}
// GenerateClientConfigHash generates a SHA256 hash of the client configuration.
// This is used to detect changes between config.json and database config.
func (c *ClientConfig) GenerateClientConfigHash() (string, error) {
hash := sha256.New()
// Hash boolean fields
if c.DropExcessRequests {
hash.Write([]byte("dropExcessRequests:true"))
} else {
hash.Write([]byte("dropExcessRequests:false"))
}
enableLogging := c.EnableLogging == nil || *c.EnableLogging
if enableLogging {
hash.Write([]byte("enableLogging:true"))
} else {
hash.Write([]byte("enableLogging:false"))
}
if c.DisableContentLogging {
hash.Write([]byte("disableContentLogging:true"))
} else {
hash.Write([]byte("disableContentLogging:false"))
}
if c.DisableDBPingsInHealth {
hash.Write([]byte("disableDBPingsInHealth:true"))
} else {
hash.Write([]byte("disableDBPingsInHealth:false"))
}
if c.EnforceAuthOnInference {
hash.Write([]byte("enforceAuthOnInference:true"))
} else {
hash.Write([]byte("enforceAuthOnInference:false"))
}
if c.AllowDirectKeys {
hash.Write([]byte("allowDirectKeys:true"))
} else {
hash.Write([]byte("allowDirectKeys:false"))
}
if c.Compat.ConvertTextToChat {
hash.Write([]byte("compatConvertTextToChat:true"))
}
if c.Compat.ConvertChatToResponses {
hash.Write([]byte("compatConvertChatToResponses:true"))
}
if c.Compat.ShouldDropParams {
hash.Write([]byte("compatShouldDropParams:true"))
}
if c.Compat.ShouldConvertParams {
hash.Write([]byte("compatShouldConvertParams:true"))
}
// Only hash non-default value to avoid legacy config hash churn.
if c.HideDeletedVirtualKeysInFilters {
hash.Write([]byte("hideDeletedVirtualKeysInFilters:true"))
}
// Always hash when non-zero — explicitly setting the default (10) is a meaningful
// config change that should be reflected in the hash. The migration that introduces
// this field backfills existing rows with RoutingChainMaxDepth=10 and regenerates
// their config_hash so there is no hash churn on upgrade for unmodified configs.
if c.RoutingChainMaxDepth > 0 {
hash.Write([]byte("routingChainMaxDepth:" + strconv.Itoa(c.RoutingChainMaxDepth)))
}
if c.MCPAgentDepth > 0 {
hash.Write([]byte("mcpAgentDepth:" + strconv.Itoa(c.MCPAgentDepth)))
} else {
hash.Write([]byte("mcpAgentDepth:0"))
}
if c.MCPToolExecutionTimeout > 0 {
hash.Write([]byte("mcpToolExecutionTimeout:" + strconv.Itoa(c.MCPToolExecutionTimeout)))
} else {
hash.Write([]byte("mcpToolExecutionTimeout:0"))
}
if c.MCPCodeModeBindingLevel != "" {
hash.Write([]byte("mcpCodeModeBindingLevel:" + c.MCPCodeModeBindingLevel))
} else {
hash.Write([]byte("mcpCodeModeBindingLevel:server"))
}
if c.MCPToolSyncInterval > 0 {
hash.Write([]byte("mcpToolSyncInterval:" + strconv.Itoa(c.MCPToolSyncInterval)))
} else {
hash.Write([]byte("mcpToolSyncInterval:0"))
}
// Only hash non-default value to avoid legacy config hash churn on upgrade.
if c.MCPDisableAutoToolInject {
hash.Write([]byte("mcpDisableAutoToolInject:true"))
}
if c.AsyncJobResultTTL > 0 {
hash.Write([]byte("asyncJobResultTTL:" + strconv.Itoa(c.AsyncJobResultTTL)))
} else {
hash.Write([]byte("asyncJobResultTTL:0"))
}
// Hash integer fields
data, err := sonic.Marshal(c.InitialPoolSize)
if err != nil {
return "", err
}
hash.Write(data)
data, err = sonic.Marshal(c.LogRetentionDays)
if err != nil {
return "", err
}
hash.Write(data)
data, err = sonic.Marshal(c.MaxRequestBodySizeMB)
if err != nil {
return "", err
}
hash.Write(data)
// Hash PrometheusLabels (sorted for deterministic hashing)
if len(c.PrometheusLabels) > 0 {
sortedLabels := make([]string, len(c.PrometheusLabels))
copy(sortedLabels, c.PrometheusLabels)
sort.Strings(sortedLabels)
data, err := sonic.Marshal(sortedLabels)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash AllowedOrigins (sorted for deterministic hashing)
if len(c.AllowedOrigins) > 0 {
sortedOrigins := make([]string, len(c.AllowedOrigins))
copy(sortedOrigins, c.AllowedOrigins)
sort.Strings(sortedOrigins)
data, err := sonic.Marshal(sortedOrigins)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash AllowedHeaders (sorted for deterministic hashing)
if len(c.AllowedHeaders) > 0 {
sortedHeaders := make([]string, len(c.AllowedHeaders))
copy(sortedHeaders, c.AllowedHeaders)
sort.Strings(sortedHeaders)
data, err := sonic.Marshal(sortedHeaders)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash LoggingHeaders (sorted for deterministic hashing)
if len(c.LoggingHeaders) > 0 {
sortedLogging := make([]string, len(c.LoggingHeaders))
copy(sortedLogging, c.LoggingHeaders)
sort.Strings(sortedLogging)
data, err := sonic.Marshal(sortedLogging)
if err != nil {
return "", err
}
hash.Write([]byte("loggingHeaders:"))
hash.Write(data)
}
// Hash RequiredHeaders (sorted for deterministic hashing)
if len(c.RequiredHeaders) > 0 {
sortedRequired := make([]string, len(c.RequiredHeaders))
copy(sortedRequired, c.RequiredHeaders)
sort.Strings(sortedRequired)
data, err := sonic.Marshal(sortedRequired)
if err != nil {
return "", err
}
hash.Write([]byte("requiredHeaders:"))
hash.Write(data)
}
// Hash WhitelistedRoutes (sorted for deterministic hashing)
if len(c.WhitelistedRoutes) > 0 {
sortedRoutes := make([]string, len(c.WhitelistedRoutes))
copy(sortedRoutes, c.WhitelistedRoutes)
sort.Strings(sortedRoutes)
data, err := sonic.Marshal(sortedRoutes)
if err != nil {
return "", err
}
hash.Write([]byte("whitelistedRoutes:"))
hash.Write(data)
}
// Hash HeaderFilterConfig
if c.HeaderFilterConfig != nil {
// Hash Allowlist (sorted for deterministic hashing)
if len(c.HeaderFilterConfig.Allowlist) > 0 {
sortedAllowlist := make([]string, len(c.HeaderFilterConfig.Allowlist))
copy(sortedAllowlist, c.HeaderFilterConfig.Allowlist)
sort.Strings(sortedAllowlist)
data, err := sonic.Marshal(sortedAllowlist)
if err != nil {
return "", err
}
hash.Write([]byte("headerFilterConfig.allowlist:"))
hash.Write(data)
}
// Hash Denylist (sorted for deterministic hashing)
if len(c.HeaderFilterConfig.Denylist) > 0 {
sortedDenylist := make([]string, len(c.HeaderFilterConfig.Denylist))
copy(sortedDenylist, c.HeaderFilterConfig.Denylist)
sort.Strings(sortedDenylist)
data, err := sonic.Marshal(sortedDenylist)
if err != nil {
return "", err
}
hash.Write([]byte("headerFilterConfig.denylist:"))
hash.Write(data)
}
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// ProviderConfig represents the configuration for a specific AI model provider.
// It includes API keys, network settings, and concurrency settings.
type ProviderConfig struct {
Keys []schemas.Key `json:"keys"` // API keys for the provider with UUIDs
NetworkConfig *schemas.NetworkConfig `json:"network_config,omitempty"` // Network-related settings
ConcurrencyAndBufferSize *schemas.ConcurrencyAndBufferSize `json:"concurrency_and_buffer_size,omitempty"` // Concurrency settings
ProxyConfig *schemas.ProxyConfig `json:"proxy_config,omitempty"` // Proxy configuration
SendBackRawRequest bool `json:"send_back_raw_request"` // Include raw request in BifrostResponse
SendBackRawResponse bool `json:"send_back_raw_response"` // Include raw response in BifrostResponse
StoreRawRequestResponse bool `json:"store_raw_request_response"` // Capture raw request/response for internal logging only; strip from API responses returned to clients
CustomProviderConfig *schemas.CustomProviderConfig `json:"custom_provider_config,omitempty"` // Custom provider configuration
OpenAIConfig *schemas.OpenAIConfig `json:"openai_config,omitempty"` // OpenAI-specific configuration
ConfigHash string `json:"config_hash,omitempty"` // Hash of config.json version, used for change detection
Status string `json:"status,omitempty"` // Model discovery status for keyless providers
Description string `json:"description,omitempty"` // Model discovery error message for keyless providers
}
// Redacted returns a redacted copy of the provider configuration.
func (p *ProviderConfig) Redacted() *ProviderConfig {
// Create redacted config with same structure but redacted values
var redactedNetworkConfig *schemas.NetworkConfig
if p.NetworkConfig != nil {
redactedNetworkConfig = p.NetworkConfig.Redacted()
}
redactedConfig := ProviderConfig{
NetworkConfig: redactedNetworkConfig,
ConcurrencyAndBufferSize: p.ConcurrencyAndBufferSize,
SendBackRawRequest: p.SendBackRawRequest,
SendBackRawResponse: p.SendBackRawResponse,
StoreRawRequestResponse: p.StoreRawRequestResponse,
CustomProviderConfig: p.CustomProviderConfig,
OpenAIConfig: p.OpenAIConfig,
ConfigHash: p.ConfigHash,
Status: p.Status,
Description: p.Description,
}
if p.ProxyConfig != nil {
redactedConfig.ProxyConfig = p.ProxyConfig.Redacted()
}
// Create redacted keys
redactedConfig.Keys = make([]schemas.Key, len(p.Keys))
for i, key := range p.Keys {
models := key.Models
if models == nil {
models = []string{} // Ensure models is never nil in JSON response
}
blacklistedModels := key.BlacklistedModels
if blacklistedModels == nil {
blacklistedModels = []string{} // Match models: empty JSON array, not null
}
redactedConfig.Keys[i] = schemas.Key{
ID: key.ID,
Name: key.Name,
Models: models,
BlacklistedModels: blacklistedModels,
Weight: key.Weight,
ConfigHash: key.ConfigHash,
}
if key.Enabled != nil {
enabled := *key.Enabled
redactedConfig.Keys[i].Enabled = &enabled
}
if key.Aliases != nil {
redactedConfig.Keys[i].Aliases = maps.Clone(key.Aliases)
}
redactedConfig.Keys[i].Value = *key.Value.Redacted()
// Add back use for batch api
if key.UseForBatchAPI != nil {
redactedConfig.Keys[i].UseForBatchAPI = key.UseForBatchAPI
} else {
redactedConfig.Keys[i].UseForBatchAPI = bifrost.Ptr(false)
}
// Add model discovery status and error
redactedConfig.Keys[i].Status = key.Status
redactedConfig.Keys[i].Description = key.Description
// Redact Azure key config if present
if key.AzureKeyConfig != nil {
azureConfig := &schemas.AzureKeyConfig{}
azureConfig.Endpoint = *key.AzureKeyConfig.Endpoint.Redacted()
azureConfig.APIVersion = key.AzureKeyConfig.APIVersion
if key.AzureKeyConfig.ClientID != nil {
azureConfig.ClientID = key.AzureKeyConfig.ClientID.Redacted()
}
if key.AzureKeyConfig.ClientSecret != nil {
azureConfig.ClientSecret = key.AzureKeyConfig.ClientSecret.Redacted()
}
if key.AzureKeyConfig.TenantID != nil {
azureConfig.TenantID = key.AzureKeyConfig.TenantID.Redacted()
}
if len(key.AzureKeyConfig.Scopes) > 0 {
azureConfig.Scopes = key.AzureKeyConfig.Scopes
}
redactedConfig.Keys[i].AzureKeyConfig = azureConfig
}
// Redact Vertex key config if present
if key.VertexKeyConfig != nil {
vertexConfig := &schemas.VertexKeyConfig{}
vertexConfig.ProjectID = *key.VertexKeyConfig.ProjectID.Redacted()
vertexConfig.ProjectNumber = *key.VertexKeyConfig.ProjectNumber.Redacted()
vertexConfig.Region = *key.VertexKeyConfig.Region.Redacted()
vertexConfig.AuthCredentials = *key.VertexKeyConfig.AuthCredentials.Redacted()
redactedConfig.Keys[i].VertexKeyConfig = vertexConfig
}
// Redact Bedrock key config if present
if key.BedrockKeyConfig != nil {
bedrockConfig := &schemas.BedrockKeyConfig{}
bedrockConfig.AccessKey = *key.BedrockKeyConfig.AccessKey.Redacted()
bedrockConfig.SecretKey = *key.BedrockKeyConfig.SecretKey.Redacted()
if key.BedrockKeyConfig.SessionToken != nil {
bedrockConfig.SessionToken = key.BedrockKeyConfig.SessionToken.Redacted()
}
if key.BedrockKeyConfig.Region != nil {
bedrockConfig.Region = key.BedrockKeyConfig.Region.Redacted()
}
if key.BedrockKeyConfig.ARN != nil {
bedrockConfig.ARN = key.BedrockKeyConfig.ARN.Redacted()
}
if key.BedrockKeyConfig.RoleARN != nil {
bedrockConfig.RoleARN = key.BedrockKeyConfig.RoleARN.Redacted()
}
if key.BedrockKeyConfig.ExternalID != nil {
bedrockConfig.ExternalID = key.BedrockKeyConfig.ExternalID.Redacted()
}
if key.BedrockKeyConfig.RoleSessionName != nil {
bedrockConfig.RoleSessionName = key.BedrockKeyConfig.RoleSessionName.Redacted()
}
// Add back s3 config
if key.BedrockKeyConfig.BatchS3Config != nil {
bedrockConfig.BatchS3Config = key.BedrockKeyConfig.BatchS3Config
}
redactedConfig.Keys[i].BedrockKeyConfig = bedrockConfig
}
if key.VLLMKeyConfig != nil {
vllmConfig := &schemas.VLLMKeyConfig{
ModelName: key.VLLMKeyConfig.ModelName,
}
vllmConfig.URL = *key.VLLMKeyConfig.URL.Redacted()
redactedConfig.Keys[i].VLLMKeyConfig = vllmConfig
}
if key.ReplicateKeyConfig != nil {
replicateConfig := &schemas.ReplicateKeyConfig{
UseDeploymentsEndpoint: key.ReplicateKeyConfig.UseDeploymentsEndpoint,
}
redactedConfig.Keys[i].ReplicateKeyConfig = replicateConfig
}
if key.OllamaKeyConfig != nil {
ollamaConfig := &schemas.OllamaKeyConfig{}
ollamaConfig.URL = *key.OllamaKeyConfig.URL.Redacted()
redactedConfig.Keys[i].OllamaKeyConfig = ollamaConfig
}
if key.SGLKeyConfig != nil {
sglConfig := &schemas.SGLKeyConfig{}
sglConfig.URL = *key.SGLKeyConfig.URL.Redacted()
redactedConfig.Keys[i].SGLKeyConfig = sglConfig
}
}
return &redactedConfig
}
// GenerateConfigHash generates a SHA256 hash of the provider configuration.
// This is used to detect changes between config.json and database config.
// Keys are excluded as they are hashed separately.
func (p *ProviderConfig) GenerateConfigHash(providerName string) (string, error) {
hash := sha256.New()
// Hash provider name
hash.Write([]byte(providerName))
// Hash NetworkConfig
if p.NetworkConfig != nil {
data, err := sonic.Marshal(p.NetworkConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash ConcurrencyAndBufferSize
if p.ConcurrencyAndBufferSize != nil {
data, err := sonic.Marshal(p.ConcurrencyAndBufferSize)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash ProxyConfig
if p.ProxyConfig != nil {
data, err := sonic.Marshal(p.ProxyConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash CustomProviderConfig
if p.CustomProviderConfig != nil {
data, err := sonic.Marshal(p.CustomProviderConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash OpenAIConfig
if p.OpenAIConfig != nil {
data, err := sonic.Marshal(p.OpenAIConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash SendBackRawRequest
if p.SendBackRawRequest {
hash.Write([]byte("sendBackRawRequest"))
}
// Hash SendBackRawResponse
if p.SendBackRawResponse {
hash.Write([]byte("sendBackRawResponse"))
}
// Hash StoreRawRequestResponse
if p.StoreRawRequestResponse {
hash.Write([]byte("storeRawRequestResponse"))
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GenerateKeyHash generates a SHA256 hash for an individual key.
// This is used to detect changes to keys between config.json and database.
// Skips: ID (dynamic UUID), timestamps
func GenerateKeyHash(key schemas.Key) (string, error) {
hash := sha256.New()
// Hash Name
hash.Write([]byte(key.Name))
// Hash Value (prefix with source type to prevent collisions between env and literal)
if key.Value.IsFromEnv() {
hash.Write([]byte("env:" + key.Value.EnvVar))
} else {
hash.Write([]byte("val:" + key.Value.Val))
}
// Hash Models (key-level model restrictions)
if len(key.Models) > 0 {
sortedModels := make([]string, len(key.Models))
copy(sortedModels, key.Models)
sort.Strings(sortedModels)
data, err := sonic.Marshal(sortedModels)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash BlacklistedModels (key-level deny list)
if len(key.BlacklistedModels) > 0 {
sortedBlacklistedModels := make([]string, len(key.BlacklistedModels))
copy(sortedBlacklistedModels, key.BlacklistedModels)
sort.Strings(sortedBlacklistedModels)
data, err := sonic.Marshal(sortedBlacklistedModels)
if err != nil {
return "", err
}
hash.Write([]byte("blacklistedModels:"))
hash.Write(data)
}
// Hash Weight
data, err := sonic.Marshal(key.Weight)
if err != nil {
return "", err
}
hash.Write(data)
// Hash AzureKeyConfig
if key.AzureKeyConfig != nil {
data, err := sonic.Marshal(key.AzureKeyConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash VertexKeyConfig
if key.VertexKeyConfig != nil {
data, err := sonic.Marshal(key.VertexKeyConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash BedrockKeyConfig
if key.BedrockKeyConfig != nil {
data, err := sonic.Marshal(key.BedrockKeyConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash Aliases
if key.Aliases != nil {
data, err := sonic.Marshal(key.Aliases)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash VLLMKeyConfig
if key.VLLMKeyConfig != nil {
data, err := sonic.Marshal(key.VLLMKeyConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash ReplicateKeyConfig
if key.ReplicateKeyConfig != nil {
data, err := sonic.Marshal(key.ReplicateKeyConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash OllamaKeyConfig
if key.OllamaKeyConfig != nil {
data, err := sonic.Marshal(key.OllamaKeyConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash SGLKeyConfig
if key.SGLKeyConfig != nil {
data, err := sonic.Marshal(key.SGLKeyConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash Enabled (nil = false, only true produces different hash)
if key.Enabled != nil && *key.Enabled {
hash.Write([]byte("enabled:true"))
}
// Hash UseForBatchAPI (nil = default false for new keys)
useForBatchAPI := false
if key.UseForBatchAPI != nil {
useForBatchAPI = *key.UseForBatchAPI
}
if useForBatchAPI {
hash.Write([]byte("useForBatchAPI:true"))
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// VirtualKeyHashInput represents the fields used for virtual key hash generation.
// This struct is used to create a consistent hash from TableVirtualKey,
// excluding dynamic fields like ID, timestamps, and relationship objects.
type VirtualKeyHashInput struct {
Name string
Description string
IsActive bool
TeamID *string
CustomerID *string
RateLimitID *string
// ProviderConfigs and MCPConfigs are hashed separately as they contain nested data
ProviderConfigs []VirtualKeyProviderConfigHashInput
MCPConfigs []VirtualKeyMCPConfigHashInput
}
// VirtualKeyProviderConfigHashInput represents provider config fields for hashing
type VirtualKeyProviderConfigHashInput struct {
Provider string
Weight *float64
AllowedModels []string
RateLimitID *string
KeyIDs []string // Only key IDs, not full key objects
}
// VirtualKeyMCPConfigHashInput represents MCP config fields for hashing
type VirtualKeyMCPConfigHashInput struct {
MCPClientID uint
ToolsToExecute []string
}
// GenerateVirtualKeyHash generates a SHA256 hash for a virtual key.
// This is used to detect changes to virtual keys between config.json and database.
// Skips: ID (primary key), CreatedAt, UpdatedAt, and relationship objects (Team, Customer, Budget, RateLimit)
func GenerateVirtualKeyHash(vk tables.TableVirtualKey) (string, error) {
hash := sha256.New()
// Hash Name
hash.Write([]byte(vk.Name))
// Hash Description
hash.Write([]byte(vk.Description))
// Hash Value
hash.Write([]byte(vk.Value))
// Hash IsActive
if vk.IsActive {
hash.Write([]byte("isActive:true"))
} else {
hash.Write([]byte("isActive:false"))
}
// Hash TeamID
if vk.TeamID != nil {
hash.Write([]byte("teamID:" + *vk.TeamID))
}
// Hash CustomerID
if vk.CustomerID != nil {
hash.Write([]byte("customerID:" + *vk.CustomerID))
}
// Hash RateLimitID
if vk.RateLimitID != nil {
hash.Write([]byte("rateLimitID:" + *vk.RateLimitID))
}
// Hash ProviderConfigs
if len(vk.ProviderConfigs) > 0 {
// Copy and sort provider configs for deterministic hashing
sortedProviderConfigs := make([]tables.TableVirtualKeyProviderConfig, len(vk.ProviderConfigs))
copy(sortedProviderConfigs, vk.ProviderConfigs)
sort.Slice(sortedProviderConfigs, func(i, j int) bool {
if sortedProviderConfigs[i].Provider != sortedProviderConfigs[j].Provider {
return sortedProviderConfigs[i].Provider < sortedProviderConfigs[j].Provider
}
ri, rj := "", ""
if sortedProviderConfigs[i].RateLimitID != nil {
ri = *sortedProviderConfigs[i].RateLimitID
}
if sortedProviderConfigs[j].RateLimitID != nil {
rj = *sortedProviderConfigs[j].RateLimitID
}
if ri != rj {
return ri < rj
}
wi, wj := sortedProviderConfigs[i].Weight, sortedProviderConfigs[j].Weight
if (wi == nil) != (wj == nil) {
return wi == nil
}
if wi != nil && wj != nil && *wi != *wj {
return *wi < *wj
}
return false
})
// Filter out provider configs that are not available
providerConfigsForHash := make([]VirtualKeyProviderConfigHashInput, len(sortedProviderConfigs))
for i, pc := range sortedProviderConfigs {
// Sort key IDs for deterministic hashing
keyIDs := make([]string, len(pc.Keys))
for j, k := range pc.Keys {
keyIDs[j] = k.KeyID
}
sort.Strings(keyIDs)
// Sort allowed models for deterministic hashing
sortedAllowedModels := make([]string, len(pc.AllowedModels))
copy(sortedAllowedModels, pc.AllowedModels)
sort.Strings(sortedAllowedModels)
providerConfigsForHash[i] = VirtualKeyProviderConfigHashInput{
Provider: pc.Provider,
Weight: pc.Weight,
AllowedModels: sortedAllowedModels,
RateLimitID: pc.RateLimitID,
KeyIDs: keyIDs,
}
}
data, err := sonic.Marshal(providerConfigsForHash)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash MCPConfigs
if len(vk.MCPConfigs) > 0 {
// Copy and sort MCP configs for deterministic hashing
sortedMCPConfigs := make([]tables.TableVirtualKeyMCPConfig, len(vk.MCPConfigs))
copy(sortedMCPConfigs, vk.MCPConfigs)
sort.Slice(sortedMCPConfigs, func(i, j int) bool {
return sortedMCPConfigs[i].MCPClientID < sortedMCPConfigs[j].MCPClientID
})
mcpConfigsForHash := make([]VirtualKeyMCPConfigHashInput, len(sortedMCPConfigs))
for i, mc := range sortedMCPConfigs {
// Sort tools for deterministic hashing
sortedTools := make([]string, len(mc.ToolsToExecute))
copy(sortedTools, mc.ToolsToExecute)
sort.Strings(sortedTools)
mcpConfigsForHash[i] = VirtualKeyMCPConfigHashInput{
MCPClientID: mc.MCPClientID,
ToolsToExecute: sortedTools,
}
}
data, err := sonic.Marshal(mcpConfigsForHash)
if err != nil {
return "", err
}
hash.Write(data)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GenerateBudgetHash generates a SHA256 hash for a budget.
// This is used to detect changes to budgets between config.json and database.
// Skips: LastReset, CurrentUsage, CreatedAt, UpdatedAt (dynamic fields)
func GenerateBudgetHash(b tables.TableBudget) (string, error) {
hash := sha256.New()
// Hash ID
hash.Write([]byte(b.ID))
// Hash MaxLimit
data, err := sonic.Marshal(b.MaxLimit)
if err != nil {
return "", err
}
hash.Write(data)
// Hash ResetDuration
hash.Write([]byte(b.ResetDuration))
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GenerateRateLimitHash generates a SHA256 hash for a rate limit.
// This is used to detect changes to rate limits between config.json and database.
// Skips: CurrentUsage, LastReset, CreatedAt, UpdatedAt (dynamic fields)
func GenerateRateLimitHash(rl tables.TableRateLimit) (string, error) {
hash := sha256.New()
// Hash ID
hash.Write([]byte(rl.ID))
// Hash TokenMaxLimit
if rl.TokenMaxLimit != nil {
data, err := sonic.Marshal(*rl.TokenMaxLimit)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash TokenResetDuration
if rl.TokenResetDuration != nil {
hash.Write([]byte(*rl.TokenResetDuration))
}
// Hash RequestMaxLimit
if rl.RequestMaxLimit != nil {
data, err := sonic.Marshal(*rl.RequestMaxLimit)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash RequestResetDuration
if rl.RequestResetDuration != nil {
hash.Write([]byte(*rl.RequestResetDuration))
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GenerateCustomerHash generates a SHA256 hash for a customer.
// This is used to detect changes to customers between config.json and database.
// Skips: CreatedAt, UpdatedAt, and relationship objects (dynamic fields)
func GenerateCustomerHash(c tables.TableCustomer) (string, error) {
hash := sha256.New()
// Hash ID
hash.Write([]byte(c.ID))
// Hash Name
hash.Write([]byte(c.Name))
// Hash BudgetID
if c.BudgetID != nil {
hash.Write([]byte("budgetID:" + *c.BudgetID))
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GenerateTeamHash generates a SHA256 hash for a team.
// This is used to detect changes to teams between config.json and database.
// Skips: CreatedAt, UpdatedAt, and relationship objects (dynamic fields)
func GenerateTeamHash(t tables.TableTeam) (string, error) {
hash := sha256.New()
// Hash ID
hash.Write([]byte(t.ID))
// Hash Name
hash.Write([]byte(t.Name))
// Hash CustomerID
if t.CustomerID != nil {
hash.Write([]byte("customerID:" + *t.CustomerID))
}
// Hash sorted budget IDs — team now owns multiple budgets; slice order must not
// affect the hash, otherwise config-sync would flip the hash on every reload.
if len(t.Budgets) > 0 {
ids := make([]string, len(t.Budgets))
for i, b := range t.Budgets {
ids[i] = b.ID
}
sort.Strings(ids)
hash.Write([]byte("budgetIDs:"))
for i, id := range ids {
if i > 0 {
hash.Write([]byte{','})
}
hash.Write([]byte(id))
}
}
// Hash Profile - use Profile if set, else marshal ParsedProfile
// (Profile has json:"-" so when loading from JSON, only ParsedProfile is populated)
// Use encoding/json for consistency with BeforeSave hook serialization
if t.Profile != nil {
hash.Write([]byte("profile:" + *t.Profile))
} else if t.ParsedProfile != nil {
data, err := json.Marshal(t.ParsedProfile)
if err != nil {
return "", err
}
hash.Write([]byte("profile:" + string(data)))
}
// Hash Config - use Config if set, else marshal ParsedConfig
// Use encoding/json for consistency with BeforeSave hook serialization
if t.Config != nil {
hash.Write([]byte("config:" + *t.Config))
} else if t.ParsedConfig != nil {
data, err := json.Marshal(t.ParsedConfig)
if err != nil {
return "", err
}
hash.Write([]byte("config:" + string(data)))
}
// Hash Claims - use Claims if set, else marshal ParsedClaims
// Use encoding/json for consistency with BeforeSave hook serialization
if t.Claims != nil {
hash.Write([]byte("claims:" + *t.Claims))
} else if t.ParsedClaims != nil {
data, err := json.Marshal(t.ParsedClaims)
if err != nil {
return "", err
}
hash.Write([]byte("claims:" + string(data)))
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GenerateRoutingRuleHash generates a SHA256 hash for a routing rule.
// This is used to detect changes to routing rules between config.json and database.
// routingTargetHashPayload is a canonical struct for hashing a routing target.
// Used to ensure deterministic hashes regardless of slice order.
// Fields use plain string (not *string) so nil and "" both marshal to "" and produce the same hash.
type routingTargetHashPayload struct {
Provider string `json:"provider"`
Model string `json:"model"`
KeyID string `json:"key_id"`
Weight float64 `json:"weight"`
}
// derefStr returns the dereferenced value of s, or "" if s is nil.
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// Skips: CreatedAt, UpdatedAt (dynamic fields)
func GenerateRoutingRuleHash(r tables.TableRoutingRule) (string, error) {
hash := sha256.New()
// Hash ID
hash.Write([]byte(r.ID))
// Hash Name
hash.Write([]byte(r.Name))
// Hash Description
hash.Write([]byte(r.Description))
// Hash Enabled
if r.Enabled {
hash.Write([]byte("enabled:true"))
} else {
hash.Write([]byte("enabled:false"))
}
// Hash CelExpression
hash.Write([]byte(r.CelExpression))
// Hash Targets: sort by canonical marshaled payload for determinism, then hash each target as a single blob
targets := make([]tables.TableRoutingTarget, len(r.Targets))
copy(targets, r.Targets)
sort.Slice(targets, func(i, j int) bool {
pi := routingTargetHashPayload{Provider: derefStr(targets[i].Provider), Model: derefStr(targets[i].Model), KeyID: derefStr(targets[i].KeyID), Weight: targets[i].Weight}
pj := routingTargetHashPayload{Provider: derefStr(targets[j].Provider), Model: derefStr(targets[j].Model), KeyID: derefStr(targets[j].KeyID), Weight: targets[j].Weight}
di, err := sonic.Marshal(pi)
if err != nil {
return false
}
dj, err := sonic.Marshal(pj)
if err != nil {
return false
}
return string(di) < string(dj)
})
for _, t := range targets {
payload := routingTargetHashPayload{Provider: derefStr(t.Provider), Model: derefStr(t.Model), KeyID: derefStr(t.KeyID), Weight: t.Weight}
data, err := sonic.Marshal(payload)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash Fallbacks: use DB string when set, else marshal ParsedFallbacks (config-origin)
if r.Fallbacks != nil {
hash.Write([]byte(*r.Fallbacks))
} else if len(r.ParsedFallbacks) > 0 {
data, err := sonic.Marshal(r.ParsedFallbacks)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash Query: use raw string when set, else marshal ParsedQuery (config-origin)
// Use OrderedMap's deterministic marshalling to ensure consistent hashes across runs
if r.Query != nil {
hash.Write([]byte(*r.Query))
} else if len(r.ParsedQuery) > 0 {
// Convert map to OrderedMap and use sorted marshalling for deterministic hashes
orderedMap := schemas.OrderedMapFromMap(r.ParsedQuery)
data, err := orderedMap.MarshalSorted()
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash ChainRule
if r.ChainRule {
hash.Write([]byte("chain_rule:true"))
} else {
hash.Write([]byte("chain_rule:false"))
}
// Hash Scope
hash.Write([]byte(r.Scope))
// Hash ScopeID (nil = global)
scopeID := ""
if r.ScopeID != nil {
scopeID = *r.ScopeID
}
hash.Write([]byte(scopeID))
// Hash Priority
hash.Write([]byte(strconv.Itoa(r.Priority)))
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GeneratePricingOverrideHash generates a SHA256 hash for a pricing override.
// Skips: CreatedAt, UpdatedAt, ConfigHash (dynamic/meta fields).
func GeneratePricingOverrideHash(p tables.TablePricingOverride) (string, error) {
hash := sha256.New()
hash.Write([]byte(p.ID))
hash.Write([]byte(p.Name))
hash.Write([]byte(p.ScopeKind))
hash.Write([]byte(derefStr(p.VirtualKeyID)))
hash.Write([]byte(derefStr(p.ProviderID)))
hash.Write([]byte(derefStr(p.ProviderKeyID)))
hash.Write([]byte(p.MatchType))
hash.Write([]byte(p.Pattern))
hash.Write([]byte(p.RequestTypesJSON))
hash.Write([]byte(p.PricingPatchJSON))
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GenerateMCPClientHash generates a SHA256 hash for an MCP client.
// This is used to detect changes to MCP clients between config.json and database.
// Skips: ID (autoIncrement), CreatedAt, UpdatedAt (dynamic fields)
func GenerateMCPClientHash(m tables.TableMCPClient) (string, error) {
hash := sha256.New()
// Hash ClientID
hash.Write([]byte(m.ClientID))
// Hash Name
hash.Write([]byte(m.Name))
// Hash ConnectionType
hash.Write([]byte(m.ConnectionType))
// Hash ConnectionString
if m.ConnectionString != nil {
if m.ConnectionString.IsFromEnv() {
hash.Write([]byte(m.ConnectionString.EnvVar))
} else {
hash.Write([]byte(m.ConnectionString.Val))
}
}
// Hash StdioConfig
if m.StdioConfig != nil {
data, err := sonic.Marshal(m.StdioConfig)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash ToolsToExecute (sorted for deterministic hashing)
if len(m.ToolsToExecute) > 0 {
sortedTools := make([]string, len(m.ToolsToExecute))
copy(sortedTools, m.ToolsToExecute)
sort.Strings(sortedTools)
data, err := sonic.Marshal(sortedTools)
if err != nil {
return "", err
}
hash.Write(data)
}
// Hash Headers (sorted for deterministic hashing)
if len(m.Headers) > 0 {
keys := make([]string, 0, len(m.Headers))
for k := range m.Headers {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
val := m.Headers[k]
if val.FromEnv {
hash.Write([]byte(k + ":env:" + val.EnvVar))
} else {
hash.Write([]byte(k + ":val:" + val.Val))
}
}
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GeneratePluginHash generates a SHA256 hash for a plugin.
// This is used to detect changes to plugins between config.json and database.
// Skips: ID (autoIncrement), CreatedAt, UpdatedAt, IsCustom (dynamic fields)
func GeneratePluginHash(p tables.TablePlugin) (string, error) {
hash := sha256.New()
// Hash Name
hash.Write([]byte(p.Name))
// Hash Enabled
if p.Enabled {
hash.Write([]byte("enabled:true"))
} else {
hash.Write([]byte("enabled:false"))
}
// Hash Path
if p.Path != nil {
hash.Write([]byte("path:" + *p.Path))
}
// Hash Config (use ConfigJSON for consistent hashing)
// Normalize: nil and empty map ({}) are treated as equivalent (no hash contribution)
if p.ConfigJSON != "" && p.ConfigJSON != "{}" {
hash.Write([]byte(p.ConfigJSON))
} else if p.Config != nil {
// Check if Config is a non-empty map before hashing
// Use encoding/json for consistency with BeforeSave hook serialization
data, err := json.Marshal(p.Config)
if err != nil {
return "", err
}
// Only hash if it's not an empty object
if string(data) != "{}" && string(data) != "null" {
hash.Write(data)
}
}
// Hash Version
data, err := sonic.Marshal(p.Version)
if err != nil {
return "", err
}
hash.Write(data)
return hex.EncodeToString(hash.Sum(nil)), nil
}
// AuthConfig represents configured auth config for Bifrost dashboard
type AuthConfig struct {
AdminUserName *schemas.EnvVar `json:"admin_username"`
AdminPassword *schemas.EnvVar `json:"admin_password"`
IsEnabled bool `json:"is_enabled"`
DisableAuthOnInference bool `json:"disable_auth_on_inference"`
}
// ConfigMap maps provider names to their configurations.
type ConfigMap map[schemas.ModelProvider]ProviderConfig
// GovernanceConfig contains governance entities loaded from the config store or
// reconciled from config.json.
type GovernanceConfig struct {
VirtualKeys []tables.TableVirtualKey `json:"virtual_keys"`
Teams []tables.TableTeam `json:"teams"`
Customers []tables.TableCustomer `json:"customers"`
Budgets []tables.TableBudget `json:"budgets"`
RateLimits []tables.TableRateLimit `json:"rate_limits"`
ModelConfigs []tables.TableModelConfig `json:"model_configs"`
Providers []tables.TableProvider `json:"providers"`
RoutingRules []tables.TableRoutingRule `json:"routing_rules"`
PricingOverrides []tables.TablePricingOverride `json:"pricing_overrides,omitempty"`
AuthConfig *AuthConfig `json:"auth_config,omitempty"`
}