Files

204 lines
7.3 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"strconv"
)
const (
AIProviderAvalAI = "avalai"
AIProviderAgentRouter = "agentrouter"
)
type Provider struct {
Name string
APIKey string
BaseURL string
Model string
}
type Config struct {
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
AICoachEnabled bool
AIPageInsightsEnabled bool
AIProvider string
AvalAIAPIKey string
AvalAIBaseURL string
AvalAIModel string
AgentRouterAPIKey string
AgentRouterBaseURL string
AgentRouterModel string
AIDailyRequestBudget int64
AIDailyTokenBudget int64
AIMaxConcurrent int
AICreatePerMinute int64
AIUncachedScopesPerDay int64
AICoachDailyRequestBudget int64
AICoachDailyTokenBudget int64
AIPageInsightsDailyRequestBudget int64
AIPageInsightsDailyTokenBudget 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"),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
ImportPath: value("IMPORT_PATH", "data/incoming/challenge_data.csv.gz"),
DemoUsername: value("DEMO_USERNAME", "merchant-0001"),
DemoPassword: os.Getenv("DEMO_PASSWORD"),
SessionSecret: os.Getenv("SESSION_SECRET"),
CursorSecret: os.Getenv("CURSOR_SECRET"),
FrontendOrigin: value("FRONTEND_ORIGIN", "http://localhost:5173"),
AIProvider: value("AI_PROVIDER", AIProviderAvalAI),
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"),
AgentRouterAPIKey: os.Getenv("AGENTROUTER_API_KEY"),
AgentRouterBaseURL: value("AGENTROUTER_BASE_URL", "https://agentrouter.org/v1"),
AgentRouterModel: value("AGENTROUTER_MODEL", "gpt-5.6-sol"),
}
if cfg.AIProvider != AIProviderAvalAI && cfg.AIProvider != AIProviderAgentRouter {
return Config{}, fmt.Errorf("config: unsupported AI_PROVIDER %q", cfg.AIProvider)
}
var err error
cfg.CookieSecure, err = strconv.ParseBool(value("COOKIE_SECURE", "false"))
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.AICoachEnabled, err = strconv.ParseBool(value("AI_COACH_ENABLED", strconv.FormatBool(cfg.AIGrowthEnabled)))
if err != nil {
return Config{}, fmt.Errorf("parsing AI coach enabled: %w", err)
}
cfg.AIPageInsightsEnabled, err = strconv.ParseBool(value("AI_PAGE_INSIGHTS_ENABLED", strconv.FormatBool(cfg.AIGrowthEnabled)))
if err != nil {
return Config{}, fmt.Errorf("parsing AI page insights 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
}
cfg.AICoachDailyRequestBudget, err = parseInt64("AI_COACH_DAILY_REQUEST_BUDGET", cfg.AIDailyRequestBudget)
if err != nil {
return Config{}, err
}
cfg.AICoachDailyTokenBudget, err = parseInt64("AI_COACH_DAILY_TOKEN_BUDGET", cfg.AIDailyTokenBudget)
if err != nil {
return Config{}, err
}
cfg.AIPageInsightsDailyRequestBudget, err = parseInt64("AI_PAGE_INSIGHTS_DAILY_REQUEST_BUDGET", cfg.AIDailyRequestBudget)
if err != nil {
return Config{}, err
}
cfg.AIPageInsightsDailyTokenBudget, err = parseInt64("AI_PAGE_INSIGHTS_DAILY_TOKEN_BUDGET", cfg.AIDailyTokenBudget)
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")
}
return cfg, nil
}
func (c Config) ValidateAPI() error {
if len(c.SessionSecret) < 32 {
return errors.New("config: SESSION_SECRET must contain at least 32 characters")
}
if len(c.CursorSecret) < 32 {
return errors.New("config: CURSOR_SECRET must contain at least 32 characters")
}
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.AIProvider != "" && c.AIProvider != AIProviderAvalAI && c.AIProvider != AIProviderAgentRouter {
return errors.New("config: unsupported AI provider")
}
if c.AppEnv == "production" && (c.AIGrowthEnabled || c.AICoachEnabled || c.AIPageInsightsEnabled) {
provider := c.SelectedAIProvider()
if provider.APIKey == "" || provider.Model == "" {
return errors.New("config: enabled production AI requires provider API key and model")
}
if c.AIGrowthEnabled && (c.AIDailyRequestBudget < 1 || c.AIDailyTokenBudget < 1) {
return errors.New("config: enabled production AI requires positive daily budgets")
}
if c.AICoachEnabled && (c.AICoachDailyRequestBudget < 1 || c.AICoachDailyTokenBudget < 1) {
return errors.New("config: enabled production coach requires positive coach budgets")
}
if c.AIPageInsightsEnabled && (c.AIPageInsightsDailyRequestBudget < 1 || c.AIPageInsightsDailyTokenBudget < 1) {
return errors.New("config: enabled production page insights require positive page-insight budgets")
}
}
return nil
}
func (c Config) SelectedAIProvider() Provider {
if c.AIProvider == AIProviderAgentRouter {
return Provider{Name: AIProviderAgentRouter, APIKey: c.AgentRouterAPIKey, BaseURL: c.AgentRouterBaseURL, Model: c.AgentRouterModel}
}
return Provider{Name: AIProviderAvalAI, APIKey: c.AvalAIAPIKey, BaseURL: c.AvalAIBaseURL, Model: c.AvalAIModel}
}
func (c Config) ValidateSeed() error {
if len(c.DemoPassword) < 12 {
return errors.New("config: DEMO_PASSWORD must contain at least 12 characters")
}
return nil
}
func value(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
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
}