diff --git a/internal/config/config.go b/internal/config/config.go index e393c57..0ad7a69 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,21 +8,32 @@ import ( ) type Config struct { - HTTPAddr string - DatabaseURL string - RedisAddr string - RedisPassword string - ImportPath string - DemoUsername string - DemoPassword string - SessionSecret string - CursorSecret string - FrontendOrigin string - CookieSecure bool + AppEnv string + HTTPAddr string + DatabaseURL string + RedisAddr string + RedisPassword string + ImportPath string + DemoUsername string + DemoPassword string + SessionSecret string + CursorSecret string + FrontendOrigin string + CookieSecure bool + AIGrowthEnabled bool + AvalAIAPIKey string + AvalAIBaseURL string + AvalAIModel string + AIDailyRequestBudget int64 + AIDailyTokenBudget int64 + AIMaxConcurrent int + AICreatePerMinute int64 + AIUncachedScopesPerDay int64 } func Load() (Config, error) { cfg := Config{ + AppEnv: value("APP_ENV", "development"), HTTPAddr: value("HTTP_ADDR", "127.0.0.1:8080"), DatabaseURL: os.Getenv("DATABASE_URL"), RedisAddr: value("REDIS_ADDR", "localhost:6379"), @@ -33,6 +44,9 @@ func Load() (Config, error) { SessionSecret: os.Getenv("SESSION_SECRET"), CursorSecret: os.Getenv("CURSOR_SECRET"), FrontendOrigin: value("FRONTEND_ORIGIN", "http://localhost:5173"), + AvalAIAPIKey: os.Getenv("AVALAI_API_KEY"), + AvalAIBaseURL: value("AVALAI_BASE_URL", "https://api.avalai.ir"), + AvalAIModel: value("AVALAI_MODEL", "gpt-5-mini-2025-08-07"), } var err error @@ -40,6 +54,31 @@ func Load() (Config, error) { if err != nil { return Config{}, fmt.Errorf("parsing cookie secure: %w", err) } + cfg.AIGrowthEnabled, err = strconv.ParseBool(value("AI_GROWTH_ENABLED", "false")) + if err != nil { + return Config{}, fmt.Errorf("parsing AI growth enabled: %w", err) + } + cfg.AIDailyRequestBudget, err = parseInt64("AI_DAILY_REQUEST_BUDGET", 0) + if err != nil { + return Config{}, err + } + cfg.AIDailyTokenBudget, err = parseInt64("AI_DAILY_TOKEN_BUDGET", 0) + if err != nil { + return Config{}, err + } + maxConcurrent, err := parseInt64("AI_MAX_CONCURRENT", 4) + if err != nil { + return Config{}, err + } + cfg.AIMaxConcurrent = int(maxConcurrent) + cfg.AICreatePerMinute, err = parseInt64("AI_CREATE_PER_MINUTE", 10) + if err != nil { + return Config{}, err + } + cfg.AIUncachedScopesPerDay, err = parseInt64("AI_UNCACHED_SCOPES_PER_DAY", 5) + if err != nil { + return Config{}, err + } if cfg.DatabaseURL == "" { return Config{}, errors.New("config: DATABASE_URL is required") } @@ -56,6 +95,17 @@ func (c Config) ValidateAPI() error { if c.CursorSecret == c.SessionSecret { return errors.New("config: CURSOR_SECRET must differ from SESSION_SECRET") } + if c.AIMaxConcurrent < 1 || c.AICreatePerMinute < 1 || c.AIUncachedScopesPerDay < 1 { + return errors.New("config: AI limits must be positive") + } + if c.AppEnv == "production" && c.AIGrowthEnabled { + if c.AvalAIAPIKey == "" || c.AvalAIModel == "" { + return errors.New("config: enabled production AI requires AVALAI_API_KEY and AVALAI_MODEL") + } + if c.AIDailyRequestBudget < 1 || c.AIDailyTokenBudget < 1 { + return errors.New("config: enabled production AI requires positive daily budgets") + } + } return nil } @@ -72,3 +122,12 @@ func value(name, fallback string) string { } return fallback } + +func parseInt64(name string, fallback int64) (int64, error) { + raw := value(name, strconv.FormatInt(fallback, 10)) + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil || parsed < 0 { + return 0, fmt.Errorf("config: %s must be a non-negative integer", name) + } + return parsed, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fe33020..934fa05 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -33,3 +33,26 @@ func TestValidateAPIRequiresIndependentCursorSecret(t *testing.T) { t.Fatal("ValidateAPI() error = nil, want independent secrets error") } } + +func TestValidateAPIRequiresGrowthBudgetsInProduction(t *testing.T) { + t.Parallel() + cfg := Config{SessionSecret: "session-secret-that-is-at-least-32-bytes", CursorSecret: "cursor-secret-that-is-at-least-32-bytes", AppEnv: "production", AIGrowthEnabled: true, AvalAIAPIKey: "key", AvalAIModel: "model", AIMaxConcurrent: 4, AICreatePerMinute: 10, AIUncachedScopesPerDay: 5} + if err := cfg.ValidateAPI(); err == nil { + t.Fatal("ValidateAPI() error = nil, want AI budget error") + } + cfg.AIDailyRequestBudget, cfg.AIDailyTokenBudget = 10, 10_000 + if err := cfg.ValidateAPI(); err != nil { + t.Fatalf("ValidateAPI() error = %v", err) + } +} + +func TestLoadGrowthDefaultsAreCostSafe(t *testing.T) { + t.Setenv("DATABASE_URL", "postgresql://example") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.AIGrowthEnabled || cfg.AIDailyRequestBudget != 0 || cfg.AIDailyTokenBudget != 0 || cfg.AIMaxConcurrent != 4 { + t.Fatalf("unsafe AI defaults: %+v", cfg) + } +}