package config import ( "errors" "log/slog" "net/url" "os" "strconv" "strings" ) const ( DefaultLocalGeneratedStorageDir = "data/static/generated" DefaultLocalUploadedStorageDir = "data/static/uploaded" ) type Config struct { AppEnv string HTTPAddr string DatabaseURL string IdentityMode string JWTSecret string ServerMainBaseURL string ServerMainInternalToken string ServerMainInternalKey string ServerMainInternalSecret string IdentitySecretStore string IdentitySecretDir string IdentityKubernetesNamespace string IdentityKubernetesSecretName string IdentityKubernetesAPIServer string IdentityKubernetesTokenFile string IdentityKubernetesCAFile string IdentitySecurityEventHeartbeatIntervalSeconds int IdentitySecurityEventStaleAfterSeconds int IdentitySecurityEventClockSkewSeconds int PublicBaseURL string WebBaseURL string LocalGeneratedStorageDir string LocalUploadedStorageDir string LocalTempAssetTTLHours int LocalResultTTLHours int LocalResultMinFreeBytes int64 LocalResultMaxBytes int64 LocalResultMaxTaskBytes int64 TaskProgressCallbackEnabled bool TaskProgressCallbackURL string TaskProgressCallbackTimeoutMS int TaskProgressCallbackMaxAttempts int TaskCleanupEnabled bool TaskRetentionDays int TaskAnalysisRetentionDays int TaskCleanupIntervalSeconds int TaskCleanupBatchSize int CORSAllowedOrigin string GlobalHTTPProxy string GlobalHTTPProxySource string LogLevel slog.Level BillingEngineMode string ProcessRole string DatabaseMaxConns int DatabaseCriticalMaxConns int DatabaseRiverMaxConns int DatabaseMinIdleConns int DatabaseMaxConnIdleSeconds int DatabaseIdleInTransactionTimeoutSeconds int DatabaseLockTimeoutSeconds int MediaRequestConcurrency int MediaMaterializationConcurrency int MediaImageNormalizationConcurrency int MediaOSSDirectEnabled bool MediaOSSEndpoint string MediaOSSBucket string MediaOSSAccessKeyID string MediaOSSAccessKeySecret string MediaOSSPublicBaseURL string MediaOSSObjectPrefix string AsyncQueueWorkerEnabled bool AsyncWorkerHardLimit int AsyncWorkerInstanceHardLimit int AsyncWorkerRefreshIntervalSeconds int WorkerAutoscalingEnabled bool WorkerReplicasNingbo int WorkerReplicasHongkong int WorkerMinReplicasNingbo int WorkerMinReplicasHongkong int WorkerMaxReplicasNingbo int WorkerMaxReplicasHongkong int WorkerTargetOutstandingPerReplica int WorkerScaleUpWindowSeconds int WorkerScaleDownStabilizationSeconds int WorkerDrainTimeoutSeconds int NodeMemoryTargetPercent int NodeMemoryHardPercent int NodeCPUTargetPercent int PostgresConnectionBudget int PostgresNonWorkerConnectionBudget int WorkerDatabaseMaxConns int CapacityControllerNamespace string CapacityControllerAPIServer string CapacityControllerTokenFile string CapacityControllerCAFile string } func Load() Config { globalProxy := LoadGlobalHTTPProxyStatus() appEnv := env("APP_ENV", "development") return Config{ AppEnv: appEnv, HTTPAddr: env("HTTP_ADDR", ":8088"), DatabaseURL: gatewayDatabaseURL(), IdentityMode: env("IDENTITY_MODE", "hybrid"), JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"), ServerMainBaseURL: strings.TrimRight( env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/", ), ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), IdentitySecretStore: env("IDENTITY_SECRET_STORE", "file"), IdentitySecretDir: env("IDENTITY_SECRET_DIR", ".local-secrets/identity"), IdentityKubernetesNamespace: env("IDENTITY_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")), IdentityKubernetesSecretName: env("IDENTITY_KUBERNETES_SECRET_NAME", "easyai-gateway-identity"), IdentityKubernetesAPIServer: env("IDENTITY_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"), IdentityKubernetesTokenFile: env("IDENTITY_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"), IdentityKubernetesCAFile: env("IDENTITY_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"), IdentitySecurityEventHeartbeatIntervalSeconds: envInt("IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60), IdentitySecurityEventStaleAfterSeconds: envInt("IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180), IdentitySecurityEventClockSkewSeconds: envInt("IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60), PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24), LocalResultTTLHours: envIntValidated("AI_GATEWAY_LOCAL_RESULT_TTL_HOURS", 24), LocalResultMinFreeBytes: envInt64Validated("AI_GATEWAY_LOCAL_RESULT_MIN_FREE_BYTES", 10*1024*1024*1024), LocalResultMaxBytes: envInt64Validated("AI_GATEWAY_LOCAL_RESULT_MAX_BYTES", 256*1024*1024), LocalResultMaxTaskBytes: envInt64Validated("AI_GATEWAY_LOCAL_RESULT_MAX_TASK_BYTES", 512*1024*1024), TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL", strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks", ), TaskProgressCallbackTimeoutMS: envIntValidated("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", 5000), TaskProgressCallbackMaxAttempts: envIntValidated("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", 10), TaskCleanupEnabled: env("AI_GATEWAY_TASK_CLEANUP_ENABLED", "false") == "true", TaskRetentionDays: envIntValidated("AI_GATEWAY_TASK_RETENTION_DAYS", 30), TaskAnalysisRetentionDays: envIntValidated("AI_GATEWAY_TASK_ANALYSIS_RETENTION_DAYS", 7), TaskCleanupIntervalSeconds: envIntValidated("AI_GATEWAY_TASK_CLEANUP_INTERVAL_SECONDS", 300), TaskCleanupBatchSize: envIntValidated("AI_GATEWAY_TASK_CLEANUP_BATCH_SIZE", 1000), CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178,http://127.0.0.1:5178"), GlobalHTTPProxy: globalProxy.HTTPProxy, GlobalHTTPProxySource: globalProxy.Source, LogLevel: logLevel(env("LOG_LEVEL", "info")), BillingEngineMode: strings.ToLower(env("BILLING_ENGINE_MODE", "observe")), ProcessRole: strings.ToLower(strings.TrimSpace(env("AI_GATEWAY_PROCESS_ROLE", "all"))), DatabaseMaxConns: envInt("AI_GATEWAY_DATABASE_MAX_CONNS", 0), DatabaseCriticalMaxConns: envOptionalIntValidated("AI_GATEWAY_DATABASE_CRITICAL_MAX_CONNS", 0), DatabaseRiverMaxConns: envOptionalIntValidated("AI_GATEWAY_DATABASE_RIVER_MAX_CONNS", 0), DatabaseMinIdleConns: envOptionalIntValidated("AI_GATEWAY_DATABASE_MIN_IDLE_CONNS", 0), DatabaseMaxConnIdleSeconds: envOptionalIntValidated("AI_GATEWAY_DATABASE_MAX_CONN_IDLE_SECONDS", 0), DatabaseIdleInTransactionTimeoutSeconds: envOptionalIntValidated( "AI_GATEWAY_DATABASE_IDLE_IN_TRANSACTION_TIMEOUT_SECONDS", 60, ), DatabaseLockTimeoutSeconds: envOptionalIntValidated("AI_GATEWAY_DATABASE_LOCK_TIMEOUT_SECONDS", 30), MediaRequestConcurrency: envIntValidated("AI_GATEWAY_MEDIA_REQUEST_CONCURRENCY", 16), MediaMaterializationConcurrency: envIntValidated("AI_GATEWAY_MEDIA_MATERIALIZATION_CONCURRENCY", 8), MediaImageNormalizationConcurrency: envIntValidated( "AI_GATEWAY_MEDIA_IMAGE_NORMALIZATION_CONCURRENCY", 2, ), MediaOSSDirectEnabled: env("AI_GATEWAY_MEDIA_OSS_DIRECT_ENABLED", "false") == "true", MediaOSSEndpoint: strings.TrimRight(env("AI_GATEWAY_MEDIA_OSS_ENDPOINT", ""), "/"), MediaOSSBucket: strings.TrimSpace(env("AI_GATEWAY_MEDIA_OSS_BUCKET", "")), MediaOSSAccessKeyID: strings.TrimSpace(env("AI_GATEWAY_MEDIA_OSS_ACCESS_KEY_ID", "")), MediaOSSAccessKeySecret: strings.TrimSpace(env("AI_GATEWAY_MEDIA_OSS_ACCESS_KEY_SECRET", "")), MediaOSSPublicBaseURL: strings.TrimRight(env("AI_GATEWAY_MEDIA_OSS_PUBLIC_BASE_URL", ""), "/"), MediaOSSObjectPrefix: strings.Trim(env("AI_GATEWAY_MEDIA_OSS_OBJECT_PREFIX", "easyai-ai-gateway/media"), "/"), AsyncQueueWorkerEnabled: env("AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED", "true") == "true", AsyncWorkerHardLimit: envIntValidated( "AI_GATEWAY_ASYNC_WORKER_GLOBAL_HARD_LIMIT", envOptionalIntValidated("AI_GATEWAY_ASYNC_WORKER_HARD_LIMIT", 2048), ), AsyncWorkerInstanceHardLimit: envIntValidated("AI_GATEWAY_ASYNC_WORKER_INSTANCE_HARD_LIMIT", 32), AsyncWorkerRefreshIntervalSeconds: envIntValidated("AI_GATEWAY_ASYNC_WORKER_REFRESH_INTERVAL_SECONDS", 5), WorkerAutoscalingEnabled: env("AI_GATEWAY_WORKER_AUTOSCALING_ENABLED", "false") == "true", WorkerReplicasNingbo: envOptionalIntValidated("AI_GATEWAY_WORKER_REPLICAS_NINGBO", 1), WorkerReplicasHongkong: envOptionalIntValidated("AI_GATEWAY_WORKER_REPLICAS_HONGKONG", 1), WorkerMinReplicasNingbo: envOptionalIntValidated("AI_GATEWAY_WORKER_MIN_REPLICAS_NINGBO", 1), WorkerMinReplicasHongkong: envOptionalIntValidated("AI_GATEWAY_WORKER_MIN_REPLICAS_HONGKONG", 1), WorkerMaxReplicasNingbo: envOptionalIntValidated("AI_GATEWAY_WORKER_MAX_REPLICAS_NINGBO", 1), WorkerMaxReplicasHongkong: envOptionalIntValidated("AI_GATEWAY_WORKER_MAX_REPLICAS_HONGKONG", 1), WorkerTargetOutstandingPerReplica: envOptionalIntValidated("AI_GATEWAY_WORKER_TARGET_OUTSTANDING_PER_REPLICA", 0), WorkerScaleUpWindowSeconds: envIntValidated("AI_GATEWAY_WORKER_SCALE_UP_WINDOW_SECONDS", 20), WorkerScaleDownStabilizationSeconds: envIntValidated( "AI_GATEWAY_WORKER_SCALE_DOWN_STABILIZATION_SECONDS", 600, ), WorkerDrainTimeoutSeconds: envIntValidated("AI_GATEWAY_WORKER_DRAIN_TIMEOUT_SECONDS", 600), NodeMemoryTargetPercent: envIntValidated("AI_GATEWAY_NODE_MEMORY_TARGET_PERCENT", 75), NodeMemoryHardPercent: envIntValidated("AI_GATEWAY_NODE_MEMORY_HARD_PERCENT", 85), NodeCPUTargetPercent: envIntValidated("AI_GATEWAY_NODE_CPU_TARGET_PERCENT", 70), PostgresConnectionBudget: envIntValidated("AI_GATEWAY_POSTGRES_CONNECTION_BUDGET", 150), PostgresNonWorkerConnectionBudget: envIntValidated( "AI_GATEWAY_POSTGRES_NON_WORKER_CONNECTION_BUDGET", 72, ), WorkerDatabaseMaxConns: envIntValidated("AI_GATEWAY_WORKER_DATABASE_MAX_CONNS", 32), CapacityControllerNamespace: env("POD_NAMESPACE", env("AI_GATEWAY_CAPACITY_CONTROLLER_NAMESPACE", "easyai")), CapacityControllerAPIServer: env("AI_GATEWAY_CAPACITY_CONTROLLER_API_SERVER", "https://kubernetes.default.svc"), CapacityControllerTokenFile: env("AI_GATEWAY_CAPACITY_CONTROLLER_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"), CapacityControllerCAFile: env("AI_GATEWAY_CAPACITY_CONTROLLER_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"), } } func (c Config) Validate() error { switch strings.ToLower(strings.TrimSpace(c.ProcessRole)) { case "", "all", "api", "worker", "capacity-controller": default: return errors.New("AI_GATEWAY_PROCESS_ROLE must be all, api, worker, or capacity-controller") } if c.DatabaseMaxConns < 0 || c.DatabaseMaxConns > 1000 { return errors.New("AI_GATEWAY_DATABASE_MAX_CONNS must be between 1 and 1000 when configured") } if c.DatabaseMinIdleConns < 0 || c.DatabaseMinIdleConns > 1000 || (c.DatabaseMaxConns > 0 && c.DatabaseMinIdleConns > c.DatabaseMaxConns) { return errors.New("AI_GATEWAY_DATABASE_MIN_IDLE_CONNS must be between 0 and AI_GATEWAY_DATABASE_MAX_CONNS") } if c.DatabaseCriticalMaxConns < 0 || c.DatabaseCriticalMaxConns > 64 || (c.DatabaseMaxConns > 0 && c.DatabaseCriticalMaxConns >= c.DatabaseMaxConns) { return errors.New("AI_GATEWAY_DATABASE_CRITICAL_MAX_CONNS must be between 0 and less than AI_GATEWAY_DATABASE_MAX_CONNS") } if c.DatabaseRiverMaxConns < 0 || c.DatabaseRiverMaxConns > 256 || (c.DatabaseMaxConns > 0 && c.DatabaseCriticalMaxConns+c.DatabaseRiverMaxConns >= c.DatabaseMaxConns) { return errors.New("critical and River database pools must leave at least one execution connection") } if c.DatabaseMaxConns > 0 && c.DatabaseMinIdleConns > c.DatabaseMaxConns-c.DatabaseCriticalMaxConns-c.DatabaseRiverMaxConns { return errors.New("AI_GATEWAY_DATABASE_MIN_IDLE_CONNS must not exceed the execution pool after reserving critical and River connections") } if c.DatabaseMaxConnIdleSeconds < 0 || c.DatabaseMaxConnIdleSeconds > 3600 { return errors.New("AI_GATEWAY_DATABASE_MAX_CONN_IDLE_SECONDS must be between 0 and 3600") } if c.DatabaseIdleInTransactionTimeoutSeconds < 0 || c.DatabaseIdleInTransactionTimeoutSeconds > 3600 { return errors.New("AI_GATEWAY_DATABASE_IDLE_IN_TRANSACTION_TIMEOUT_SECONDS must be between 0 and 3600") } if c.DatabaseLockTimeoutSeconds < 0 || c.DatabaseLockTimeoutSeconds > 3600 { return errors.New("AI_GATEWAY_DATABASE_LOCK_TIMEOUT_SECONDS must be between 0 and 3600") } if c.MediaMaterializationConcurrency != 0 && (c.MediaMaterializationConcurrency < 1 || c.MediaMaterializationConcurrency > 256) { return errors.New("AI_GATEWAY_MEDIA_MATERIALIZATION_CONCURRENCY must be between 1 and 256") } if c.MediaRequestConcurrency != 0 && (c.MediaRequestConcurrency < 1 || c.MediaRequestConcurrency > 1024) { return errors.New("AI_GATEWAY_MEDIA_REQUEST_CONCURRENCY must be between 1 and 1024") } if c.MediaImageNormalizationConcurrency != 0 && (c.MediaImageNormalizationConcurrency < 1 || c.MediaImageNormalizationConcurrency > 64) { return errors.New("AI_GATEWAY_MEDIA_IMAGE_NORMALIZATION_CONCURRENCY must be between 1 and 64") } if c.MediaOSSDirectEnabled { if strings.TrimSpace(c.MediaOSSAccessKeyID) == "" || strings.TrimSpace(c.MediaOSSAccessKeySecret) == "" { return errors.New("AI_GATEWAY_MEDIA_OSS_ACCESS_KEY_ID and AI_GATEWAY_MEDIA_OSS_ACCESS_KEY_SECRET are required when direct media OSS is enabled") } if !validMediaOSSBucket(c.MediaOSSBucket) { return errors.New("AI_GATEWAY_MEDIA_OSS_BUCKET is invalid") } if !validMediaOSSURL(c.MediaOSSEndpoint, c.AppEnv) { return errors.New("AI_GATEWAY_MEDIA_OSS_ENDPOINT must be an HTTPS URL") } if !validMediaOSSURL(c.MediaOSSPublicBaseURL, c.AppEnv) { return errors.New("AI_GATEWAY_MEDIA_OSS_PUBLIC_BASE_URL must be an HTTPS URL") } if !validMediaOSSObjectPrefix(c.MediaOSSObjectPrefix) { return errors.New("AI_GATEWAY_MEDIA_OSS_OBJECT_PREFIX is invalid") } } switch strings.ToLower(strings.TrimSpace(c.BillingEngineMode)) { case "", "observe", "enforce", "hold": default: return errors.New("BILLING_ENGINE_MODE must be observe, enforce, or hold") } if c.AsyncWorkerHardLimit < 1 || c.AsyncWorkerHardLimit > 10000 { return errors.New("AI_GATEWAY_ASYNC_WORKER_HARD_LIMIT must be between 1 and 10000") } if c.AsyncWorkerInstanceHardLimit < 1 || c.AsyncWorkerInstanceHardLimit > 10000 { return errors.New("AI_GATEWAY_ASYNC_WORKER_INSTANCE_HARD_LIMIT must be between 1 and 10000") } if c.AsyncWorkerRefreshIntervalSeconds < 1 { return errors.New("AI_GATEWAY_ASYNC_WORKER_REFRESH_INTERVAL_SECONDS must be positive") } if c.WorkerReplicasNingbo < 0 || c.WorkerReplicasHongkong < 0 || c.WorkerMinReplicasNingbo < 0 || c.WorkerMinReplicasHongkong < 0 || c.WorkerMaxReplicasNingbo < c.WorkerMinReplicasNingbo || c.WorkerMaxReplicasHongkong < c.WorkerMinReplicasHongkong || c.WorkerMaxReplicasNingbo > 64 || c.WorkerMaxReplicasHongkong > 64 { return errors.New("Worker replica configuration must be between 0 and 64 with min not greater than max") } if c.WorkerAutoscalingEnabled && (c.WorkerReplicasNingbo < c.WorkerMinReplicasNingbo || c.WorkerReplicasNingbo > c.WorkerMaxReplicasNingbo || c.WorkerReplicasHongkong < c.WorkerMinReplicasHongkong || c.WorkerReplicasHongkong > c.WorkerMaxReplicasHongkong) { return errors.New("Worker bootstrap replicas must be within the autoscaling min/max range") } if c.WorkerTargetOutstandingPerReplica < 0 || c.WorkerTargetOutstandingPerReplica > 100000 { return errors.New("AI_GATEWAY_WORKER_TARGET_OUTSTANDING_PER_REPLICA must be between 0 and 100000") } if c.WorkerScaleUpWindowSeconds != 0 && (c.WorkerScaleUpWindowSeconds < 5 || c.WorkerScaleUpWindowSeconds > 600) { return errors.New("AI_GATEWAY_WORKER_SCALE_UP_WINDOW_SECONDS must be between 5 and 600") } if c.WorkerScaleDownStabilizationSeconds != 0 && (c.WorkerScaleDownStabilizationSeconds < 60 || c.WorkerScaleDownStabilizationSeconds > 86400) { return errors.New("AI_GATEWAY_WORKER_SCALE_DOWN_STABILIZATION_SECONDS must be between 60 and 86400") } if c.WorkerDrainTimeoutSeconds != 0 && (c.WorkerDrainTimeoutSeconds < 60 || c.WorkerDrainTimeoutSeconds > 86400) { return errors.New("AI_GATEWAY_WORKER_DRAIN_TIMEOUT_SECONDS must be between 60 and 86400") } if (c.NodeMemoryTargetPercent != 0 || c.NodeMemoryHardPercent != 0) && (c.NodeMemoryTargetPercent < 50 || c.NodeMemoryTargetPercent > 90 || c.NodeMemoryHardPercent <= c.NodeMemoryTargetPercent || c.NodeMemoryHardPercent > 95) { return errors.New("Worker node memory target/hard percentages must be ordered within 50..95") } if c.NodeCPUTargetPercent != 0 && (c.NodeCPUTargetPercent < 40 || c.NodeCPUTargetPercent > 90) { return errors.New("AI_GATEWAY_NODE_CPU_TARGET_PERCENT must be between 40 and 90") } if c.PostgresConnectionBudget < 0 || c.PostgresConnectionBudget > 1000 { return errors.New("AI_GATEWAY_POSTGRES_CONNECTION_BUDGET must be between 1 and 1000") } if c.PostgresNonWorkerConnectionBudget < 0 || (c.PostgresConnectionBudget > 0 && c.PostgresNonWorkerConnectionBudget >= c.PostgresConnectionBudget) { return errors.New("AI_GATEWAY_POSTGRES_NON_WORKER_CONNECTION_BUDGET must be below the PostgreSQL connection budget") } if c.WorkerDatabaseMaxConns < 0 || c.WorkerDatabaseMaxConns > 256 { return errors.New("AI_GATEWAY_WORKER_DATABASE_MAX_CONNS must be between 1 and 256") } if c.TaskProgressCallbackTimeoutMS != 0 && (c.TaskProgressCallbackTimeoutMS < 100 || c.TaskProgressCallbackTimeoutMS > 60000) { return errors.New("TASK_PROGRESS_CALLBACK_TIMEOUT_MS must be between 100 and 60000") } if c.TaskProgressCallbackMaxAttempts != 0 && (c.TaskProgressCallbackMaxAttempts < 1 || c.TaskProgressCallbackMaxAttempts > 100) { return errors.New("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS must be between 1 and 100") } if c.TaskRetentionDays != 0 && (c.TaskRetentionDays < 1 || c.TaskRetentionDays > 3650) { return errors.New("AI_GATEWAY_TASK_RETENTION_DAYS must be between 1 and 3650") } if c.TaskAnalysisRetentionDays != 0 && (c.TaskAnalysisRetentionDays < 1 || (c.TaskRetentionDays != 0 && c.TaskAnalysisRetentionDays > c.TaskRetentionDays)) { return errors.New("AI_GATEWAY_TASK_ANALYSIS_RETENTION_DAYS must be between 1 and AI_GATEWAY_TASK_RETENTION_DAYS") } if c.TaskCleanupIntervalSeconds != 0 && c.TaskCleanupIntervalSeconds < 60 { return errors.New("AI_GATEWAY_TASK_CLEANUP_INTERVAL_SECONDS must be at least 60") } if c.TaskCleanupBatchSize != 0 && (c.TaskCleanupBatchSize < 100 || c.TaskCleanupBatchSize > 5000) { return errors.New("AI_GATEWAY_TASK_CLEANUP_BATCH_SIZE must be between 100 and 5000") } if c.LocalResultTTLHours != 0 && (c.LocalResultTTLHours < 1 || c.LocalResultTTLHours > 24*30) { return errors.New("AI_GATEWAY_LOCAL_RESULT_TTL_HOURS must be between 1 and 720") } if c.LocalResultMinFreeBytes < 0 { return errors.New("AI_GATEWAY_LOCAL_RESULT_MIN_FREE_BYTES must not be negative") } if c.LocalResultMaxBytes != 0 && c.LocalResultMaxBytes < 1024 { return errors.New("AI_GATEWAY_LOCAL_RESULT_MAX_BYTES must be at least 1024") } if c.LocalResultMaxTaskBytes != 0 && c.LocalResultMaxTaskBytes < c.LocalResultMaxBytes { return errors.New("AI_GATEWAY_LOCAL_RESULT_MAX_TASK_BYTES must be at least AI_GATEWAY_LOCAL_RESULT_MAX_BYTES") } switch strings.ToLower(strings.TrimSpace(c.IdentitySecretStore)) { case "": case "file": if strings.TrimSpace(c.IdentitySecretDir) == "" { return errors.New("IDENTITY_SECRET_DIR is required for the file SecretStore") } case "kubernetes": if strings.TrimSpace(c.IdentityKubernetesNamespace) == "" || strings.TrimSpace(c.IdentityKubernetesSecretName) == "" { return errors.New("Kubernetes identity SecretStore requires namespace and Secret name") } default: return errors.New("IDENTITY_SECRET_STORE must be file or kubernetes") } if c.IdentitySecurityEventHeartbeatIntervalSeconds != 0 || c.IdentitySecurityEventStaleAfterSeconds != 0 || c.IdentitySecurityEventClockSkewSeconds != 0 { if c.IdentitySecurityEventHeartbeatIntervalSeconds <= 0 || c.IdentitySecurityEventStaleAfterSeconds < 2*c.IdentitySecurityEventHeartbeatIntervalSeconds || c.IdentitySecurityEventClockSkewSeconds < 0 || c.IdentitySecurityEventClockSkewSeconds > 300 { return errors.New("identity security event heartbeat, stale threshold, or clock skew is invalid") } } return nil } func validMediaOSSBucket(value string) bool { value = strings.TrimSpace(value) if len(value) < 3 || len(value) > 63 { return false } for index, item := range value { if (item >= 'a' && item <= 'z') || (item >= '0' && item <= '9') || (item == '-' && index > 0 && index < len(value)-1) { continue } return false } return true } func validMediaOSSURL(value string, appEnv string) bool { parsed, err := url.Parse(strings.TrimSpace(value)) if err != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil { return false } if parsed.Scheme == "https" { return true } host := strings.ToLower(parsed.Hostname()) return !strings.EqualFold(strings.TrimSpace(appEnv), "production") && parsed.Scheme == "http" && (host == "127.0.0.1" || host == "localhost" || host == "::1") } func validMediaOSSObjectPrefix(value string) bool { value = strings.Trim(value, "/") if value == "" || len(value) > 256 || strings.Contains(value, "..") { return false } for _, item := range value { if (item >= 'a' && item <= 'z') || (item >= 'A' && item <= 'Z') || (item >= '0' && item <= '9') || item == '-' || item == '_' || item == '/' { continue } return false } return true } func (c Config) EffectiveProcessRole() string { role := strings.ToLower(strings.TrimSpace(c.ProcessRole)) if role == "" { return "all" } return role } func (c Config) RunsPublicHTTP() bool { switch c.EffectiveProcessRole() { case "worker", "capacity-controller": return false default: return true } } func (c Config) RunsAsyncExecutionWorker() bool { switch c.EffectiveProcessRole() { case "api", "capacity-controller": return false case "worker": return true default: return c.AsyncQueueWorkerEnabled } } func (c Config) RunsBackgroundWorkers() bool { switch c.EffectiveProcessRole() { case "api", "capacity-controller": return false default: return true } } func (c Config) RunsCapacityController() bool { return c.EffectiveProcessRole() == "capacity-controller" } type GlobalHTTPProxyStatus struct { HTTPProxy string Source string } func LoadGlobalHTTPProxyStatus() GlobalHTTPProxyStatus { for _, key := range []string{ "AI_GATEWAY_GLOBAL_HTTP_PROXY", "GLOBAL_HTTP_PROXY", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", } { if value := envValue(key); value != "" { return GlobalHTTPProxyStatus{HTTPProxy: value, Source: key} } } return GlobalHTTPProxyStatus{} } func gatewayDatabaseURL() string { if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" { return normalizePostgresURL(value) } if value := envValue("DATABASE_URL"); value != "" { return normalizePostgresURL(value) } if memoryURL := envValue("MEMORY_DATABASE_URL"); memoryURL != "" { return normalizePostgresURL(withDatabase(memoryURL, env("AI_GATEWAY_DATABASE_NAME", "easyai_ai_gateway"))) } return normalizePostgresURL("postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable") } func normalizePostgresURL(raw string) string { parsed, err := url.Parse(raw) if err != nil { return raw } values := parsed.Query() schema := values.Get("schema") if schema == "" { return raw } values.Del("schema") if values.Get("search_path") == "" { values.Set("search_path", schema) } parsed.RawQuery = values.Encode() return parsed.String() } func withDatabase(raw string, databaseName string) string { parsed, err := url.Parse(raw) if err != nil || databaseName == "" { return raw } parsed.Path = "/" + databaseName return parsed.String() } func envValue(key string) string { return strings.TrimSpace(os.Getenv(key)) } func env(key string, fallback string) string { if value := envValue(key); value != "" { return value } return fallback } func envInt(key string, fallback int) int { value := envValue(key) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func envIntValidated(key string, fallback int) int { value := envValue(key) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return 0 } return parsed } func envOptionalIntValidated(key string, fallback int) int { value := envValue(key) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return -1 } return parsed } func envInt64Validated(key string, fallback int64) int64 { value := envValue(key) if value == "" { return fallback } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil { return 0 } return parsed } func logLevel(value string) slog.Level { switch strings.ToLower(value) { case "debug": return slog.LevelDebug case "warn", "warning": return slog.LevelWarn case "error": return slog.LevelError default: return slog.LevelInfo } }