summaryrefslogtreecommitdiff
path: root/internal/config/config.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/config/config.go')
-rw-r--r--internal/config/config.go311
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
+}