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 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 AsyncWorkerHardLimit int AsyncWorkerRefreshIntervalSeconds int } 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), 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")), AsyncWorkerHardLimit: envIntValidated("AI_GATEWAY_ASYNC_WORKER_HARD_LIMIT", 2048), AsyncWorkerRefreshIntervalSeconds: envIntValidated("AI_GATEWAY_ASYNC_WORKER_REFRESH_INTERVAL_SECONDS", 5), } } func (c Config) Validate() error { 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.AsyncWorkerRefreshIntervalSeconds < 1 { return errors.New("AI_GATEWAY_ASYNC_WORKER_REFRESH_INTERVAL_SECONDS must be positive") } 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") } 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 } 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 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 } }