package config import ( "encoding/json" "errors" "fmt" "io" "net/url" "os" "strings" "time" "aigw/internal/domain" ) type Config struct { Server ServerConfig `json:"server"` Auth AuthConfig `json:"auth"` ControlPlane ControlPlaneConfig `json:"control_plane"` Admin AdminConfig `json:"admin"` UpstreamHTTP UpstreamHTTPConfig `json:"upstream_http"` Providers []ProviderConfig `json:"providers"` Models []ModelConfig `json:"models"` Observability ObservabilityConfig `json:"observability"` } type ServerConfig struct { Address string `json:"address"` MaxBodyBytes int64 `json:"max_body_bytes"` ReadHeaderTimeoutSecs int `json:"read_header_timeout_seconds"` IdleTimeoutSecs int `json:"idle_timeout_seconds"` ShutdownTimeoutSecs int `json:"shutdown_timeout_seconds"` } type AuthConfig struct { KeysEnv string `json:"keys_env"` AllowAnonymous bool `json:"allow_anonymous"` } type ControlPlaneConfig struct { Enabled bool `json:"enabled"` DatabaseURLEnv string `json:"database_url_env"` RedisURLEnv string `json:"redis_url_env"` CredentialKeyEnv string `json:"credential_key_env"` RedisChannel string `json:"redis_channel"` SnapshotCacheKey string `json:"snapshot_cache_key"` ReloadIntervalSeconds int `json:"reload_interval_seconds"` AutoMigrate bool `json:"auto_migrate"` DatabaseURL string `json:"-"` RedisURL string `json:"-"` CredentialKey string `json:"-"` } type AdminConfig struct { Enabled bool `json:"enabled"` TokenEnv string `json:"token_env"` BasePath string `json:"base_path"` Token string `json:"-"` } type UpstreamHTTPConfig struct { MaxIdleConnections int `json:"max_idle_connections"` MaxIdleConnectionsPerHost int `json:"max_idle_connections_per_host"` IdleConnectionTimeoutSecs int `json:"idle_connection_timeout_seconds"` ResponseHeaderTimeoutSecs int `json:"response_header_timeout_seconds"` } type ProviderConfig struct { ID string `json:"id"` Protocol domain.Protocol `json:"protocol"` BaseURL string `json:"base_url"` APIKeyEnv string `json:"api_key_env"` APIKey string `json:"-"` } type ModelConfig struct { ID string `json:"id"` OwnedBy string `json:"owned_by"` Routes []RouteConfig `json:"routes"` } type RouteConfig struct { Provider string `json:"provider"` UpstreamModel string `json:"upstream_model"` Priority int `json:"priority"` Weight int `json:"weight"` } type ObservabilityConfig struct { UsageBuffer int `json:"usage_buffer"` ExposeMetrics bool `json:"expose_metrics"` } func Load(path string) (Config, error) { f, err := os.Open(path) if err != nil { return Config{}, fmt.Errorf("open config: %w", err) } defer f.Close() var cfg Config decoder := json.NewDecoder(f) decoder.DisallowUnknownFields() if err := decoder.Decode(&cfg); err != nil { return Config{}, fmt.Errorf("decode config: %w", err) } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { if err == nil { return Config{}, errors.New("decode config: multiple JSON values") } return Config{}, fmt.Errorf("decode config trailing data: %w", err) } applyDefaults(&cfg) if err := resolveSecrets(&cfg); err != nil { return Config{}, err } if err := Validate(cfg); err != nil { return Config{}, err } return cfg, nil } func applyDefaults(cfg *Config) { if cfg.Server.Address == "" { cfg.Server.Address = ":8080" } if cfg.Server.MaxBodyBytes == 0 { cfg.Server.MaxBodyBytes = 16 << 20 } if cfg.Server.ReadHeaderTimeoutSecs == 0 { cfg.Server.ReadHeaderTimeoutSecs = 10 } if cfg.Server.IdleTimeoutSecs == 0 { cfg.Server.IdleTimeoutSecs = 120 } if cfg.Server.ShutdownTimeoutSecs == 0 { cfg.Server.ShutdownTimeoutSecs = 20 } if cfg.Auth.KeysEnv == "" { cfg.Auth.KeysEnv = "AIGW_API_KEYS" } if cfg.ControlPlane.DatabaseURLEnv == "" { cfg.ControlPlane.DatabaseURLEnv = "AIGW_DATABASE_URL" } if cfg.ControlPlane.RedisURLEnv == "" { cfg.ControlPlane.RedisURLEnv = "AIGW_REDIS_URL" } if cfg.ControlPlane.CredentialKeyEnv == "" { cfg.ControlPlane.CredentialKeyEnv = "AIGW_CREDENTIAL_KEY" } if cfg.ControlPlane.RedisChannel == "" { cfg.ControlPlane.RedisChannel = "aigw:control:changed" } if cfg.ControlPlane.SnapshotCacheKey == "" { cfg.ControlPlane.SnapshotCacheKey = "aigw:control:snapshot:v1" } if cfg.ControlPlane.ReloadIntervalSeconds == 0 { cfg.ControlPlane.ReloadIntervalSeconds = 30 } if cfg.Admin.TokenEnv == "" { cfg.Admin.TokenEnv = "AIGW_ADMIN_TOKEN" } if cfg.Admin.BasePath == "" { cfg.Admin.BasePath = "/admin" } if cfg.UpstreamHTTP.MaxIdleConnections == 0 { cfg.UpstreamHTTP.MaxIdleConnections = 4096 } if cfg.UpstreamHTTP.MaxIdleConnectionsPerHost == 0 { cfg.UpstreamHTTP.MaxIdleConnectionsPerHost = 1024 } if cfg.UpstreamHTTP.IdleConnectionTimeoutSecs == 0 { cfg.UpstreamHTTP.IdleConnectionTimeoutSecs = 90 } if cfg.UpstreamHTTP.ResponseHeaderTimeoutSecs == 0 { cfg.UpstreamHTTP.ResponseHeaderTimeoutSecs = 60 } if cfg.Observability.UsageBuffer == 0 { cfg.Observability.UsageBuffer = 8192 } for i := range cfg.Models { for j := range cfg.Models[i].Routes { if cfg.Models[i].Routes[j].Weight == 0 { cfg.Models[i].Routes[j].Weight = 1 } } } } func resolveSecrets(cfg *Config) error { if cfg.ControlPlane.Enabled { cfg.ControlPlane.DatabaseURL = os.Getenv(cfg.ControlPlane.DatabaseURLEnv) cfg.ControlPlane.RedisURL = os.Getenv(cfg.ControlPlane.RedisURLEnv) cfg.ControlPlane.CredentialKey = os.Getenv(cfg.ControlPlane.CredentialKeyEnv) } if cfg.Admin.Enabled { cfg.Admin.Token = os.Getenv(cfg.Admin.TokenEnv) } for i := range cfg.Providers { provider := &cfg.Providers[i] if provider.APIKeyEnv == "" { continue } provider.APIKey = os.Getenv(provider.APIKeyEnv) if provider.APIKey == "" { return fmt.Errorf("provider %q: environment variable %s is empty", provider.ID, provider.APIKeyEnv) } } return nil } func Validate(cfg Config) error { if cfg.Server.MaxBodyBytes < 1024 { return errors.New("server.max_body_bytes must be at least 1024") } if cfg.Observability.UsageBuffer < 1 { return errors.New("observability.usage_buffer must be positive") } if cfg.ControlPlane.Enabled { if cfg.ControlPlane.DatabaseURL == "" { return fmt.Errorf("control_plane: environment variable %s is empty", cfg.ControlPlane.DatabaseURLEnv) } if cfg.ControlPlane.CredentialKey == "" { return fmt.Errorf("control_plane: environment variable %s is empty", cfg.ControlPlane.CredentialKeyEnv) } if cfg.ControlPlane.ReloadIntervalSeconds < 1 { return errors.New("control_plane.reload_interval_seconds must be positive") } } if cfg.Admin.Enabled { if !cfg.ControlPlane.Enabled { return errors.New("admin requires control_plane.enabled") } if cfg.Admin.Token == "" { return fmt.Errorf("admin: environment variable %s is empty", cfg.Admin.TokenEnv) } if !strings.HasPrefix(cfg.Admin.BasePath, "/") || cfg.Admin.BasePath == "/" { return errors.New("admin.base_path must start with / and cannot be /") } } providers := make(map[string]ProviderConfig, len(cfg.Providers)) for _, provider := range cfg.Providers { if provider.ID == "" { return errors.New("provider id is required") } if _, exists := providers[provider.ID]; exists { return fmt.Errorf("duplicate provider id %q", provider.ID) } if provider.Protocol != domain.ProtocolOpenAI && provider.Protocol != domain.ProtocolAnthropic { return fmt.Errorf("provider %q: unsupported protocol %q", provider.ID, provider.Protocol) } parsed, err := url.Parse(provider.BaseURL) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return fmt.Errorf("provider %q: base_url must be an absolute http(s) URL", provider.ID) } if provider.APIKeyEnv == "" { return fmt.Errorf("provider %q: api_key_env is required", provider.ID) } providers[provider.ID] = provider } if len(providers) == 0 && !cfg.ControlPlane.Enabled { return errors.New("at least one provider is required") } models := make(map[string]struct{}, len(cfg.Models)) for _, model := range cfg.Models { if strings.TrimSpace(model.ID) == "" { return errors.New("model id is required") } if _, exists := models[model.ID]; exists { return fmt.Errorf("duplicate model id %q", model.ID) } models[model.ID] = struct{}{} if len(model.Routes) == 0 { return fmt.Errorf("model %q: at least one route is required", model.ID) } for _, route := range model.Routes { if _, exists := providers[route.Provider]; !exists { return fmt.Errorf("model %q: unknown provider %q", model.ID, route.Provider) } if route.UpstreamModel == "" { return fmt.Errorf("model %q: upstream_model is required", model.ID) } if route.Priority < 0 { return fmt.Errorf("model %q: route priority cannot be negative", model.ID) } if route.Weight < 1 || route.Weight > 100 { return fmt.Errorf("model %q: route weight must be between 1 and 100", model.ID) } } } if len(models) == 0 && !cfg.ControlPlane.Enabled { return errors.New("at least one model is required") } return nil } func (c ServerConfig) ReadHeaderTimeout() time.Duration { return time.Duration(c.ReadHeaderTimeoutSecs) * time.Second } func (c ServerConfig) IdleTimeout() time.Duration { return time.Duration(c.IdleTimeoutSecs) * time.Second } func (c ServerConfig) ShutdownTimeout() time.Duration { return time.Duration(c.ShutdownTimeoutSecs) * time.Second }