diff options
Diffstat (limited to 'internal/config/config.go')
| -rw-r--r-- | internal/config/config.go | 311 |
1 files changed, 311 insertions, 0 deletions
diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5e1e518 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,311 @@ +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 +} |
