diff --git a/.env.template b/.env.template
index e1fcfb573..80532d5de 100644
--- a/.env.template
+++ b/.env.template
@@ -1,5 +1,5 @@
# Server Configuration
-PORT=8080
+# PORT=8080
# Maximum request body size (prevents DoS attacks)
# Accepts values like "10M", "1G", "500K" (default: 10M)
@@ -27,7 +27,7 @@ PORT=8080
# Type:
# - "local" (default) for single instance,
# - "redis" for multiple instances
-CACHE_TYPE=local
+# CACHE_TYPE=local
# Redis Configuration (only used when CACHE_TYPE=redis)
# REDIS_URL=redis://localhost:6379
@@ -61,7 +61,7 @@ CACHE_TYPE=local
# Enable/disable audit logging (default: false)
# When enabled, all requests and responses are logged to the configured storage
-LOGGING_ENABLED=false
+# LOGGING_ENABLED=false
# Log full request/response bodies (default: false)
# WARNING: May contain PII, API keys in prompts, or sensitive data
diff --git a/.gitignore b/.gitignore
index e0488d042..bd540d9a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@
# Environment variables
/.env
+# User config (created from config/config.example.yaml)
+/config/config.yaml
+
# Cache
/.cache
diff --git a/config/config.example.yaml b/config/config.example.yaml
new file mode 100644
index 000000000..f696d97a2
--- /dev/null
+++ b/config/config.example.yaml
@@ -0,0 +1,101 @@
+# GOModel Configuration (optional)
+# Copy to config/config.yaml to customize.
+# Environment variables always override values in this file.
+# All settings have sensible defaults — no config file is required.
+
+server:
+ port: "8080"
+ master_key: "your-secret-key"
+ body_size_limit: "10M"
+
+cache:
+ type: "local" # "local" or "redis"
+ cache_dir: ".cache" # local cache directory
+ redis:
+ url: "redis://localhost:6379"
+ key: "gomodel:models"
+ ttl: 86400 # 24 hours in seconds
+
+storage:
+ type: "sqlite" # "sqlite", "postgresql", or "mongodb"
+ sqlite:
+ path: ".cache/gomodel.db"
+ postgresql:
+ url: "postgres://user:pass@localhost/gomodel"
+ max_conns: 10
+ mongodb:
+ url: "mongodb://localhost:27017"
+ database: "gomodel"
+
+logging:
+ enabled: false
+ log_bodies: true # WARNING: may contain sensitive data
+ log_headers: true
+ buffer_size: 1000
+ flush_interval: 5 # seconds
+ retention_days: 30 # 0 = keep forever
+ only_model_interactions: true
+
+usage:
+ enabled: true
+ enforce_returning_usage_data: true
+ buffer_size: 1000
+ flush_interval: 5
+ retention_days: 90
+
+metrics:
+ enabled: false
+ endpoint: "/metrics"
+
+http:
+ timeout: 600 # seconds (10 minutes)
+ response_header_timeout: 600
+
+providers:
+ openai:
+ type: openai
+ api_key: "sk-..."
+
+ anthropic:
+ type: anthropic
+ api_key: "sk-ant-..."
+
+ gemini:
+ type: gemini
+ api_key: "..."
+
+ xai:
+ type: xai
+ api_key: "..."
+
+ groq:
+ type: groq
+ api_key: "gsk_..."
+
+ ollama:
+ type: ollama
+ base_url: "http://localhost:11434/v1"
+
+ # Custom OpenAI-compatible provider
+ # my-provider:
+ # type: openai
+ # base_url: "https://api.example.com/v1"
+ # api_key: "..."
+
+ # Example: Groq (OpenAI-compatible)
+ # groq:
+ # type: "openai"
+ # base_url: "https://api.groq.com/openai/v1"
+ # api_key: "${GROQ_API_KEY}"
+
+ # Example: Azure OpenAI
+ # azure-openai:
+ # type: "openai"
+ # base_url: "https://your-resource.openai.azure.com/openai/deployments/your-deployment"
+ # api_key: "${AZURE_OPENAI_API_KEY}"
+
+ # Example: DeepSeek (OpenAI-compatible)
+ # deepseek:
+ # type: "openai"
+ # base_url: "https://api.deepseek.com/v1"
+ # api_key: "${DEEPSEEK_API_KEY}"
diff --git a/config/config.go b/config/config.go
index cb8501b1e..88c0cc3e8 100644
--- a/config/config.go
+++ b/config/config.go
@@ -8,9 +8,8 @@ import (
"strconv"
"strings"
- "github.com/go-viper/mapstructure/v2"
"github.com/joho/godotenv"
- "github.com/spf13/viper"
+ "gopkg.in/yaml.v3"
)
// Body size limit constants
@@ -25,330 +24,250 @@ var bodySizeLimitRegex = regexp.MustCompile(`(?i)^(\d+)([KMG])?B?$`)
// Config holds the application configuration
type Config struct {
- Server ServerConfig
- Cache CacheConfig
- Storage StorageConfig
- Logging LogConfig
- Usage UsageConfig
- Metrics MetricsConfig
- Providers map[string]ProviderConfig
+ Server ServerConfig `yaml:"server"`
+ Cache CacheConfig `yaml:"cache"`
+ Storage StorageConfig `yaml:"storage"`
+ Logging LogConfig `yaml:"logging"`
+ Usage UsageConfig `yaml:"usage"`
+ Metrics MetricsConfig `yaml:"metrics"`
+ HTTP HTTPConfig `yaml:"http"`
+ Providers map[string]ProviderConfig `yaml:"providers"`
+}
+
+// HTTPConfig holds HTTP client configuration for upstream API requests.
+// These values are also readable via the HTTP_TIMEOUT and HTTP_RESPONSE_HEADER_TIMEOUT
+// environment variables in internal/httpclient/client.go.
+type HTTPConfig struct {
+ // Timeout is the overall HTTP request timeout in seconds (default: 600)
+ Timeout int `yaml:"timeout"`
+
+ // ResponseHeaderTimeout is the time to wait for response headers in seconds (default: 600)
+ ResponseHeaderTimeout int `yaml:"response_header_timeout"`
}
// LogConfig holds audit logging configuration
type LogConfig struct {
// Enabled controls whether audit logging is active
// Default: false
- Enabled bool
+ Enabled bool `yaml:"enabled"`
// LogBodies enables logging of full request/response bodies
// WARNING: May contain sensitive data (PII, API keys in prompts)
// Default: true
- LogBodies bool
+ LogBodies bool `yaml:"log_bodies"`
// LogHeaders enables logging of request/response headers
// Sensitive headers (Authorization, Cookie, etc.) are auto-redacted
// Default: true
- LogHeaders bool
+ LogHeaders bool `yaml:"log_headers"`
// BufferSize is the number of log entries to buffer before flushing
// Default: 1000
- BufferSize int
+ BufferSize int `yaml:"buffer_size"`
// FlushInterval is how often to flush buffered logs (in seconds)
// Default: 5
- FlushInterval int
+ FlushInterval int `yaml:"flush_interval"`
// RetentionDays is how long to keep logs (0 = forever)
// Default: 30
- RetentionDays int
+ RetentionDays int `yaml:"retention_days"`
// OnlyModelInteractions limits audit logging to AI model endpoints only
// When true, only /v1/chat/completions and /v1/responses are logged
// Endpoints like /health, /metrics, /admin, /v1/models are skipped
// Default: true
- OnlyModelInteractions bool
+ OnlyModelInteractions bool `yaml:"only_model_interactions"`
}
// UsageConfig holds token usage tracking configuration
type UsageConfig struct {
// Enabled controls whether usage tracking is active
// Default: true
- Enabled bool
+ Enabled bool `yaml:"enabled"`
// EnforceReturningUsageData controls whether to enforce returning usage data in streaming responses.
// When true, stream_options: {"include_usage": true} is automatically added to streaming requests.
// Default: true
- EnforceReturningUsageData bool
+ EnforceReturningUsageData bool `yaml:"enforce_returning_usage_data"`
// BufferSize is the number of usage entries to buffer before flushing
// Default: 1000
- BufferSize int
+ BufferSize int `yaml:"buffer_size"`
// FlushInterval is how often to flush buffered usage entries (in seconds)
// Default: 5
- FlushInterval int
+ FlushInterval int `yaml:"flush_interval"`
// RetentionDays is how long to keep usage data (0 = forever)
// Default: 90
- RetentionDays int
+ RetentionDays int `yaml:"retention_days"`
}
// StorageConfig holds database storage configuration (used by audit logging, usage tracking, future IAM, etc.)
type StorageConfig struct {
// Type specifies the storage backend: "sqlite" (default), "postgresql", or "mongodb"
- Type string
+ Type string `yaml:"type"`
// SQLite configuration
- SQLite SQLiteStorageConfig
+ SQLite SQLiteStorageConfig `yaml:"sqlite"`
// PostgreSQL configuration
- PostgreSQL PostgreSQLStorageConfig
+ PostgreSQL PostgreSQLStorageConfig `yaml:"postgresql"`
// MongoDB configuration
- MongoDB MongoDBStorageConfig
+ MongoDB MongoDBStorageConfig `yaml:"mongodb"`
}
// SQLiteStorageConfig holds SQLite-specific storage configuration
type SQLiteStorageConfig struct {
// Path is the database file path (default: .cache/gomodel.db)
- Path string
+ Path string `yaml:"path"`
}
// PostgreSQLStorageConfig holds PostgreSQL-specific storage configuration
type PostgreSQLStorageConfig struct {
// URL is the connection string (e.g., postgres://user:pass@localhost/dbname)
- URL string
+ URL string `yaml:"url"`
// MaxConns is the maximum connection pool size (default: 10)
- MaxConns int
+ MaxConns int `yaml:"max_conns"`
}
// MongoDBStorageConfig holds MongoDB-specific storage configuration
type MongoDBStorageConfig struct {
// URL is the connection string (e.g., mongodb://localhost:27017)
- URL string
+ URL string `yaml:"url"`
// Database is the database name (default: gomodel)
- Database string
+ Database string `yaml:"database"`
}
// CacheConfig holds cache configuration for model storage
type CacheConfig struct {
// Type specifies the cache backend: "local" (default) or "redis"
- Type string
+ Type string `yaml:"type"`
+
+ // CacheDir is the directory for local cache files (default: ".cache")
+ CacheDir string `yaml:"cache_dir"`
// Redis configuration (only used when Type is "redis")
- Redis RedisConfig
+ Redis RedisConfig `yaml:"redis"`
}
// RedisConfig holds Redis-specific configuration
type RedisConfig struct {
// URL is the Redis connection URL (e.g., "redis://localhost:6379")
- URL string
+ URL string `yaml:"url"`
// Key is the Redis key for storing the model cache (default: "gomodel:models")
- Key string
+ Key string `yaml:"key"`
// TTL is the time-to-live for cached data in seconds (default: 86400 = 24 hours)
- TTL int
+ TTL int `yaml:"ttl"`
}
// ServerConfig holds HTTP server configuration
type ServerConfig struct {
- Port string
- MasterKey string // Optional: Master key for authentication
- BodySizeLimit string // Max request body size (e.g., "10M", "1024K")
+ Port string `yaml:"port"`
+ MasterKey string `yaml:"master_key"` // Optional: Master key for authentication
+ BodySizeLimit string `yaml:"body_size_limit"` // Max request body size (e.g., "10M", "1024K")
}
// MetricsConfig holds observability configuration for Prometheus metrics
type MetricsConfig struct {
// Enabled controls whether Prometheus metrics are collected and exposed
// Default: false
- Enabled bool
+ Enabled bool `yaml:"enabled"`
// Endpoint is the HTTP path where metrics are exposed
// Default: "/metrics"
- Endpoint string
+ Endpoint string `yaml:"endpoint"`
}
// ProviderConfig holds generic provider configuration
type ProviderConfig struct {
- Type string // e.g., "openai", "anthropic", "gemini"
- APIKey string // API key for authentication
- BaseURL string // Optional: override default base URL
- Models []string // Optional: restrict to specific models
-}
-
-// snakeCaseMatchName returns a DecoderConfigOption that matches
-// snake_case map keys to PascalCase struct field names.
-func snakeCaseMatchName() viper.DecoderConfigOption {
- return func(c *mapstructure.DecoderConfig) {
- c.MatchName = func(mapKey, fieldName string) bool {
- // Reject malformed keys: leading/trailing underscores or consecutive underscores
- if strings.HasPrefix(mapKey, "_") || strings.HasSuffix(mapKey, "_") {
- return false
- }
- if strings.Contains(mapKey, "__") {
- return false
- }
-
- // Remove underscores and compare case-insensitively
- normalizedKey := strings.ReplaceAll(mapKey, "_", "")
- return strings.EqualFold(normalizedKey, fieldName)
- }
- }
+ Type string `yaml:"type"` // e.g., "openai", "anthropic", "gemini"
+ APIKey string `yaml:"api_key"` // API key for authentication
+ BaseURL string `yaml:"base_url"` // Optional: override default base URL
+ Models []string `yaml:"models"` // Optional: restrict to specific models
}
-// Load reads configuration from file and environment
-func Load() (*Config, error) {
- // Load .env file directly into environment variables
- // This ensures os.Getenv works for variables defined in .env
- _ = godotenv.Load() // Ignore error (e.g., file not found)
-
- // Load .env file using Viper (optional, won't fail if not found)
- viper.SetConfigName(".env")
-
- viper.SetConfigType("env")
- viper.AddConfigPath(".")
- _ = viper.ReadInConfig() // Ignore error if .env file doesn't exist
-
- // Set defaults
- viper.SetDefault("server.port", "8080")
- viper.SetDefault("cache.type", "local")
- viper.SetDefault("cache.redis.key", "gomodel:models")
- viper.SetDefault("cache.redis.ttl", 86400) // 24 hours
- viper.SetDefault("metrics.enabled", false)
- viper.SetDefault("metrics.endpoint", "/metrics")
-
- // Storage defaults
- viper.SetDefault("storage.type", "sqlite")
- viper.SetDefault("storage.sqlite.path", ".cache/gomodel.db")
- viper.SetDefault("storage.postgresql.max_conns", 10)
- viper.SetDefault("storage.mongodb.database", "gomodel")
-
- // Logging defaults
- viper.SetDefault("logging.enabled", false)
- viper.SetDefault("logging.log_bodies", true)
- viper.SetDefault("logging.log_headers", true)
- viper.SetDefault("logging.buffer_size", 1000)
- viper.SetDefault("logging.flush_interval", 5)
- viper.SetDefault("logging.retention_days", 30)
- viper.SetDefault("logging.only_model_interactions", true)
-
- // Usage tracking defaults
- viper.SetDefault("usage.enabled", true)
- viper.SetDefault("usage.enforce_returning_usage_data", true)
- viper.SetDefault("usage.buffer_size", 1000)
- viper.SetDefault("usage.flush_interval", 5)
- viper.SetDefault("usage.retention_days", 90)
-
- // Enable automatic environment variable reading
- viper.AutomaticEnv()
-
- // Try to read config.yaml
- viper.SetConfigName("config")
- viper.SetConfigType("yaml")
- viper.AddConfigPath("./config")
- viper.AddConfigPath(".")
-
- var cfg Config
-
- // Read config file (optional, won't fail if not found)
- if err := viper.ReadInConfig(); err == nil {
- // Config file found, unmarshal it
- if err := viper.Unmarshal(&cfg, snakeCaseMatchName()); err != nil {
- return nil, err
- }
- // Expand environment variables in config values
- cfg = expandEnvVars(cfg)
- // Remove providers with unresolved environment variables
- cfg = removeEmptyProviders(cfg)
- } else {
- // No config file, use environment variables (legacy support)
- cfg = Config{
- Server: ServerConfig{
- Port: viper.GetString("PORT"),
- MasterKey: viper.GetString("GOMODEL_MASTER_KEY"),
- BodySizeLimit: viper.GetString("BODY_SIZE_LIMIT"),
- },
- Storage: StorageConfig{
- Type: getEnvOrDefault("STORAGE_TYPE", "sqlite"),
- SQLite: SQLiteStorageConfig{
- Path: getEnvOrDefault("SQLITE_PATH", ".cache/gomodel.db"),
- },
- PostgreSQL: PostgreSQLStorageConfig{
- URL: os.Getenv("POSTGRES_URL"),
- MaxConns: getEnvIntOrDefault("POSTGRES_MAX_CONNS", 10),
- },
- MongoDB: MongoDBStorageConfig{
- URL: os.Getenv("MONGODB_URL"),
- Database: getEnvOrDefault("MONGODB_DATABASE", "gomodel"),
- },
+// defaultConfig returns the single source of truth for all configuration defaults.
+func defaultConfig() Config {
+ return Config{
+ Server: ServerConfig{Port: "8080"},
+ Cache: CacheConfig{
+ Type: "local",
+ CacheDir: ".cache",
+ Redis: RedisConfig{
+ Key: "gomodel:models",
+ TTL: 86400,
},
- Logging: LogConfig{
- Enabled: getEnvBool("LOGGING_ENABLED"),
- LogBodies: getEnvBoolOrDefault("LOGGING_LOG_BODIES", true),
- LogHeaders: getEnvBoolOrDefault("LOGGING_LOG_HEADERS", true),
- BufferSize: getEnvIntOrDefault("LOGGING_BUFFER_SIZE", 1000),
- FlushInterval: getEnvIntOrDefault("LOGGING_FLUSH_INTERVAL", 5),
- RetentionDays: getEnvIntOrDefault("LOGGING_RETENTION_DAYS", 30),
- OnlyModelInteractions: getEnvBoolOrDefault("LOGGING_ONLY_MODEL_INTERACTIONS", true),
+ },
+ Storage: StorageConfig{
+ Type: "sqlite",
+ SQLite: SQLiteStorageConfig{
+ Path: ".cache/gomodel.db",
},
- Usage: UsageConfig{
- Enabled: getEnvBoolOrDefault("USAGE_ENABLED", true),
- EnforceReturningUsageData: getEnvBoolOrDefault("ENFORCE_RETURNING_USAGE_DATA", true),
- BufferSize: getEnvIntOrDefault("USAGE_BUFFER_SIZE", 1000),
- FlushInterval: getEnvIntOrDefault("USAGE_FLUSH_INTERVAL", 5),
- RetentionDays: getEnvIntOrDefault("USAGE_RETENTION_DAYS", 90),
+ PostgreSQL: PostgreSQLStorageConfig{
+ MaxConns: 10,
},
- Metrics: MetricsConfig{
- Enabled: viper.GetBool("METRICS_ENABLED"),
- Endpoint: viper.GetString("METRICS_ENDPOINT"),
+ MongoDB: MongoDBStorageConfig{
+ Database: "gomodel",
},
- Providers: make(map[string]ProviderConfig),
- }
+ },
+ Logging: LogConfig{
+ LogBodies: true,
+ LogHeaders: true,
+ BufferSize: 1000,
+ FlushInterval: 5,
+ RetentionDays: 30,
+ OnlyModelInteractions: true,
+ },
+ Usage: UsageConfig{
+ Enabled: true,
+ EnforceReturningUsageData: true,
+ BufferSize: 1000,
+ FlushInterval: 5,
+ RetentionDays: 90,
+ },
+ Metrics: MetricsConfig{
+ Endpoint: "/metrics",
+ },
+ HTTP: HTTPConfig{
+ Timeout: 600,
+ ResponseHeaderTimeout: 600,
+ },
+ Providers: make(map[string]ProviderConfig),
+ }
+}
- // TODO: Similarly for ENV variables. All ENV variables like *_API_KEY should be taken and iterated over
- // Add providers from environment variables if available
- if apiKey := viper.GetString("OPENAI_API_KEY"); apiKey != "" {
- cfg.Providers["openai"] = ProviderConfig{
- Type: "openai",
- APIKey: apiKey,
- }
- }
- if apiKey := viper.GetString("ANTHROPIC_API_KEY"); apiKey != "" {
- cfg.Providers["anthropic"] = ProviderConfig{
- Type: "anthropic",
- APIKey: apiKey,
- }
- }
- if apiKey := viper.GetString("GEMINI_API_KEY"); apiKey != "" {
- cfg.Providers["gemini"] = ProviderConfig{
- Type: "gemini",
- APIKey: apiKey,
- }
- }
- if apiKey := viper.GetString("XAI_API_KEY"); apiKey != "" {
- cfg.Providers["xai"] = ProviderConfig{
- Type: "xai",
- APIKey: apiKey,
- }
- }
- if apiKey := viper.GetString("GROQ_API_KEY"); apiKey != "" {
- cfg.Providers["groq"] = ProviderConfig{
- Type: "groq",
- APIKey: apiKey,
- }
- }
- // Ollama (no API key required, enabled via base URL)
- if baseURL := viper.GetString("OLLAMA_BASE_URL"); baseURL != "" {
- cfg.Providers["ollama"] = ProviderConfig{
- Type: "ollama",
- APIKey: "", // Not required
- BaseURL: baseURL,
- }
- }
+// Load reads configuration from file and environment using a three-layer pipeline:
+//
+// defaults (code) → config.yaml (optional overlay) → env vars (always win)
+//
+// Every run follows the same code path regardless of whether config.yaml exists.
+func Load() (*Config, error) {
+ // 1. Load .env into process env (ignore if not found)
+ _ = godotenv.Load()
+
+ // 2. Start with compiled defaults
+ cfg := defaultConfig()
+
+ // 3. Optional YAML overlay
+ if err := applyYAML(&cfg); err != nil {
+ return nil, err
}
- // Validate body size limit if provided
+ // 4. Env vars always win
+ applyEnvVars(&cfg)
+
+ // 5. Discover providers from env
+ applyProviderEnvVars(&cfg)
+
+ // 6. Filter invalid providers
+ removeEmptyProviders(&cfg)
+
+ // 7. Validate
if cfg.Server.BodySizeLimit != "" {
if err := ValidateBodySizeLimit(cfg.Server.BodySizeLimit); err != nil {
return nil, fmt.Errorf("invalid BODY_SIZE_LIMIT: %w", err)
@@ -358,184 +277,220 @@ func Load() (*Config, error) {
return &cfg, nil
}
-// expandEnvVars expands environment variable references in configuration values
-func expandEnvVars(cfg Config) Config {
- // Expand server config
- cfg.Server.Port = expandString(cfg.Server.Port)
- cfg.Server.MasterKey = expandString(cfg.Server.MasterKey)
- cfg.Server.BodySizeLimit = expandString(cfg.Server.BodySizeLimit)
-
- // Expand metrics configuration
- // Check METRICS_ENABLED env var - it should override YAML config
- if metricsEnabled := os.Getenv("METRICS_ENABLED"); metricsEnabled != "" {
- cfg.Metrics.Enabled = strings.EqualFold(metricsEnabled, "true") || metricsEnabled == "1"
- }
- cfg.Metrics.Endpoint = expandString(cfg.Metrics.Endpoint)
-
- // Expand cache configuration
- cfg.Cache.Type = expandString(cfg.Cache.Type)
- cfg.Cache.Redis.URL = expandString(cfg.Cache.Redis.URL)
- cfg.Cache.Redis.Key = expandString(cfg.Cache.Redis.Key)
-
- // Expand storage configuration
- cfg.Storage.Type = expandString(cfg.Storage.Type)
- cfg.Storage.SQLite.Path = expandString(cfg.Storage.SQLite.Path)
- cfg.Storage.PostgreSQL.URL = expandString(cfg.Storage.PostgreSQL.URL)
- cfg.Storage.MongoDB.URL = expandString(cfg.Storage.MongoDB.URL)
- cfg.Storage.MongoDB.Database = expandString(cfg.Storage.MongoDB.Database)
-
- // Override storage configuration from environment variables
- // This allows env vars to take precedence over config file values
- if storageType := os.Getenv("STORAGE_TYPE"); storageType != "" {
- cfg.Storage.Type = storageType
- }
- if sqlitePath := os.Getenv("SQLITE_PATH"); sqlitePath != "" {
- cfg.Storage.SQLite.Path = sqlitePath
- }
- if postgresURL := os.Getenv("POSTGRES_URL"); postgresURL != "" {
- cfg.Storage.PostgreSQL.URL = postgresURL
+// applyYAML reads an optional config.yaml and overlays it onto cfg.
+// If no config file is found, this is a no-op (not an error).
+func applyYAML(cfg *Config) error {
+ // Search paths: config/config.yaml then ./config.yaml
+ paths := []string{
+ "config/config.yaml",
+ "config.yaml",
}
- if postgresMaxConns := os.Getenv("POSTGRES_MAX_CONNS"); postgresMaxConns != "" {
- if maxConns, err := strconv.Atoi(postgresMaxConns); err == nil {
- cfg.Storage.PostgreSQL.MaxConns = maxConns
+
+ var data []byte
+ for _, p := range paths {
+ raw, err := os.ReadFile(p)
+ if err == nil {
+ data = raw
+ break
}
}
- if mongoURL := os.Getenv("MONGODB_URL"); mongoURL != "" {
- cfg.Storage.MongoDB.URL = mongoURL
- }
- if mongoDatabase := os.Getenv("MONGODB_DATABASE"); mongoDatabase != "" {
- cfg.Storage.MongoDB.Database = mongoDatabase
- }
- // Override logging configuration from environment variables
- if loggingEnabled := os.Getenv("LOGGING_ENABLED"); loggingEnabled != "" {
- cfg.Logging.Enabled = strings.EqualFold(loggingEnabled, "true") || loggingEnabled == "1"
+ if data == nil {
+ return nil // No config file found — not an error
}
- if logBodies := os.Getenv("LOGGING_LOG_BODIES"); logBodies != "" {
- cfg.Logging.LogBodies = strings.EqualFold(logBodies, "true") || logBodies == "1"
- }
- if logHeaders := os.Getenv("LOGGING_LOG_HEADERS"); logHeaders != "" {
- cfg.Logging.LogHeaders = strings.EqualFold(logHeaders, "true") || logHeaders == "1"
- }
- if onlyModel := os.Getenv("LOGGING_ONLY_MODEL_INTERACTIONS"); onlyModel != "" {
- cfg.Logging.OnlyModelInteractions = strings.EqualFold(onlyModel, "true") || onlyModel == "1"
+
+ // Expand ${VAR} and ${VAR:-default} before YAML parsing
+ expanded := expandString(string(data))
+
+ // Unmarshal into the existing cfg — unset YAML fields preserve defaults
+ if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
+ return fmt.Errorf("failed to parse config.yaml: %w", err)
}
- // Override usage tracking configuration from environment variables
- if usageEnabled := os.Getenv("USAGE_ENABLED"); usageEnabled != "" {
- cfg.Usage.Enabled = strings.EqualFold(usageEnabled, "true") || usageEnabled == "1"
+ // Ensure Providers map is initialized even if YAML had none
+ if cfg.Providers == nil {
+ cfg.Providers = make(map[string]ProviderConfig)
}
- if enforceUsage := os.Getenv("ENFORCE_RETURNING_USAGE_DATA"); enforceUsage != "" {
- cfg.Usage.EnforceReturningUsageData = strings.EqualFold(enforceUsage, "true") || enforceUsage == "1"
+
+ return nil
+}
+
+// envStringMapping maps an environment variable to a Config string field setter.
+type envStringMapping struct {
+ key string
+ set func(*Config, string)
+}
+
+// envBoolMapping maps an environment variable to a Config bool field setter.
+type envBoolMapping struct {
+ key string
+ set func(*Config, bool)
+}
+
+// envIntMapping maps an environment variable to a Config int field setter.
+type envIntMapping struct {
+ key string
+ set func(*Config, int)
+}
+
+var envStringMappings = []envStringMapping{
+ {"PORT", func(c *Config, v string) { c.Server.Port = v }},
+ {"GOMODEL_MASTER_KEY", func(c *Config, v string) { c.Server.MasterKey = v }},
+ {"BODY_SIZE_LIMIT", func(c *Config, v string) { c.Server.BodySizeLimit = v }},
+ {"CACHE_TYPE", func(c *Config, v string) { c.Cache.Type = v }},
+ {"GOMODEL_CACHE_DIR", func(c *Config, v string) { c.Cache.CacheDir = v }},
+ {"REDIS_URL", func(c *Config, v string) { c.Cache.Redis.URL = v }},
+ {"REDIS_KEY", func(c *Config, v string) { c.Cache.Redis.Key = v }},
+ {"STORAGE_TYPE", func(c *Config, v string) { c.Storage.Type = v }},
+ {"SQLITE_PATH", func(c *Config, v string) { c.Storage.SQLite.Path = v }},
+ {"POSTGRES_URL", func(c *Config, v string) { c.Storage.PostgreSQL.URL = v }},
+ {"MONGODB_URL", func(c *Config, v string) { c.Storage.MongoDB.URL = v }},
+ {"MONGODB_DATABASE", func(c *Config, v string) { c.Storage.MongoDB.Database = v }},
+ {"METRICS_ENDPOINT", func(c *Config, v string) { c.Metrics.Endpoint = v }},
+}
+
+var envBoolMappings = []envBoolMapping{
+ {"METRICS_ENABLED", func(c *Config, v bool) { c.Metrics.Enabled = v }},
+ {"LOGGING_ENABLED", func(c *Config, v bool) { c.Logging.Enabled = v }},
+ {"LOGGING_LOG_BODIES", func(c *Config, v bool) { c.Logging.LogBodies = v }},
+ {"LOGGING_LOG_HEADERS", func(c *Config, v bool) { c.Logging.LogHeaders = v }},
+ {"LOGGING_ONLY_MODEL_INTERACTIONS", func(c *Config, v bool) { c.Logging.OnlyModelInteractions = v }},
+ {"USAGE_ENABLED", func(c *Config, v bool) { c.Usage.Enabled = v }},
+ {"ENFORCE_RETURNING_USAGE_DATA", func(c *Config, v bool) { c.Usage.EnforceReturningUsageData = v }},
+}
+
+var envIntMappings = []envIntMapping{
+ {"REDIS_TTL", func(c *Config, v int) { c.Cache.Redis.TTL = v }},
+ {"POSTGRES_MAX_CONNS", func(c *Config, v int) { c.Storage.PostgreSQL.MaxConns = v }},
+ {"LOGGING_BUFFER_SIZE", func(c *Config, v int) { c.Logging.BufferSize = v }},
+ {"LOGGING_FLUSH_INTERVAL", func(c *Config, v int) { c.Logging.FlushInterval = v }},
+ {"LOGGING_RETENTION_DAYS", func(c *Config, v int) { c.Logging.RetentionDays = v }},
+ {"USAGE_BUFFER_SIZE", func(c *Config, v int) { c.Usage.BufferSize = v }},
+ {"USAGE_FLUSH_INTERVAL", func(c *Config, v int) { c.Usage.FlushInterval = v }},
+ {"USAGE_RETENTION_DAYS", func(c *Config, v int) { c.Usage.RetentionDays = v }},
+ {"HTTP_TIMEOUT", func(c *Config, v int) { c.HTTP.Timeout = v }},
+ {"HTTP_RESPONSE_HEADER_TIMEOUT", func(c *Config, v int) { c.HTTP.ResponseHeaderTimeout = v }},
+}
+
+// applyEnvVars applies environment variable overrides onto cfg.
+// Only set env vars override; unset env vars leave cfg untouched.
+func applyEnvVars(cfg *Config) {
+ for _, m := range envStringMappings {
+ if v := os.Getenv(m.key); v != "" {
+ m.set(cfg, v)
+ }
}
- if usageBufferSize := os.Getenv("USAGE_BUFFER_SIZE"); usageBufferSize != "" {
- if bufferSize, err := strconv.Atoi(usageBufferSize); err == nil {
- cfg.Usage.BufferSize = bufferSize
+ for _, m := range envBoolMappings {
+ if v := os.Getenv(m.key); v != "" {
+ m.set(cfg, parseBool(v))
}
}
- if usageFlushInterval := os.Getenv("USAGE_FLUSH_INTERVAL"); usageFlushInterval != "" {
- if flushInterval, err := strconv.Atoi(usageFlushInterval); err == nil {
- cfg.Usage.FlushInterval = flushInterval
+ for _, m := range envIntMappings {
+ if v := os.Getenv(m.key); v != "" {
+ if n, err := strconv.Atoi(v); err == nil {
+ m.set(cfg, n)
+ }
}
}
- if usageRetentionDays := os.Getenv("USAGE_RETENTION_DAYS"); usageRetentionDays != "" {
- if retentionDays, err := strconv.Atoi(usageRetentionDays); err == nil {
- cfg.Usage.RetentionDays = retentionDays
+}
+
+// knownProvider describes a provider that can be auto-discovered from environment variables.
+type knownProvider struct {
+ apiKeyEnv string
+ baseURLEnv string
+ name string
+ providerType string
+}
+
+var knownProviders = []knownProvider{
+ {"OPENAI_API_KEY", "OPENAI_BASE_URL", "openai", "openai"},
+ {"ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "anthropic", "anthropic"},
+ {"GEMINI_API_KEY", "GEMINI_BASE_URL", "gemini", "gemini"},
+ {"XAI_API_KEY", "XAI_BASE_URL", "xai", "xai"},
+ {"GROQ_API_KEY", "GROQ_BASE_URL", "groq", "groq"},
+ {"OLLAMA_API_KEY", "OLLAMA_BASE_URL", "ollama", "ollama"},
+}
+
+// applyProviderEnvVars discovers providers from well-known environment variables.
+// Env vars override YAML-provided values for the same provider name.
+func applyProviderEnvVars(cfg *Config) {
+ for _, kp := range knownProviders {
+ apiKey := os.Getenv(kp.apiKeyEnv)
+ baseURL := os.Getenv(kp.baseURLEnv)
+
+ // Skip if no env vars set for this provider
+ if apiKey == "" && baseURL == "" {
+ continue
+ }
+
+ // Ollama special case: no API key required, enabled via base URL
+ if kp.providerType == "ollama" && apiKey == "" && baseURL == "" {
+ continue
+ }
+
+ existing, exists := cfg.Providers[kp.name]
+ if exists {
+ // Override existing provider's env-sourced values
+ if apiKey != "" {
+ existing.APIKey = apiKey
+ }
+ if baseURL != "" {
+ existing.BaseURL = baseURL
+ }
+ cfg.Providers[kp.name] = existing
+ } else {
+ // Add new provider from env
+ cfg.Providers[kp.name] = ProviderConfig{
+ Type: kp.providerType,
+ APIKey: apiKey,
+ BaseURL: baseURL,
+ }
}
}
+}
- // Expand provider configurations
+// removeEmptyProviders removes providers that have no valid credentials.
+func removeEmptyProviders(cfg *Config) {
for name, pCfg := range cfg.Providers {
- pCfg.APIKey = expandString(pCfg.APIKey)
- pCfg.BaseURL = expandString(pCfg.BaseURL)
- cfg.Providers[name] = pCfg
+ // Preserve Ollama providers with a non-empty BaseURL (no API key required)
+ if pCfg.Type == "ollama" && pCfg.BaseURL != "" {
+ continue
+ }
+ // Remove provider if API key is empty or contains unexpanded placeholders
+ if pCfg.APIKey == "" || strings.Contains(pCfg.APIKey, "${") {
+ delete(cfg.Providers, name)
+ }
}
-
- return cfg
}
-// expandString expands environment variable references like ${VAR_NAME} or ${VAR_NAME:-default} in a string
+// expandString expands environment variable references like ${VAR} or ${VAR:-default} in a string.
func expandString(s string) string {
if s == "" {
return s
}
return os.Expand(s, func(key string) string {
- // Check for default value syntax ${VAR:-default}
varname := key
defaultValue := ""
hasDefault := false
- if strings.Contains(key, ":-") {
- parts := strings.SplitN(key, ":-", 2)
- varname = parts[0]
- defaultValue = parts[1]
+ if idx := strings.Index(key, ":-"); idx >= 0 {
+ varname = key[:idx]
+ defaultValue = key[idx+2:]
hasDefault = true
}
-
- // Try to get from environment
value := os.Getenv(varname)
if value == "" {
- // If default syntax was used (even with empty default), return the default
if hasDefault {
return defaultValue
}
- // If not in environment and no default syntax, return the original placeholder
- // This allows config to work with or without env vars
return "${" + key + "}"
}
return value
})
}
-// removeEmptyProviders removes providers with empty API keys
-func removeEmptyProviders(cfg Config) Config {
- filteredProviders := make(map[string]ProviderConfig)
- for name, pCfg := range cfg.Providers {
- // Preserve Ollama providers with a non-empty BaseURL (no API key required)
- if pCfg.Type == "ollama" && pCfg.BaseURL != "" {
- filteredProviders[name] = pCfg
- continue
- }
- // Keep provider only if API key doesn't contain unexpanded placeholders
- if pCfg.APIKey != "" && !strings.Contains(pCfg.APIKey, "${") {
- filteredProviders[name] = pCfg
- }
- }
- cfg.Providers = filteredProviders
- return cfg
-}
-
-// getEnvOrDefault returns the environment variable value or the default if not set
-func getEnvOrDefault(key, defaultValue string) string {
- if value := os.Getenv(key); value != "" {
- return value
- }
- return defaultValue
-}
-
-// getEnvIntOrDefault returns the environment variable as int or the default if not set/invalid
-func getEnvIntOrDefault(key string, defaultValue int) int {
- if value := os.Getenv(key); value != "" {
- if intVal, err := strconv.Atoi(value); err == nil {
- return intVal
- }
- }
- return defaultValue
-}
-
-// getEnvBool returns true if the environment variable is "true" or "1"
-func getEnvBool(key string) bool {
- value := os.Getenv(key)
- return strings.EqualFold(value, "true") || value == "1"
-}
-
-// getEnvBoolOrDefault returns the boolean value of the environment variable,
-// or the default value if the variable is not set
-func getEnvBoolOrDefault(key string, defaultValue bool) bool {
- value := os.Getenv(key)
- if value == "" {
- return defaultValue
- }
- return strings.EqualFold(value, "true") || value == "1"
+// parseBool returns true if s is "true" or "1" (case-insensitive).
+func parseBool(s string) bool {
+ return strings.EqualFold(s, "true") || s == "1"
}
// ValidateBodySizeLimit validates a body size limit string.
diff --git a/config/config.yaml b/config/config.yaml
deleted file mode 100644
index 459f041da..000000000
--- a/config/config.yaml
+++ /dev/null
@@ -1,91 +0,0 @@
-# GOModel Configuration File
-#
-# Notes:
-# - Environment variables can be referenced using ${VAR_NAME} syntax
-# - Default values can be specified using ${VAR_NAME:-default_value} syntax
-# - Providers with unresolved environment variables are automatically filtered out
-# - At least one provider with a valid API key is required
-
-server:
- port: "${PORT:-8080}"
- master_key: "${GOMODEL_MASTER_KEY:-}"
-
-# Cache configuration for model storage
-# type: "local" (default) - file-based cache for single instance
-# type: "redis" - Redis cache for multiple instances behind load balancer
-cache:
- type: "${CACHE_TYPE:-local}"
- redis:
- url: "${REDIS_URL:-redis://localhost:6379}"
- key: "${REDIS_KEY:-gomodel:models}"
- ttl: 86400 # 24 hours in seconds
-
-# Storage configuration (used by audit logging, usage tracking, future IAM, etc.)
-# type: "sqlite" (default), "postgresql", or "mongodb"
-storage:
- type: "${STORAGE_TYPE:-sqlite}"
- sqlite:
- path: "${SQLITE_PATH:-.cache/gomodel.db}"
- postgresql:
- url: "${POSTGRES_URL:-}"
- max_conns: 10
- mongodb:
- url: "${MONGODB_URL:-}"
- database: "${MONGODB_DATABASE:-gomodel}"
-
-metrics:
- # Enable or disable Prometheus metrics collection
- # When disabled, no metrics are collected and the /metrics endpoint returns 404
- # Default: false
- # TODO: the default value should be taken here from ENV variable METRICS_ENABLED explicitly
- # Note: It should be commented out, because it overrides the ENV variable in the current form
- # enabled: false
-
- # HTTP endpoint path where metrics are exposed
- # Default: /metrics
- endpoint: "${METRICS_ENDPOINT:-/metrics}"
-
-providers:
- openai:
- type: "openai"
- api_key: "${OPENAI_API_KEY}"
-
- anthropic:
- type: "anthropic"
- api_key: "${ANTHROPIC_API_KEY}"
-
- gemini:
- type: "gemini"
- api_key: "${GEMINI_API_KEY}"
-
- xai:
- type: "xai"
- api_key: "${XAI_API_KEY}"
-
- groq:
- type: "groq"
- api_key: "${GROQ_API_KEY}"
-
- ollama:
- type: "ollama"
- base_url: "${OLLAMA_BASE_URL:-http://localhost:11434/v1}"
- api_key: "${OLLAMA_API_KEY:-}" # Optional, not required by Ollama
-
-
- # Example: Groq (OpenAI-compatible)
- # groq:
- # type: "openai"
- # base_url: "https://api.groq.com/openai/v1"
- # api_key: "${GROQ_API_KEY}"
-
- # Example: Azure OpenAI
- # azure-openai:
- # type: "openai"
- # base_url: "https://your-resource.openai.azure.com/openai/deployments/your-deployment"
- # api_key: "${AZURE_OPENAI_API_KEY}"
-
- # Example: DeepSeek (OpenAI-compatible)
- # deepseek:
- # type: "openai"
- # base_url: "https://api.deepseek.com/v1"
- # api_key: "${DEEPSEEK_API_KEY}"
diff --git a/config/config_defaults_test.go b/config/config_defaults_test.go
deleted file mode 100644
index bdc8d681f..000000000
--- a/config/config_defaults_test.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package config
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- viper "github.com/spf13/viper"
-)
-
-func TestLoad_WithDefaults(t *testing.T) {
- // 1. Test Default Value
- t.Run("UseDefaultValue", func(t *testing.T) {
- // Create a temporary directory for this test
- tempDir, err := os.MkdirTemp("", "config-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp dir: %v", err)
- }
- defer func() {
- _ = os.RemoveAll(tempDir)
- }()
-
- // Save current directory and change to temp directory
- originalDir, err := os.Getwd()
- if err != nil {
- t.Fatalf("Failed to get current directory: %v", err)
- }
- defer func() {
- _ = os.Chdir(originalDir)
- }()
-
- err = os.Chdir(tempDir)
- if err != nil {
- t.Fatalf("Failed to change to temp directory: %v", err)
- }
-
- // Create config with default value syntax
- configContent := `
-server:
- port: "${TEST_PORT_DEFAULTS:-9999}"
-providers:
- openai:
- type: "openai"
- api_key: "${TEST_KEY_DEFAULTS:-default-key}"
-`
- err = os.WriteFile(filepath.Join(tempDir, "config.yaml"), []byte(configContent), 0644)
- if err != nil {
- t.Fatalf("Failed to write config file: %v", err)
- }
-
- // Ensure env vars are unset
- _ = os.Unsetenv("TEST_PORT_DEFAULTS")
- _ = os.Unsetenv("TEST_KEY_DEFAULTS")
- defer func() {
- _ = os.Unsetenv("TEST_PORT_DEFAULTS")
- }()
- defer func() {
- _ = os.Unsetenv("TEST_KEY_DEFAULTS")
- }()
-
- // Reset viper
- viper.Reset()
-
- cfg, err := Load()
- if err != nil {
- t.Fatalf("Load() failed: %v", err)
- }
-
- if cfg.Server.Port != "9999" {
- t.Errorf("Expected port 9999 (default), got %s", cfg.Server.Port)
- }
-
- provider := cfg.Providers["openai"]
- if provider.APIKey != "default-key" {
- t.Errorf("Expected API key 'default-key', got %s", provider.APIKey)
- }
- })
-
- // 2. Test Env Var Override
- t.Run("OverrideDefaultValue", func(t *testing.T) {
- // Create a temporary directory for this test
- tempDir, err := os.MkdirTemp("", "config-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp dir: %v", err)
- }
- defer func() {
- _ = os.RemoveAll(tempDir)
- }()
-
- // Save current directory and change to temp directory
- originalDir, err := os.Getwd()
- if err != nil {
- t.Fatalf("Failed to get current directory: %v", err)
- }
- defer func() {
- _ = os.Chdir(originalDir)
- }()
-
- err = os.Chdir(tempDir)
- if err != nil {
- t.Fatalf("Failed to change to temp directory: %v", err)
- }
-
- // Same config content...
- // But set env vars
- _ = os.Setenv("TEST_PORT_DEFAULTS", "1111")
- _ = os.Setenv("TEST_KEY_DEFAULTS", "real-key")
- defer func() {
- _ = os.Unsetenv("TEST_PORT_DEFAULTS")
- }()
- defer func() {
- _ = os.Unsetenv("TEST_KEY_DEFAULTS")
- }()
-
- // Create config (need to recreate as Load might re-read)
- configContent := `
-server:
- port: "${TEST_PORT_DEFAULTS:-9999}"
-providers:
- openai:
- type: "openai"
- api_key: "${TEST_KEY_DEFAULTS:-default-key}"
-`
- err = os.WriteFile(filepath.Join(tempDir, "config.yaml"), []byte(configContent), 0644)
- if err != nil {
- t.Fatalf("Failed to write config file: %v", err)
- }
-
- viper.Reset()
-
- cfg, err := Load()
- if err != nil {
- t.Fatalf("Load() failed: %v", err)
- }
-
- if cfg.Server.Port != "1111" {
- t.Errorf("Expected port 1111 (env override), got %s", cfg.Server.Port)
- }
-
- provider := cfg.Providers["openai"]
- if provider.APIKey != "real-key" {
- t.Errorf("Expected API key 'real-key', got %s", provider.APIKey)
- }
- })
-}
diff --git a/config/config_example_test.go b/config/config_example_test.go
index 7016a3019..421100777 100644
--- a/config/config_example_test.go
+++ b/config/config_example_test.go
@@ -16,19 +16,17 @@ func TestLoad_FromEnvironment(t *testing.T) {
_ = os.Unsetenv("ANTHROPIC_API_KEY")
}()
- // Note: This test assumes config.yaml exists and uses ${VAR} placeholders
cfg, err := Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- // When config.yaml exists with hardcoded port, it takes precedence
- // In production, use ${PORT} in config.yaml to allow env var override
- if cfg.Server.Port == "" {
- t.Error("expected non-empty port")
+ // Env vars always win — port should be overridden
+ if cfg.Server.Port != "9090" {
+ t.Errorf("expected port 9090, got %s", cfg.Server.Port)
}
- // Providers should be created from expanded env vars
+ // Providers should be created from env vars
if len(cfg.Providers) < 2 {
t.Errorf("expected at least 2 providers, got %d", len(cfg.Providers))
}
diff --git a/config/config_helpers_test.go b/config/config_helpers_test.go
index 04e575a34..df1aa5e3b 100644
--- a/config/config_helpers_test.go
+++ b/config/config_helpers_test.go
@@ -162,482 +162,203 @@ func TestExpandString(t *testing.T) {
}
}
-// TestExpandEnvVars tests the expandEnvVars function
-func TestExpandEnvVars(t *testing.T) {
+// TestRemoveEmptyProviders tests the removeEmptyProviders function
+func TestRemoveEmptyProviders(t *testing.T) {
tests := []struct {
- name string
- input Config
- envVars map[string]string
- expected Config
+ name string
+ providers map[string]ProviderConfig
+ expectedProviders map[string]ProviderConfig
}{
{
- name: "expand server port",
- input: Config{
- Server: ServerConfig{
- Port: "${PORT}",
- },
- Providers: map[string]ProviderConfig{},
- },
- envVars: map[string]string{"PORT": "3000"},
- expected: Config{
- Server: ServerConfig{
- Port: "3000",
- },
- Providers: map[string]ProviderConfig{},
- },
- },
- {
- name: "expand provider API key",
- input: Config{
- Server: ServerConfig{
- Port: "8080",
+ name: "remove provider with empty API key",
+ providers: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "",
},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "${OPENAI_API_KEY}",
- },
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "sk-ant-valid",
},
},
- envVars: map[string]string{"OPENAI_API_KEY": "sk-test-123"},
- expected: Config{
- Server: ServerConfig{
- Port: "8080",
- },
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-test-123",
- },
+ expectedProviders: map[string]ProviderConfig{
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "sk-ant-valid",
},
},
},
{
- name: "expand provider base URL",
- input: Config{
- Server: ServerConfig{
- Port: "8080",
+ name: "remove provider with unresolved placeholder",
+ providers: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "${OPENAI_API_KEY}",
},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-test-123",
- BaseURL: "${OPENAI_BASE_URL}",
- },
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "sk-ant-valid",
},
},
- envVars: map[string]string{"OPENAI_BASE_URL": "https://custom.api.com"},
- expected: Config{
- Server: ServerConfig{
- Port: "8080",
- },
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-test-123",
- BaseURL: "https://custom.api.com",
- },
+ expectedProviders: map[string]ProviderConfig{
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "sk-ant-valid",
},
},
},
{
- name: "multiple providers with mixed expansion",
- input: Config{
- Server: ServerConfig{
- Port: "${PORT:-8080}",
+ name: "remove provider with partially resolved placeholder",
+ providers: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "prefix-${UNRESOLVED}",
},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "${OPENAI_API_KEY}",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "${ANTHROPIC_API_KEY}",
- },
- "gemini": {
- Type: "gemini",
- APIKey: "${GEMINI_API_KEY}",
- },
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "sk-ant-valid",
},
},
- envVars: map[string]string{
- "OPENAI_API_KEY": "sk-openai-123",
- "ANTHROPIC_API_KEY": "sk-ant-456",
- // GEMINI_API_KEY intentionally missing
- },
- expected: Config{
- Server: ServerConfig{
- Port: "8080",
- },
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-123",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "sk-ant-456",
- },
- "gemini": {
- Type: "gemini",
- APIKey: "${GEMINI_API_KEY}",
- },
+ expectedProviders: map[string]ProviderConfig{
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "sk-ant-valid",
},
},
},
{
- name: "unresolved variables remain as placeholders",
- input: Config{
- Server: ServerConfig{
- Port: "${MISSING_PORT}",
+ name: "keep all providers with valid API keys",
+ providers: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "sk-openai-123",
},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "${MISSING_KEY}",
- BaseURL: "${MISSING_URL}",
- },
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "sk-ant-456",
},
- },
- envVars: map[string]string{},
- expected: Config{
- Server: ServerConfig{
- Port: "${MISSING_PORT}",
- },
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "${MISSING_KEY}",
- BaseURL: "${MISSING_URL}",
- },
+ "gemini": {
+ Type: "gemini",
+ APIKey: "sk-gem-789",
},
},
- },
- {
- name: "empty config",
- input: Config{
- Server: ServerConfig{},
- Providers: map[string]ProviderConfig{},
- },
- envVars: map[string]string{},
- expected: Config{
- Server: ServerConfig{},
- Providers: map[string]ProviderConfig{},
- },
- },
- {
- name: "config with default values in placeholders",
- input: Config{
- Server: ServerConfig{
- Port: "${PORT:-9000}",
+ expectedProviders: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "sk-openai-123",
},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "${OPENAI_API_KEY}",
- BaseURL: "${OPENAI_BASE_URL:-https://api.openai.com}",
- },
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "sk-ant-456",
},
- },
- envVars: map[string]string{
- "OPENAI_API_KEY": "sk-test-789",
- },
- expected: Config{
- Server: ServerConfig{
- Port: "9000",
- },
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-test-789",
- BaseURL: "https://api.openai.com",
- },
+ "gemini": {
+ Type: "gemini",
+ APIKey: "sk-gem-789",
},
},
},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup environment variables
- for k, v := range tt.envVars {
- _ = os.Setenv(k, v)
- }
- // Cleanup after test
- defer func() {
- for k := range tt.envVars {
- _ = os.Unsetenv(k)
- }
- }()
-
- result := expandEnvVars(tt.input)
-
- // Compare server config
- if result.Server.Port != tt.expected.Server.Port {
- t.Errorf("Server.Port = %q, want %q", result.Server.Port, tt.expected.Server.Port)
- }
-
- // Compare providers
- if len(result.Providers) != len(tt.expected.Providers) {
- t.Errorf("len(Providers) = %d, want %d", len(result.Providers), len(tt.expected.Providers))
- }
-
- for name, expectedProvider := range tt.expected.Providers {
- resultProvider, exists := result.Providers[name]
- if !exists {
- t.Errorf("Provider %q not found in result", name)
- continue
- }
-
- if resultProvider.Type != expectedProvider.Type {
- t.Errorf("Provider %q: Type = %q, want %q", name, resultProvider.Type, expectedProvider.Type)
- }
- if resultProvider.APIKey != expectedProvider.APIKey {
- t.Errorf("Provider %q: APIKey = %q, want %q", name, resultProvider.APIKey, expectedProvider.APIKey)
- }
- if resultProvider.BaseURL != expectedProvider.BaseURL {
- t.Errorf("Provider %q: BaseURL = %q, want %q", name, resultProvider.BaseURL, expectedProvider.BaseURL)
- }
- }
- })
- }
-}
-
-// TestRemoveEmptyProviders tests the removeEmptyProviders function
-func TestRemoveEmptyProviders(t *testing.T) {
- tests := []struct {
- name string
- input Config
- expected Config
- }{
{
- name: "remove provider with empty API key",
- input: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "sk-ant-valid",
- },
+ name: "remove all providers when all have invalid keys",
+ providers: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "${OPENAI_API_KEY}",
},
- },
- expected: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "anthropic": {
- Type: "anthropic",
- APIKey: "sk-ant-valid",
- },
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "",
+ },
+ "gemini": {
+ Type: "gemini",
+ APIKey: "${GEMINI_API_KEY}",
},
},
+ expectedProviders: map[string]ProviderConfig{},
},
{
- name: "remove provider with unresolved placeholder",
- input: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "${OPENAI_API_KEY}",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "sk-ant-valid",
- },
+ name: "mixed valid and invalid providers",
+ providers: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "sk-openai-valid",
},
- },
- expected: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "anthropic": {
- Type: "anthropic",
- APIKey: "sk-ant-valid",
- },
+ "openai-fallback": {
+ Type: "openai",
+ APIKey: "${OPENAI_FALLBACK_KEY}",
},
- },
- },
- {
- name: "remove provider with partially resolved placeholder",
- input: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "prefix-${UNRESOLVED}",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "sk-ant-valid",
- },
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "",
},
- },
- expected: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "anthropic": {
- Type: "anthropic",
- APIKey: "sk-ant-valid",
- },
+ "gemini": {
+ Type: "gemini",
+ APIKey: "sk-gemini-valid",
},
},
- },
- {
- name: "keep all providers with valid API keys",
- input: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-123",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "sk-ant-456",
- },
- "gemini": {
- Type: "gemini",
- APIKey: "sk-gem-789",
- },
+ expectedProviders: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "sk-openai-valid",
},
- },
- expected: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-123",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "sk-ant-456",
- },
- "gemini": {
- Type: "gemini",
- APIKey: "sk-gem-789",
- },
+ "gemini": {
+ Type: "gemini",
+ APIKey: "sk-gemini-valid",
},
},
},
{
- name: "remove all providers when all have invalid keys",
- input: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "${OPENAI_API_KEY}",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "",
- },
- "gemini": {
- Type: "gemini",
- APIKey: "${GEMINI_API_KEY}",
- },
- },
- },
- expected: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{},
- },
+ name: "empty providers map",
+ providers: map[string]ProviderConfig{},
+ expectedProviders: map[string]ProviderConfig{},
},
{
- name: "mixed valid and invalid providers",
- input: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-valid",
- },
- "openai-fallback": {
- Type: "openai",
- APIKey: "${OPENAI_FALLBACK_KEY}",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "",
- },
- "gemini": {
- Type: "gemini",
- APIKey: "sk-gemini-valid",
- },
+ name: "provider with valid API key but empty BaseURL should be kept",
+ providers: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "sk-openai-123",
+ BaseURL: "",
},
},
- expected: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-valid",
- },
- "gemini": {
- Type: "gemini",
- APIKey: "sk-gemini-valid",
- },
+ expectedProviders: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "sk-openai-123",
+ BaseURL: "",
},
},
},
{
- name: "empty providers map",
- input: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{},
- },
- expected: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{},
- },
- },
- {
- name: "provider with valid API key but empty BaseURL should be kept",
- input: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-123",
- BaseURL: "",
- },
+ name: "provider with valid API key but unresolved BaseURL should be kept",
+ providers: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "sk-openai-123",
+ BaseURL: "${CUSTOM_URL}",
},
},
- expected: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-123",
- BaseURL: "",
- },
+ expectedProviders: map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "sk-openai-123",
+ BaseURL: "${CUSTOM_URL}",
},
},
},
{
- name: "provider with valid API key but unresolved BaseURL should be kept",
- input: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-123",
- BaseURL: "${CUSTOM_URL}",
- },
+ name: "ollama with base URL preserved (no API key needed)",
+ providers: map[string]ProviderConfig{
+ "ollama": {
+ Type: "ollama",
+ APIKey: "",
+ BaseURL: "http://localhost:11434/v1",
},
},
- expected: Config{
- Server: ServerConfig{Port: "8080"},
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-123",
- BaseURL: "${CUSTOM_URL}",
- },
+ expectedProviders: map[string]ProviderConfig{
+ "ollama": {
+ Type: "ollama",
+ APIKey: "",
+ BaseURL: "http://localhost:11434/v1",
},
},
},
@@ -645,21 +366,18 @@ func TestRemoveEmptyProviders(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := removeEmptyProviders(tt.input)
-
- // Compare server config (should remain unchanged)
- if result.Server.Port != tt.expected.Server.Port {
- t.Errorf("Server.Port = %q, want %q", result.Server.Port, tt.expected.Server.Port)
+ cfg := &Config{
+ Server: ServerConfig{Port: "8080"},
+ Providers: tt.providers,
}
+ removeEmptyProviders(cfg)
- // Compare number of providers
- if len(result.Providers) != len(tt.expected.Providers) {
- t.Errorf("len(Providers) = %d, want %d", len(result.Providers), len(tt.expected.Providers))
+ if len(cfg.Providers) != len(tt.expectedProviders) {
+ t.Errorf("len(Providers) = %d, want %d", len(cfg.Providers), len(tt.expectedProviders))
}
- // Check each expected provider exists with correct values
- for name, expectedProvider := range tt.expected.Providers {
- resultProvider, exists := result.Providers[name]
+ for name, expectedProvider := range tt.expectedProviders {
+ resultProvider, exists := cfg.Providers[name]
if !exists {
t.Errorf("Provider %q not found in result", name)
continue
@@ -676,9 +394,8 @@ func TestRemoveEmptyProviders(t *testing.T) {
}
}
- // Check that no unexpected providers exist in result
- for name := range result.Providers {
- if _, exists := tt.expected.Providers[name]; !exists {
+ for name := range cfg.Providers {
+ if _, exists := tt.expectedProviders[name]; !exists {
t.Errorf("Unexpected provider %q found in result", name)
}
}
@@ -686,205 +403,83 @@ func TestRemoveEmptyProviders(t *testing.T) {
}
}
-// TestExpandEnvVars_MasterKey specifically tests master key expansion to prevent auth bypass bugs
-func TestExpandEnvVars_MasterKey(t *testing.T) {
+// TestApplyEnvVars tests the applyEnvVars function
+func TestApplyEnvVars(t *testing.T) {
tests := []struct {
- name string
- input Config
- envVars map[string]string
- expectedMasterKey string
+ name string
+ envVars map[string]string
+ check func(t *testing.T, cfg *Config)
}{
{
- name: "master key not set with empty default should be empty string",
- input: Config{
- Server: ServerConfig{
- Port: "8080",
- MasterKey: "${GOMODEL_MASTER_KEY:-}",
- },
- Providers: map[string]ProviderConfig{},
+ name: "PORT override",
+ envVars: map[string]string{"PORT": "3000"},
+ check: func(t *testing.T, cfg *Config) {
+ if cfg.Server.Port != "3000" {
+ t.Errorf("Server.Port = %q, want %q", cfg.Server.Port, "3000")
+ }
},
- envVars: map[string]string{},
- expectedMasterKey: "",
},
{
- name: "master key set should use the value",
- input: Config{
- Server: ServerConfig{
- Port: "8080",
- MasterKey: "${GOMODEL_MASTER_KEY:-}",
- },
- Providers: map[string]ProviderConfig{},
+ name: "GOMODEL_MASTER_KEY override",
+ envVars: map[string]string{"GOMODEL_MASTER_KEY": "my-secret"},
+ check: func(t *testing.T, cfg *Config) {
+ if cfg.Server.MasterKey != "my-secret" {
+ t.Errorf("Server.MasterKey = %q, want %q", cfg.Server.MasterKey, "my-secret")
+ }
},
- envVars: map[string]string{"GOMODEL_MASTER_KEY": "my-secret-key"},
- expectedMasterKey: "my-secret-key",
},
{
- name: "master key with non-empty default - not set should use default",
- input: Config{
- Server: ServerConfig{
- Port: "8080",
- MasterKey: "${GOMODEL_MASTER_KEY:-default-secret}",
- },
- Providers: map[string]ProviderConfig{},
+ name: "storage overrides",
+ envVars: map[string]string{"STORAGE_TYPE": "postgresql", "POSTGRES_URL": "postgres://localhost/test", "POSTGRES_MAX_CONNS": "20"},
+ check: func(t *testing.T, cfg *Config) {
+ if cfg.Storage.Type != "postgresql" {
+ t.Errorf("Storage.Type = %q, want %q", cfg.Storage.Type, "postgresql")
+ }
+ if cfg.Storage.PostgreSQL.URL != "postgres://localhost/test" {
+ t.Errorf("Storage.PostgreSQL.URL = %q, want %q", cfg.Storage.PostgreSQL.URL, "postgres://localhost/test")
+ }
+ if cfg.Storage.PostgreSQL.MaxConns != 20 {
+ t.Errorf("Storage.PostgreSQL.MaxConns = %d, want %d", cfg.Storage.PostgreSQL.MaxConns, 20)
+ }
},
- envVars: map[string]string{},
- expectedMasterKey: "default-secret",
},
{
- name: "master key without default syntax - not set should keep placeholder",
- input: Config{
- Server: ServerConfig{
- Port: "8080",
- MasterKey: "${GOMODEL_MASTER_KEY}",
- },
- Providers: map[string]ProviderConfig{},
- },
- envVars: map[string]string{},
- expectedMasterKey: "${GOMODEL_MASTER_KEY}",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Clean up any existing env var first
- _ = os.Unsetenv("GOMODEL_MASTER_KEY")
-
- // Setup environment variables
- for k, v := range tt.envVars {
- _ = os.Setenv(k, v)
- }
- // Cleanup after test
- defer func() {
- _ = os.Unsetenv("GOMODEL_MASTER_KEY")
- for k := range tt.envVars {
- _ = os.Unsetenv(k)
+ name: "bool overrides",
+ envVars: map[string]string{"METRICS_ENABLED": "true", "LOGGING_ENABLED": "1", "LOGGING_LOG_BODIES": "false"},
+ check: func(t *testing.T, cfg *Config) {
+ if !cfg.Metrics.Enabled {
+ t.Error("Metrics.Enabled should be true")
+ }
+ if !cfg.Logging.Enabled {
+ t.Error("Logging.Enabled should be true")
+ }
+ if cfg.Logging.LogBodies {
+ t.Error("Logging.LogBodies should be false")
}
- }()
-
- result := expandEnvVars(tt.input)
-
- if result.Server.MasterKey != tt.expectedMasterKey {
- t.Errorf("Server.MasterKey = %q, want %q", result.Server.MasterKey, tt.expectedMasterKey)
- }
- })
- }
-}
-
-// TestIntegration_ExpandAndFilter tests the combination of expandEnvVars and removeEmptyProviders
-func TestIntegration_ExpandAndFilter(t *testing.T) {
- tests := []struct {
- name string
- input Config
- envVars map[string]string
- expected Config
- }{
- {
- name: "expand and filter mixed providers",
- input: Config{
- Server: ServerConfig{
- Port: "${PORT:-8080}",
- },
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "${OPENAI_API_KEY}",
- },
- "openai-fallback": {
- Type: "openai",
- APIKey: "${OPENAI_FALLBACK_KEY}",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "${ANTHROPIC_API_KEY}",
- },
- },
- },
- envVars: map[string]string{
- "OPENAI_API_KEY": "sk-openai-123",
- // OPENAI_FALLBACK_KEY and ANTHROPIC_API_KEY intentionally missing
- },
- expected: Config{
- Server: ServerConfig{
- Port: "8080",
- },
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "sk-openai-123",
- },
- },
},
},
{
- name: "all providers filtered when none have valid keys",
- input: Config{
- Server: ServerConfig{
- Port: "8080",
- },
- Providers: map[string]ProviderConfig{
- "openai": {
- Type: "openai",
- APIKey: "${OPENAI_API_KEY}",
- },
- "anthropic": {
- Type: "anthropic",
- APIKey: "${ANTHROPIC_API_KEY}",
- },
- },
- },
- envVars: map[string]string{},
- expected: Config{
- Server: ServerConfig{
- Port: "8080",
- },
- Providers: map[string]ProviderConfig{},
+ name: "HTTP timeout overrides",
+ envVars: map[string]string{"HTTP_TIMEOUT": "30", "HTTP_RESPONSE_HEADER_TIMEOUT": "60"},
+ check: func(t *testing.T, cfg *Config) {
+ if cfg.HTTP.Timeout != 30 {
+ t.Errorf("HTTP.Timeout = %d, want 30", cfg.HTTP.Timeout)
+ }
+ if cfg.HTTP.ResponseHeaderTimeout != 60 {
+ t.Errorf("HTTP.ResponseHeaderTimeout = %d, want 60", cfg.HTTP.ResponseHeaderTimeout)
+ }
},
},
{
- name: "complex scenario with defaults and partial resolution",
- input: Config{
- Server: ServerConfig{
- Port: "${PORT:-9000}",
- },
- Providers: map[string]ProviderConfig{
- "provider1": {
- Type: "openai",
- APIKey: "${API_KEY_1}",
- BaseURL: "${BASE_URL_1:-https://api.default1.com}",
- },
- "provider2": {
- Type: "openai",
- APIKey: "${API_KEY_2:-default-key}",
- BaseURL: "${BASE_URL_2}",
- },
- "provider3": {
- Type: "anthropic",
- APIKey: "${API_KEY_3}",
- BaseURL: "",
- },
- },
- },
- envVars: map[string]string{
- "API_KEY_1": "sk-valid-1",
- // API_KEY_2 will use default
- // API_KEY_3 is missing (no default)
- },
- expected: Config{
- Server: ServerConfig{
- Port: "9000",
- },
- Providers: map[string]ProviderConfig{
- "provider1": {
- Type: "openai",
- APIKey: "sk-valid-1",
- BaseURL: "https://api.default1.com",
- },
- "provider2": {
- Type: "openai",
- APIKey: "default-key",
- BaseURL: "${BASE_URL_2}",
- },
- },
+ name: "no env vars set preserves defaults",
+ envVars: map[string]string{},
+ check: func(t *testing.T, cfg *Config) {
+ if cfg.Server.Port != "8080" {
+ t.Errorf("Server.Port = %q, want %q", cfg.Server.Port, "8080")
+ }
+ if cfg.HTTP.Timeout != 600 {
+ t.Errorf("HTTP.Timeout = %d, want 600", cfg.HTTP.Timeout)
+ }
},
},
}
@@ -893,46 +488,102 @@ func TestIntegration_ExpandAndFilter(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Setup environment variables
for k, v := range tt.envVars {
- _ = os.Setenv(k, v)
+ t.Setenv(k, v)
}
- // Cleanup after test
- defer func() {
- for k := range tt.envVars {
- _ = os.Unsetenv(k)
- }
- }()
- // Apply both functions in sequence (as done in Load())
- result := expandEnvVars(tt.input)
- result = removeEmptyProviders(result)
+ cfg := defaultConfig()
+ applyEnvVars(&cfg)
+ tt.check(t, &cfg)
+ })
+ }
+}
- // Compare server config
- if result.Server.Port != tt.expected.Server.Port {
- t.Errorf("Server.Port = %q, want %q", result.Server.Port, tt.expected.Server.Port)
- }
+// TestApplyProviderEnvVars tests the applyProviderEnvVars function
+func TestApplyProviderEnvVars(t *testing.T) {
+ t.Run("discovers provider from API key", func(t *testing.T) {
+ t.Setenv("OPENAI_API_KEY", "sk-test-123")
+ cfg := defaultConfig()
+ applyProviderEnvVars(&cfg)
- // Compare providers
- if len(result.Providers) != len(tt.expected.Providers) {
- t.Errorf("len(Providers) = %d, want %d", len(result.Providers), len(tt.expected.Providers))
- }
+ p, ok := cfg.Providers["openai"]
+ if !ok {
+ t.Fatal("expected openai provider")
+ }
+ if p.APIKey != "sk-test-123" {
+ t.Errorf("APIKey = %q, want %q", p.APIKey, "sk-test-123")
+ }
+ if p.Type != "openai" {
+ t.Errorf("Type = %q, want %q", p.Type, "openai")
+ }
+ })
- for name, expectedProvider := range tt.expected.Providers {
- resultProvider, exists := result.Providers[name]
- if !exists {
- t.Errorf("Provider %q not found in result", name)
- continue
- }
+ t.Run("overrides existing YAML provider", func(t *testing.T) {
+ t.Setenv("OPENAI_API_KEY", "sk-env-key")
+ cfg := defaultConfig()
+ cfg.Providers["openai"] = ProviderConfig{Type: "openai", APIKey: "sk-yaml-key", BaseURL: "https://custom.api.com"}
+ applyProviderEnvVars(&cfg)
- if resultProvider.Type != expectedProvider.Type {
- t.Errorf("Provider %q: Type = %q, want %q", name, resultProvider.Type, expectedProvider.Type)
- }
- if resultProvider.APIKey != expectedProvider.APIKey {
- t.Errorf("Provider %q: APIKey = %q, want %q", name, resultProvider.APIKey, expectedProvider.APIKey)
- }
- if resultProvider.BaseURL != expectedProvider.BaseURL {
- t.Errorf("Provider %q: BaseURL = %q, want %q", name, resultProvider.BaseURL, expectedProvider.BaseURL)
- }
- }
- })
- }
+ p := cfg.Providers["openai"]
+ if p.APIKey != "sk-env-key" {
+ t.Errorf("APIKey = %q, want %q (env should override yaml)", p.APIKey, "sk-env-key")
+ }
+ if p.BaseURL != "https://custom.api.com" {
+ t.Errorf("BaseURL = %q should be preserved from YAML", p.BaseURL)
+ }
+ })
+
+ t.Run("ollama enabled via base URL only", func(t *testing.T) {
+ t.Setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
+ cfg := defaultConfig()
+ applyProviderEnvVars(&cfg)
+
+ p, ok := cfg.Providers["ollama"]
+ if !ok {
+ t.Fatal("expected ollama provider")
+ }
+ if p.BaseURL != "http://localhost:11434/v1" {
+ t.Errorf("BaseURL = %q", p.BaseURL)
+ }
+ })
+
+ t.Run("skips when no env vars set", func(t *testing.T) {
+ cfg := defaultConfig()
+ applyProviderEnvVars(&cfg)
+
+ if len(cfg.Providers) != 0 {
+ t.Errorf("expected no providers, got %d", len(cfg.Providers))
+ }
+ })
+}
+
+// TestIntegration_ExpandAndFilter tests env var expansion in YAML + provider filtering
+func TestIntegration_ExpandAndFilter(t *testing.T) {
+ t.Run("expand and filter mixed providers", func(t *testing.T) {
+ _ = os.Setenv("OPENAI_API_KEY", "sk-openai-123")
+ defer os.Unsetenv("OPENAI_API_KEY")
+
+ cfg := defaultConfig()
+ cfg.Providers = map[string]ProviderConfig{
+ "openai": {
+ Type: "openai",
+ APIKey: "sk-openai-123", // already expanded
+ },
+ "openai-fallback": {
+ Type: "openai",
+ APIKey: "${OPENAI_FALLBACK_KEY}", // unresolved
+ },
+ "anthropic": {
+ Type: "anthropic",
+ APIKey: "${ANTHROPIC_API_KEY}", // unresolved
+ },
+ }
+ removeEmptyProviders(&cfg)
+
+ if len(cfg.Providers) != 1 {
+ t.Errorf("expected 1 provider, got %d: %v", len(cfg.Providers), cfg.Providers)
+ }
+ if _, ok := cfg.Providers["openai"]; !ok {
+ t.Error("expected openai to survive filtering")
+ }
+ })
}
diff --git a/config/config_test.go b/config/config_test.go
index 26d41953a..de4eef75c 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -2,299 +2,529 @@ package config
import (
"os"
+ "path/filepath"
"testing"
-
- "github.com/go-viper/mapstructure/v2"
- "github.com/spf13/viper"
)
-func TestLoad_DefaultPort(t *testing.T) {
- // Reset viper state before test
- viper.Reset()
+// clearProviderEnvVars unsets all known provider-related environment variables.
+func clearProviderEnvVars(t *testing.T) {
+ t.Helper()
+ for _, key := range []string{
+ "OPENAI_API_KEY", "OPENAI_BASE_URL",
+ "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL",
+ "GEMINI_API_KEY", "GEMINI_BASE_URL",
+ "XAI_API_KEY", "XAI_BASE_URL",
+ "GROQ_API_KEY", "GROQ_BASE_URL",
+ "OLLAMA_API_KEY", "OLLAMA_BASE_URL",
+ } {
+ t.Setenv(key, "")
+ os.Unsetenv(key)
+ }
+}
- // Clear any existing environment variables
- _ = os.Unsetenv("PORT")
- _ = os.Unsetenv("OPENAI_API_KEY")
+// clearAllConfigEnvVars unsets all config-related environment variables.
+func clearAllConfigEnvVars(t *testing.T) {
+ t.Helper()
+ for _, key := range []string{
+ "PORT", "GOMODEL_MASTER_KEY", "BODY_SIZE_LIMIT",
+ "CACHE_TYPE", "GOMODEL_CACHE_DIR",
+ "REDIS_URL", "REDIS_KEY", "REDIS_TTL",
+ "STORAGE_TYPE", "SQLITE_PATH", "POSTGRES_URL", "POSTGRES_MAX_CONNS",
+ "MONGODB_URL", "MONGODB_DATABASE",
+ "METRICS_ENABLED", "METRICS_ENDPOINT",
+ "LOGGING_ENABLED", "LOGGING_LOG_BODIES", "LOGGING_LOG_HEADERS",
+ "LOGGING_ONLY_MODEL_INTERACTIONS", "LOGGING_BUFFER_SIZE",
+ "LOGGING_FLUSH_INTERVAL", "LOGGING_RETENTION_DAYS",
+ "USAGE_ENABLED", "ENFORCE_RETURNING_USAGE_DATA",
+ "USAGE_BUFFER_SIZE", "USAGE_FLUSH_INTERVAL", "USAGE_RETENTION_DAYS",
+ "HTTP_TIMEOUT", "HTTP_RESPONSE_HEADER_TIMEOUT",
+ } {
+ t.Setenv(key, "")
+ os.Unsetenv(key)
+ }
+ clearProviderEnvVars(t)
+}
- cfg, err := Load()
+// withTempDir runs fn in a temporary directory, restoring the original working directory afterward.
+func withTempDir(t *testing.T, fn func(dir string)) {
+ t.Helper()
+ tempDir := t.TempDir()
+ originalDir, err := os.Getwd()
if err != nil {
- t.Fatalf("Load() failed: %v", err)
+ t.Fatalf("Failed to get current directory: %v", err)
}
+ if err := os.Chdir(tempDir); err != nil {
+ t.Fatalf("Failed to change to temp directory: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(originalDir) })
+ fn(tempDir)
+}
+
+func TestDefaultConfig(t *testing.T) {
+ cfg := defaultConfig()
if cfg.Server.Port != "8080" {
- t.Errorf("expected default port 8080, got %s", cfg.Server.Port)
+ t.Errorf("expected Server.Port=8080, got %s", cfg.Server.Port)
+ }
+ if cfg.Cache.Type != "local" {
+ t.Errorf("expected Cache.Type=local, got %s", cfg.Cache.Type)
+ }
+ if cfg.Cache.CacheDir != ".cache" {
+ t.Errorf("expected Cache.CacheDir=.cache, got %s", cfg.Cache.CacheDir)
+ }
+ if cfg.Cache.Redis.Key != "gomodel:models" {
+ t.Errorf("expected Cache.Redis.Key=gomodel:models, got %s", cfg.Cache.Redis.Key)
+ }
+ if cfg.Cache.Redis.TTL != 86400 {
+ t.Errorf("expected Cache.Redis.TTL=86400, got %d", cfg.Cache.Redis.TTL)
+ }
+ if cfg.Storage.Type != "sqlite" {
+ t.Errorf("expected Storage.Type=sqlite, got %s", cfg.Storage.Type)
+ }
+ if cfg.Storage.SQLite.Path != ".cache/gomodel.db" {
+ t.Errorf("expected Storage.SQLite.Path=.cache/gomodel.db, got %s", cfg.Storage.SQLite.Path)
+ }
+ if cfg.Storage.PostgreSQL.MaxConns != 10 {
+ t.Errorf("expected Storage.PostgreSQL.MaxConns=10, got %d", cfg.Storage.PostgreSQL.MaxConns)
+ }
+ if cfg.Storage.MongoDB.Database != "gomodel" {
+ t.Errorf("expected Storage.MongoDB.Database=gomodel, got %s", cfg.Storage.MongoDB.Database)
+ }
+ if !cfg.Logging.LogBodies {
+ t.Error("expected Logging.LogBodies=true")
+ }
+ if !cfg.Logging.LogHeaders {
+ t.Error("expected Logging.LogHeaders=true")
+ }
+ if cfg.Logging.BufferSize != 1000 {
+ t.Errorf("expected Logging.BufferSize=1000, got %d", cfg.Logging.BufferSize)
+ }
+ if cfg.Logging.FlushInterval != 5 {
+ t.Errorf("expected Logging.FlushInterval=5, got %d", cfg.Logging.FlushInterval)
+ }
+ if cfg.Logging.RetentionDays != 30 {
+ t.Errorf("expected Logging.RetentionDays=30, got %d", cfg.Logging.RetentionDays)
+ }
+ if !cfg.Logging.OnlyModelInteractions {
+ t.Error("expected Logging.OnlyModelInteractions=true")
+ }
+ if cfg.Logging.Enabled {
+ t.Error("expected Logging.Enabled=false")
+ }
+ if !cfg.Usage.Enabled {
+ t.Error("expected Usage.Enabled=true")
+ }
+ if !cfg.Usage.EnforceReturningUsageData {
+ t.Error("expected Usage.EnforceReturningUsageData=true")
+ }
+ if cfg.Usage.BufferSize != 1000 {
+ t.Errorf("expected Usage.BufferSize=1000, got %d", cfg.Usage.BufferSize)
+ }
+ if cfg.Usage.FlushInterval != 5 {
+ t.Errorf("expected Usage.FlushInterval=5, got %d", cfg.Usage.FlushInterval)
+ }
+ if cfg.Usage.RetentionDays != 90 {
+ t.Errorf("expected Usage.RetentionDays=90, got %d", cfg.Usage.RetentionDays)
+ }
+ if cfg.Metrics.Endpoint != "/metrics" {
+ t.Errorf("expected Metrics.Endpoint=/metrics, got %s", cfg.Metrics.Endpoint)
+ }
+ if cfg.Metrics.Enabled {
+ t.Error("expected Metrics.Enabled=false")
+ }
+ if cfg.HTTP.Timeout != 600 {
+ t.Errorf("expected HTTP.Timeout=600, got %d", cfg.HTTP.Timeout)
+ }
+ if cfg.HTTP.ResponseHeaderTimeout != 600 {
+ t.Errorf("expected HTTP.ResponseHeaderTimeout=600, got %d", cfg.HTTP.ResponseHeaderTimeout)
+ }
+ if cfg.Providers == nil {
+ t.Error("expected Providers to be initialized (non-nil)")
}
}
-func TestLoad_PortFromEnv(t *testing.T) {
- // Reset viper state before test
- viper.Reset()
+func TestLoad_ZeroConfig(t *testing.T) {
+ clearAllConfigEnvVars(t)
- // Set environment variable
- _ = os.Setenv("PORT", "9090")
- defer func() { _ = os.Unsetenv("PORT") }()
+ withTempDir(t, func(_ string) {
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- // Note: If config.yaml exists and has a hardcoded port,
- // it will take precedence over PORT env var.
- // This test might fail if config.yaml exists in the config/ directory.
- // In production, use config.yaml with ${PORT} placeholder or
- // rely on viper.AutomaticEnv() for dynamic overrides.
+ if cfg.Server.Port != "8080" {
+ t.Errorf("expected default port 8080, got %s", cfg.Server.Port)
+ }
+ if len(cfg.Providers) != 0 {
+ t.Errorf("expected no providers, got %d", len(cfg.Providers))
+ }
+ })
+}
- cfg, err := Load()
- if err != nil {
- t.Fatalf("Load() failed: %v", err)
- }
+func TestLoad_YAMLOverridesDefaults(t *testing.T) {
+ clearAllConfigEnvVars(t)
+
+ withTempDir(t, func(dir string) {
+ yaml := `
+server:
+ port: "3000"
+cache:
+ type: "redis"
+ redis:
+ url: "redis://myhost:6379"
+ key: "custom:key"
+ ttl: 3600
+logging:
+ enabled: true
+ log_bodies: false
+ buffer_size: 500
+`
+ if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil {
+ t.Fatalf("Failed to write config.yaml: %v", err)
+ }
- // When config.yaml is present with hardcoded port, it takes precedence
- // This is expected behavior - config file has priority
- // If you want env vars to override, use placeholders in YAML
- if cfg.Server.Port == "" {
- t.Error("expected non-empty port")
- }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
+
+ if cfg.Server.Port != "3000" {
+ t.Errorf("expected port 3000, got %s", cfg.Server.Port)
+ }
+ if cfg.Cache.Type != "redis" {
+ t.Errorf("expected cache type redis, got %s", cfg.Cache.Type)
+ }
+ if cfg.Cache.Redis.URL != "redis://myhost:6379" {
+ t.Errorf("expected redis URL redis://myhost:6379, got %s", cfg.Cache.Redis.URL)
+ }
+ if cfg.Cache.Redis.Key != "custom:key" {
+ t.Errorf("expected redis key custom:key, got %s", cfg.Cache.Redis.Key)
+ }
+ if cfg.Cache.Redis.TTL != 3600 {
+ t.Errorf("expected redis TTL 3600, got %d", cfg.Cache.Redis.TTL)
+ }
+ if !cfg.Logging.Enabled {
+ t.Error("expected Logging.Enabled=true from YAML")
+ }
+ if cfg.Logging.LogBodies {
+ t.Error("expected Logging.LogBodies=false from YAML")
+ }
+ if cfg.Logging.BufferSize != 500 {
+ t.Errorf("expected Logging.BufferSize=500, got %d", cfg.Logging.BufferSize)
+ }
+ // Defaults preserved for unset YAML fields
+ if cfg.Logging.FlushInterval != 5 {
+ t.Errorf("expected Logging.FlushInterval=5 (default), got %d", cfg.Logging.FlushInterval)
+ }
+ if cfg.Storage.Type != "sqlite" {
+ t.Errorf("expected Storage.Type=sqlite (default), got %s", cfg.Storage.Type)
+ }
+ })
}
-func TestLoad_OpenAIAPIKeyFromEnv(t *testing.T) {
- // Reset viper state before test
- viper.Reset()
+func TestLoad_EnvOverridesYAML(t *testing.T) {
+ clearAllConfigEnvVars(t)
+
+ withTempDir(t, func(dir string) {
+ yaml := `
+server:
+ port: "3000"
+cache:
+ type: "redis"
+logging:
+ enabled: true
+`
+ if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil {
+ t.Fatalf("Failed to write config.yaml: %v", err)
+ }
- // Set environment variable
- testAPIKey := "sk-test-key-12345"
- _ = os.Setenv("OPENAI_API_KEY", testAPIKey)
- defer func() { _ = os.Unsetenv("OPENAI_API_KEY") }()
+ t.Setenv("PORT", "9090")
+ t.Setenv("CACHE_TYPE", "local")
+ t.Setenv("LOGGING_ENABLED", "false")
- cfg, err := Load()
- if err != nil {
- t.Fatalf("Load() failed: %v", err)
- }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- // Check that OpenAI provider was created from environment variable
- provider, exists := cfg.Providers["openai"]
- if !exists {
- t.Fatal("expected 'openai' provider to exist")
- }
+ if cfg.Server.Port != "9090" {
+ t.Errorf("expected port 9090 (env override), got %s", cfg.Server.Port)
+ }
+ if cfg.Cache.Type != "local" {
+ t.Errorf("expected cache type local (env override), got %s", cfg.Cache.Type)
+ }
+ if cfg.Logging.Enabled {
+ t.Error("expected Logging.Enabled=false (env override)")
+ }
+ })
+}
- if provider.Type != "openai" {
- t.Errorf("expected provider type 'openai', got %s", provider.Type)
- }
+func TestLoad_EnvOverridesDefaults(t *testing.T) {
+ clearAllConfigEnvVars(t)
- if provider.APIKey != testAPIKey {
- t.Errorf("expected API key %s from env, got %s", testAPIKey, provider.APIKey)
- }
+ withTempDir(t, func(_ string) {
+ t.Setenv("PORT", "5555")
+ t.Setenv("STORAGE_TYPE", "postgresql")
+ t.Setenv("POSTGRES_URL", "postgres://localhost/test")
+ t.Setenv("POSTGRES_MAX_CONNS", "20")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
+
+ if cfg.Server.Port != "5555" {
+ t.Errorf("expected port 5555, got %s", cfg.Server.Port)
+ }
+ if cfg.Storage.Type != "postgresql" {
+ t.Errorf("expected storage type postgresql, got %s", cfg.Storage.Type)
+ }
+ if cfg.Storage.PostgreSQL.URL != "postgres://localhost/test" {
+ t.Errorf("expected postgres URL, got %s", cfg.Storage.PostgreSQL.URL)
+ }
+ if cfg.Storage.PostgreSQL.MaxConns != 20 {
+ t.Errorf("expected max conns 20, got %d", cfg.Storage.PostgreSQL.MaxConns)
+ }
+ })
}
-func TestLoad_EmptyAPIKey(t *testing.T) {
- // Reset viper state before test
- viper.Reset()
+func TestLoad_ProviderFromEnv(t *testing.T) {
+ clearAllConfigEnvVars(t)
- // Clear all API key environment variables and Ollama base URL
- _ = os.Unsetenv("OPENAI_API_KEY")
- _ = os.Unsetenv("ANTHROPIC_API_KEY")
- _ = os.Unsetenv("GEMINI_API_KEY")
- _ = os.Unsetenv("XAI_API_KEY")
- _ = os.Unsetenv("GROQ_API_KEY")
- _ = os.Unsetenv("OLLAMA_BASE_URL")
+ withTempDir(t, func(_ string) {
+ t.Setenv("OPENAI_API_KEY", "sk-test-key")
- cfg, err := Load()
- if err != nil {
- t.Fatalf("Load() failed: %v", err)
- }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- // When no API keys are set, only keyless providers like ollama (with default base_url) should remain
- // Ollama is preserved because it has a non-empty BaseURL from config.yaml default
- for name, pCfg := range cfg.Providers {
- if pCfg.Type == "ollama" && pCfg.BaseURL != "" {
- continue // Ollama with base_url is expected
+ provider, exists := cfg.Providers["openai"]
+ if !exists {
+ t.Fatal("expected 'openai' provider to exist")
}
- t.Errorf("unexpected provider %q with empty API key should have been filtered out", name)
- }
+ if provider.Type != "openai" {
+ t.Errorf("expected provider type 'openai', got %s", provider.Type)
+ }
+ if provider.APIKey != "sk-test-key" {
+ t.Errorf("expected API key sk-test-key, got %s", provider.APIKey)
+ }
+ })
}
-func TestLoad_MultipleEnvVars(t *testing.T) {
- // Reset viper state before test
- viper.Reset()
-
- // Set multiple environment variables
- testPort := "3000"
- testAPIKey := "sk-test-multiple"
- testAnthropicKey := "sk-ant-test"
-
- _ = os.Setenv("PORT", testPort)
- _ = os.Setenv("OPENAI_API_KEY", testAPIKey)
- _ = os.Setenv("ANTHROPIC_API_KEY", testAnthropicKey)
- defer func() {
- _ = os.Unsetenv("PORT")
- _ = os.Unsetenv("OPENAI_API_KEY")
- _ = os.Unsetenv("ANTHROPIC_API_KEY")
- }()
-
- cfg, err := Load()
- if err != nil {
- t.Fatalf("Load() failed: %v", err)
- }
+func TestLoad_ProviderFromYAML(t *testing.T) {
+ clearAllConfigEnvVars(t)
- // Note: Port from config.yaml takes precedence if it exists
- // This is expected behavior
- if cfg.Server.Port == "" {
- t.Error("expected non-empty port")
- }
+ withTempDir(t, func(dir string) {
+ yaml := `
+providers:
+ openai:
+ type: openai
+ api_key: "sk-yaml-key"
+ base_url: "https://custom.openai.com"
+`
+ if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil {
+ t.Fatalf("Failed to write config.yaml: %v", err)
+ }
- // Check OpenAI provider
- openaiProvider, exists := cfg.Providers["openai"]
- if !exists {
- t.Error("expected 'openai' provider to exist")
- } else if openaiProvider.APIKey != testAPIKey {
- t.Errorf("expected OpenAI API key %s, got %s", testAPIKey, openaiProvider.APIKey)
- }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- // Check Anthropic provider
- anthropicProvider, exists := cfg.Providers["anthropic"]
- if !exists {
- t.Error("expected 'anthropic' provider to exist")
- } else if anthropicProvider.APIKey != testAnthropicKey {
- t.Errorf("expected Anthropic API key %s, got %s", testAnthropicKey, anthropicProvider.APIKey)
- }
+ provider, exists := cfg.Providers["openai"]
+ if !exists {
+ t.Fatal("expected 'openai' provider to exist")
+ }
+ if provider.APIKey != "sk-yaml-key" {
+ t.Errorf("expected API key sk-yaml-key, got %s", provider.APIKey)
+ }
+ if provider.BaseURL != "https://custom.openai.com" {
+ t.Errorf("expected base URL https://custom.openai.com, got %s", provider.BaseURL)
+ }
+ })
}
-func TestLoad_DotEnvFile(t *testing.T) {
- // Reset viper state before test
- viper.Reset()
+func TestLoad_EnvOverridesProviderYAML(t *testing.T) {
+ clearAllConfigEnvVars(t)
- // Clear environment variables to test .env file reading
- _ = os.Unsetenv("PORT")
- _ = os.Unsetenv("OPENAI_API_KEY")
-
- // Create a temporary .env file
- envContent := `PORT=7070
-OPENAI_API_KEY=sk-from-dotenv-file
+ withTempDir(t, func(dir string) {
+ yaml := `
+providers:
+ openai:
+ type: openai
+ api_key: "sk-yaml-key"
`
- err := os.WriteFile(".env.test", []byte(envContent), 0644)
- if err != nil {
- t.Fatalf("Failed to create test .env file: %v", err)
- }
- defer func() { _ = os.Remove(".env.test") }()
+ if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil {
+ t.Fatalf("Failed to write config.yaml: %v", err)
+ }
- // Configure viper to read from test file
- viper.SetConfigName(".env.test")
- viper.SetConfigType("env")
- viper.AddConfigPath(".")
- _ = viper.ReadInConfig()
+ t.Setenv("OPENAI_API_KEY", "sk-env-key")
- // Set defaults
- viper.SetDefault("PORT", "8080")
- viper.AutomaticEnv()
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- cfg := &Config{
- Server: ServerConfig{
- Port: viper.GetString("PORT"),
- },
- Providers: make(map[string]ProviderConfig),
- }
+ provider, exists := cfg.Providers["openai"]
+ if !exists {
+ t.Fatal("expected 'openai' provider to exist")
+ }
+ if provider.APIKey != "sk-env-key" {
+ t.Errorf("expected API key sk-env-key (env override), got %s", provider.APIKey)
+ }
+ })
+}
+
+func TestLoad_OllamaNoAPIKey(t *testing.T) {
+ clearAllConfigEnvVars(t)
- // Add provider from environment variable
- if apiKey := viper.GetString("OPENAI_API_KEY"); apiKey != "" {
- cfg.Providers["openai"] = ProviderConfig{
- Type: "openai",
- APIKey: apiKey,
+ withTempDir(t, func(_ string) {
+ t.Setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
}
- }
- // Verify values from .env file
- if cfg.Server.Port != "7070" {
- t.Errorf("expected port 7070 from .env file, got %s", cfg.Server.Port)
- }
+ provider, exists := cfg.Providers["ollama"]
+ if !exists {
+ t.Fatal("expected 'ollama' provider to exist")
+ }
+ if provider.Type != "ollama" {
+ t.Errorf("expected provider type 'ollama', got %s", provider.Type)
+ }
+ if provider.BaseURL != "http://localhost:11434/v1" {
+ t.Errorf("expected base URL, got %s", provider.BaseURL)
+ }
+ })
+}
- openaiProvider, exists := cfg.Providers["openai"]
- if !exists {
- t.Fatal("expected 'openai' provider to exist")
- }
+func TestLoad_EmptyProviderFiltered(t *testing.T) {
+ clearAllConfigEnvVars(t)
- if openaiProvider.APIKey != "sk-from-dotenv-file" {
- t.Errorf("expected API key from .env file, got %s", openaiProvider.APIKey)
- }
+ withTempDir(t, func(_ string) {
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
+
+ if len(cfg.Providers) != 0 {
+ t.Errorf("expected no providers, got %d: %v", len(cfg.Providers), cfg.Providers)
+ }
+ })
}
-func TestLoad_EnvOverridesDotEnv(t *testing.T) {
- // Reset viper state before test
- viper.Reset()
+func TestLoad_HTTPConfig(t *testing.T) {
+ clearAllConfigEnvVars(t)
- // Create a temporary .env file
- envContent := `PORT=7070
-OPENAI_API_KEY=sk-from-dotenv-file
-`
- err := os.WriteFile(".env.test2", []byte(envContent), 0644)
- if err != nil {
- t.Fatalf("Failed to create test .env file: %v", err)
- }
- defer func() { _ = os.Remove(".env.test2") }()
+ // Test defaults
+ withTempDir(t, func(_ string) {
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- // Set environment variables (should override .env file)
- _ = os.Setenv("PORT", "9999")
- _ = os.Setenv("OPENAI_API_KEY", "sk-from-real-env")
- defer func() {
- _ = os.Unsetenv("PORT")
- _ = os.Unsetenv("OPENAI_API_KEY")
- }()
+ if cfg.HTTP.Timeout != 600 {
+ t.Errorf("expected HTTP.Timeout=600, got %d", cfg.HTTP.Timeout)
+ }
+ if cfg.HTTP.ResponseHeaderTimeout != 600 {
+ t.Errorf("expected HTTP.ResponseHeaderTimeout=600, got %d", cfg.HTTP.ResponseHeaderTimeout)
+ }
+ })
- // Configure viper to read from test file
- viper.SetConfigName(".env.test2")
- viper.SetConfigType("env")
- viper.AddConfigPath(".")
- _ = viper.ReadInConfig()
+ // Test env override
+ withTempDir(t, func(_ string) {
+ t.Setenv("HTTP_TIMEOUT", "30")
+ t.Setenv("HTTP_RESPONSE_HEADER_TIMEOUT", "60")
- // Set defaults
- viper.SetDefault("PORT", "8080")
- viper.AutomaticEnv()
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- cfg := &Config{
- Server: ServerConfig{
- Port: viper.GetString("PORT"),
- },
- Providers: make(map[string]ProviderConfig),
- }
+ if cfg.HTTP.Timeout != 30 {
+ t.Errorf("expected HTTP.Timeout=30, got %d", cfg.HTTP.Timeout)
+ }
+ if cfg.HTTP.ResponseHeaderTimeout != 60 {
+ t.Errorf("expected HTTP.ResponseHeaderTimeout=60, got %d", cfg.HTTP.ResponseHeaderTimeout)
+ }
+ })
+}
+
+func TestLoad_CacheDir(t *testing.T) {
+ clearAllConfigEnvVars(t)
- // Add provider from environment variable
- if apiKey := viper.GetString("OPENAI_API_KEY"); apiKey != "" {
- cfg.Providers["openai"] = ProviderConfig{
- Type: "openai",
- APIKey: apiKey,
+ // Test default
+ withTempDir(t, func(_ string) {
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
}
- }
- // Environment variables should override .env file
- if cfg.Server.Port != "9999" {
- t.Errorf("expected port 9999 from environment variable (not .env file), got %s", cfg.Server.Port)
- }
+ if cfg.Cache.CacheDir != ".cache" {
+ t.Errorf("expected Cache.CacheDir=.cache, got %s", cfg.Cache.CacheDir)
+ }
+ })
- openaiProvider, exists := cfg.Providers["openai"]
- if !exists {
- t.Fatal("expected 'openai' provider to exist")
- }
+ // Test env override
+ withTempDir(t, func(_ string) {
+ t.Setenv("GOMODEL_CACHE_DIR", "/tmp/gomodel-cache")
- if openaiProvider.APIKey != "sk-from-real-env" {
- t.Errorf("expected API key from environment variable (not .env file), got %s", openaiProvider.APIKey)
- }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
+
+ if cfg.Cache.CacheDir != "/tmp/gomodel-cache" {
+ t.Errorf("expected Cache.CacheDir=/tmp/gomodel-cache, got %s", cfg.Cache.CacheDir)
+ }
+ })
}
-func TestLoggingOnlyModelInteractionsDefault(t *testing.T) {
- // Reset viper state before test
- viper.Reset()
+func TestLoad_MultipleProviders(t *testing.T) {
+ clearAllConfigEnvVars(t)
- // Clear all relevant environment variables
- _ = os.Unsetenv("LOGGING_ONLY_MODEL_INTERACTIONS")
- _ = os.Unsetenv("OPENAI_API_KEY")
+ withTempDir(t, func(_ string) {
+ t.Setenv("OPENAI_API_KEY", "sk-openai")
+ t.Setenv("ANTHROPIC_API_KEY", "sk-anthropic")
- cfg, err := Load()
- if err != nil {
- t.Fatalf("Load() failed: %v", err)
- }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- // Default should be true
- if !cfg.Logging.OnlyModelInteractions {
- t.Error("expected OnlyModelInteractions to default to true")
- }
+ if _, ok := cfg.Providers["openai"]; !ok {
+ t.Error("expected 'openai' provider")
+ }
+ if _, ok := cfg.Providers["anthropic"]; !ok {
+ t.Error("expected 'anthropic' provider")
+ }
+ if cfg.Providers["openai"].APIKey != "sk-openai" {
+ t.Errorf("expected OpenAI key sk-openai, got %s", cfg.Providers["openai"].APIKey)
+ }
+ if cfg.Providers["anthropic"].APIKey != "sk-anthropic" {
+ t.Errorf("expected Anthropic key sk-anthropic, got %s", cfg.Providers["anthropic"].APIKey)
+ }
+ })
+}
+
+func TestLoad_LoggingOnlyModelInteractionsDefault(t *testing.T) {
+ clearAllConfigEnvVars(t)
+
+ withTempDir(t, func(_ string) {
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
+
+ if !cfg.Logging.OnlyModelInteractions {
+ t.Error("expected OnlyModelInteractions to default to true")
+ }
+ })
}
-func TestLoggingOnlyModelInteractionsFromEnv(t *testing.T) {
+func TestLoad_LoggingOnlyModelInteractionsFromEnv(t *testing.T) {
tests := []struct {
name string
envValue string
@@ -312,185 +542,142 @@ func TestLoggingOnlyModelInteractionsFromEnv(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- // Reset viper state before each subtest
- viper.Reset()
+ clearAllConfigEnvVars(t)
- // Clear and set environment variable
- _ = os.Unsetenv("OPENAI_API_KEY")
- _ = os.Setenv("LOGGING_ONLY_MODEL_INTERACTIONS", tt.envValue)
- defer func() { _ = os.Unsetenv("LOGGING_ONLY_MODEL_INTERACTIONS") }()
+ withTempDir(t, func(_ string) {
+ t.Setenv("LOGGING_ONLY_MODEL_INTERACTIONS", tt.envValue)
- cfg, err := Load()
- if err != nil {
- t.Fatalf("Load() failed: %v", err)
- }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- if cfg.Logging.OnlyModelInteractions != tt.expected {
- t.Errorf("expected OnlyModelInteractions=%v for env value %q, got %v",
- tt.expected, tt.envValue, cfg.Logging.OnlyModelInteractions)
- }
+ if cfg.Logging.OnlyModelInteractions != tt.expected {
+ t.Errorf("expected OnlyModelInteractions=%v for env value %q, got %v",
+ tt.expected, tt.envValue, cfg.Logging.OnlyModelInteractions)
+ }
+ })
})
}
}
-func TestSnakeCaseMatchName(t *testing.T) {
- tests := []struct {
- name string
- mapKey string
- fieldName string
- expected bool
- }{
- // Snake case to PascalCase matches
- {"body_size_limit matches BodySizeLimit", "body_size_limit", "BodySizeLimit", true},
- {"api_key matches APIKey", "api_key", "APIKey", true},
- {"base_url matches BaseURL", "base_url", "BaseURL", true},
- {"storage_type matches StorageType", "storage_type", "StorageType", true},
- {"log_bodies matches LogBodies", "log_bodies", "LogBodies", true},
- {"only_model_interactions matches OnlyModelInteractions", "only_model_interactions", "OnlyModelInteractions", true},
- {"max_conns matches MaxConns", "max_conns", "MaxConns", true},
- {"flush_interval matches FlushInterval", "flush_interval", "FlushInterval", true},
- {"retention_days matches RetentionDays", "retention_days", "RetentionDays", true},
-
- // Simple case-insensitive matches (no underscores)
- {"port matches Port", "port", "Port", true},
- {"enabled matches Enabled", "enabled", "Enabled", true},
- {"type matches Type", "type", "Type", true},
- {"url matches URL", "url", "URL", true},
- {"ttl matches TTL", "ttl", "TTL", true},
- {"redis matches Redis", "redis", "Redis", true},
- {"sqlite matches SQLite", "sqlite", "SQLite", true},
- {"postgresql matches PostgreSQL", "postgresql", "PostgreSQL", true},
- {"mongodb matches MongoDB", "mongodb", "MongoDB", true},
-
- // Case variations
- {"PORT matches Port", "PORT", "Port", true},
- {"Port matches Port", "Port", "Port", true},
- {"BODY_SIZE_LIMIT matches BodySizeLimit", "BODY_SIZE_LIMIT", "BodySizeLimit", true},
-
- // Non-matches
- {"different names don't match", "foo", "Bar", false},
- {"partial match fails", "body_size", "BodySizeLimit", false},
-
- // Malformed keys are rejected
- {"consecutive underscores rejected", "body__size_limit", "BodySizeLimit", false},
- {"leading underscore rejected", "_port", "Port", false},
- {"trailing underscore rejected", "port_", "Port", false},
- {"leading and trailing underscore rejected", "_port_", "Port", false},
- }
-
- // Get the MatchName function from snakeCaseMatchName
- var decoderConfig mapstructure.DecoderConfig
- opt := snakeCaseMatchName()
- opt(&decoderConfig)
- matchName := decoderConfig.MatchName
+func TestLoad_YAMLWithEnvVarExpansion(t *testing.T) {
+ clearAllConfigEnvVars(t)
+
+ withTempDir(t, func(dir string) {
+ yaml := `
+server:
+ port: "${TEST_PORT_CFG:-9999}"
+providers:
+ openai:
+ type: "openai"
+ api_key: "${TEST_KEY_CFG:-default-key}"
+`
+ if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil {
+ t.Fatalf("Failed to write config.yaml: %v", err)
+ }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := matchName(tt.mapKey, tt.fieldName)
- if result != tt.expected {
- t.Errorf("matchName(%q, %q) = %v, expected %v",
- tt.mapKey, tt.fieldName, result, tt.expected)
- }
- })
- }
+ // Test with defaults (env vars not set)
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
+
+ if cfg.Server.Port != "9999" {
+ t.Errorf("expected port 9999 (YAML default), got %s", cfg.Server.Port)
+ }
+ provider := cfg.Providers["openai"]
+ if provider.APIKey != "default-key" {
+ t.Errorf("expected API key 'default-key', got %s", provider.APIKey)
+ }
+ })
}
-func TestSnakeCaseMatchNameWithViper(t *testing.T) {
- // Reset viper state
- viper.Reset()
-
- // Create a map simulating YAML config with snake_case keys
- configData := map[string]any{
- "server": map[string]any{
- "port": "9090",
- "master_key": "test-master-key",
- "body_size_limit": "50M",
- },
- "logging": map[string]any{
- "enabled": true,
- "log_bodies": false,
- "log_headers": true,
- "buffer_size": 500,
- "flush_interval": 10,
- "retention_days": 60,
- "only_model_interactions": false,
- },
- "storage": map[string]any{
- "type": "postgresql",
- },
- "cache": map[string]any{
- "type": "redis",
- "redis": map[string]any{
- "url": "redis://localhost:6379",
- "key": "test:models",
- "ttl": 3600,
- },
- },
- }
-
- // Set the config data in viper
- for k, v := range configData {
- viper.Set(k, v)
- }
-
- var cfg Config
- err := viper.Unmarshal(&cfg, snakeCaseMatchName())
- if err != nil {
- t.Fatalf("Unmarshal failed: %v", err)
- }
+func TestLoad_YAMLWithEnvVarOverride(t *testing.T) {
+ clearAllConfigEnvVars(t)
+
+ withTempDir(t, func(dir string) {
+ yaml := `
+server:
+ port: "${TEST_PORT_CFG:-9999}"
+providers:
+ openai:
+ type: "openai"
+ api_key: "${TEST_KEY_CFG:-default-key}"
+`
+ if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil {
+ t.Fatalf("Failed to write config.yaml: %v", err)
+ }
- // Verify server config
- if cfg.Server.Port != "9090" {
- t.Errorf("expected Server.Port=9090, got %s", cfg.Server.Port)
- }
- if cfg.Server.MasterKey != "test-master-key" {
- t.Errorf("expected Server.MasterKey=test-master-key, got %s", cfg.Server.MasterKey)
- }
- if cfg.Server.BodySizeLimit != "50M" {
- t.Errorf("expected Server.BodySizeLimit=50M, got %s", cfg.Server.BodySizeLimit)
- }
+ t.Setenv("TEST_PORT_CFG", "1111")
+ t.Setenv("TEST_KEY_CFG", "real-key")
- // Verify storage config
- if cfg.Storage.Type != "postgresql" {
- t.Errorf("expected Storage.Type=postgresql, got %s", cfg.Storage.Type)
- }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
- // Verify logging config
- if !cfg.Logging.Enabled {
- t.Error("expected Logging.Enabled=true")
- }
- if cfg.Logging.LogBodies {
- t.Error("expected Logging.LogBodies=false")
- }
- if !cfg.Logging.LogHeaders {
- t.Error("expected Logging.LogHeaders=true")
- }
- if cfg.Logging.BufferSize != 500 {
- t.Errorf("expected Logging.BufferSize=500, got %d", cfg.Logging.BufferSize)
- }
- if cfg.Logging.FlushInterval != 10 {
- t.Errorf("expected Logging.FlushInterval=10, got %d", cfg.Logging.FlushInterval)
- }
- if cfg.Logging.RetentionDays != 60 {
- t.Errorf("expected Logging.RetentionDays=60, got %d", cfg.Logging.RetentionDays)
- }
- if cfg.Logging.OnlyModelInteractions {
- t.Error("expected Logging.OnlyModelInteractions=false")
- }
+ if cfg.Server.Port != "1111" {
+ t.Errorf("expected port 1111 (env override), got %s", cfg.Server.Port)
+ }
+ provider := cfg.Providers["openai"]
+ if provider.APIKey != "real-key" {
+ t.Errorf("expected API key 'real-key', got %s", provider.APIKey)
+ }
+ })
+}
- // Verify cache config
- if cfg.Cache.Type != "redis" {
- t.Errorf("expected Cache.Type=redis, got %s", cfg.Cache.Type)
- }
- if cfg.Cache.Redis.URL != "redis://localhost:6379" {
- t.Errorf("expected Cache.Redis.URL=redis://localhost:6379, got %s", cfg.Cache.Redis.URL)
- }
- if cfg.Cache.Redis.Key != "test:models" {
- t.Errorf("expected Cache.Redis.Key=test:models, got %s", cfg.Cache.Redis.Key)
- }
- if cfg.Cache.Redis.TTL != 3600 {
- t.Errorf("expected Cache.Redis.TTL=3600, got %d", cfg.Cache.Redis.TTL)
- }
+func TestLoad_YAMLInConfigSubdir(t *testing.T) {
+ clearAllConfigEnvVars(t)
+
+ withTempDir(t, func(dir string) {
+ configDir := filepath.Join(dir, "config")
+ if err := os.MkdirAll(configDir, 0755); err != nil {
+ t.Fatalf("Failed to create config dir: %v", err)
+ }
+
+ yaml := `
+server:
+ port: "4444"
+`
+ if err := os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(yaml), 0644); err != nil {
+ t.Fatalf("Failed to write config/config.yaml: %v", err)
+ }
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
+
+ if cfg.Server.Port != "4444" {
+ t.Errorf("expected port 4444 from config/config.yaml, got %s", cfg.Server.Port)
+ }
+ })
+}
+
+func TestLoad_UnexpandedProviderFiltered(t *testing.T) {
+ clearAllConfigEnvVars(t)
+
+ withTempDir(t, func(dir string) {
+ yaml := `
+providers:
+ openai:
+ type: openai
+ api_key: "${OPENAI_API_KEY}"
+`
+ if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil {
+ t.Fatalf("Failed to write config.yaml: %v", err)
+ }
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() failed: %v", err)
+ }
+
+ if _, exists := cfg.Providers["openai"]; exists {
+ t.Error("expected openai provider with unexpanded ${OPENAI_API_KEY} to be filtered out")
+ }
+ })
}
func TestValidateBodySizeLimit(t *testing.T) {
diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx
new file mode 100644
index 000000000..356570cf0
--- /dev/null
+++ b/docs/advanced/configuration.mdx
@@ -0,0 +1,293 @@
+---
+title: "Configuration"
+description: "How to configure GOModel using environment variables, .env files, and YAML."
+---
+
+## Overview
+
+GOModel uses a three-layer configuration pipeline. Every setting has a sensible default, so you can start the server with zero configuration.
+
+```
+Code Defaults → config.yaml (optional) → Environment Variables (always win)
+```
+
+Each layer overrides the previous one. Environment variables always take the highest priority.
+
+
+ GOModel works out of the box with no configuration files. Just set your provider API keys as environment variables and start the server.
+
+
+## Quick Start
+
+The fastest way to get running:
+
+```bash
+# Set at least one provider API key
+export OPENAI_API_KEY="sk-..."
+
+# Start the server (uses default port 8080)
+./gomodel
+```
+
+GOModel automatically discovers providers from well-known environment variables. No config file needed.
+
+## Configuration Methods
+
+### 1. Environment Variables
+
+The most common way to configure GOModel. Set any of the variables below to override defaults.
+
+#### Server
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `PORT` | HTTP server port | `8080` |
+| `GOMODEL_MASTER_KEY` | Authentication key for securing the gateway | _(empty, unsafe mode)_ |
+| `BODY_SIZE_LIMIT` | Max request body size (e.g., `10M`, `1024K`, `500KB`) | _(no limit)_ |
+
+#### Cache
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `CACHE_TYPE` | Cache backend: `local` or `redis` | `local` |
+| `GOMODEL_CACHE_DIR` | Directory for local cache files | `.cache` |
+| `REDIS_URL` | Redis connection URL | _(empty)_ |
+| `REDIS_KEY` | Redis key for model cache | `gomodel:models` |
+| `REDIS_TTL` | Cache TTL in seconds | `86400` (24h) |
+
+#### Storage
+
+Storage is shared by audit logging, usage tracking, and future features like IAM.
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `STORAGE_TYPE` | Backend: `sqlite`, `postgresql`, or `mongodb` | `sqlite` |
+| `SQLITE_PATH` | SQLite database file path | `.cache/gomodel.db` |
+| `POSTGRES_URL` | PostgreSQL connection string | _(empty)_ |
+| `POSTGRES_MAX_CONNS` | PostgreSQL connection pool size | `10` |
+| `MONGODB_URL` | MongoDB connection string | _(empty)_ |
+| `MONGODB_DATABASE` | MongoDB database name | `gomodel` |
+
+#### Audit Logging
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `LOGGING_ENABLED` | Enable audit logging | `false` |
+| `LOGGING_LOG_BODIES` | Log request/response bodies | `true` |
+| `LOGGING_LOG_HEADERS` | Log headers (sensitive ones auto-redacted) | `true` |
+| `LOGGING_ONLY_MODEL_INTERACTIONS` | Only log AI model endpoints | `true` |
+| `LOGGING_BUFFER_SIZE` | In-memory buffer before flush | `1000` |
+| `LOGGING_FLUSH_INTERVAL` | Flush interval in seconds | `5` |
+| `LOGGING_RETENTION_DAYS` | Auto-delete after N days (0 = forever) | `30` |
+
+
+ When `LOGGING_LOG_BODIES` is enabled, request and response bodies are stored in full. These may contain sensitive data such as PII or API keys embedded in prompts.
+
+
+#### Token Usage Tracking
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `USAGE_ENABLED` | Enable token usage tracking | `true` |
+| `ENFORCE_RETURNING_USAGE_DATA` | Auto-add `include_usage` to streaming requests | `true` |
+| `USAGE_BUFFER_SIZE` | In-memory buffer before flush | `1000` |
+| `USAGE_FLUSH_INTERVAL` | Flush interval in seconds | `5` |
+| `USAGE_RETENTION_DAYS` | Auto-delete after N days (0 = forever) | `90` |
+
+#### Metrics
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `METRICS_ENABLED` | Enable Prometheus metrics | `false` |
+| `METRICS_ENDPOINT` | HTTP path for metrics | `/metrics` |
+
+#### HTTP Client
+
+These control timeouts for upstream API requests to LLM providers.
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `HTTP_TIMEOUT` | Overall request timeout in seconds | `600` (10 min) |
+| `HTTP_RESPONSE_HEADER_TIMEOUT` | Time to wait for response headers in seconds | `600` (10 min) |
+
+#### Provider API Keys
+
+Set these to automatically register providers. No YAML configuration required.
+
+| Variable | Provider |
+|----------|----------|
+| `OPENAI_API_KEY` | OpenAI |
+| `ANTHROPIC_API_KEY` | Anthropic |
+| `GEMINI_API_KEY` | Google Gemini |
+| `XAI_API_KEY` | xAI (Grok) |
+| `GROQ_API_KEY` | Groq |
+| `OLLAMA_BASE_URL` | Ollama (no API key needed) |
+
+You can also set a custom base URL for any provider using `_BASE_URL` (e.g., `OPENAI_BASE_URL`).
+
+### 2. `.env` File
+
+GOModel automatically loads a `.env` file from the working directory at startup. This is convenient for local development.
+
+```bash
+# .env
+PORT=3000
+OPENAI_API_KEY=sk-...
+ANTHROPIC_API_KEY=sk-ant-...
+```
+
+Copy `.env.template` to `.env` and uncomment the values you need:
+
+```bash
+cp .env.template .env
+```
+
+
+ Real environment variables always override values from the `.env` file. The `.env` file is only loaded if it exists — missing it is not an error.
+
+
+### 3. Configuration File (YAML)
+
+For more complex setups, you can use an optional YAML configuration file. GOModel looks for it in two locations (in order):
+
+1. `config/config.yaml`
+2. `config.yaml`
+
+To get started, copy the example:
+
+```bash
+cp config/config.example.yaml config/config.yaml
+```
+
+Then uncomment and edit the settings you want to change:
+
+```yaml
+server:
+ port: "3000"
+ master_key: "my-secret-key"
+
+cache:
+ type: redis
+ redis:
+ url: "redis://my-redis:6379"
+
+providers:
+ openai:
+ type: openai
+ api_key: "sk-..."
+
+ anthropic:
+ type: anthropic
+ api_key: "sk-ant-..."
+
+ # Custom OpenAI-compatible provider
+ my-custom-llm:
+ type: openai
+ base_url: "https://api.example.com/v1"
+ api_key: "..."
+```
+
+The YAML file supports environment variable expansion using `${VAR}` and `${VAR:-default}` syntax:
+
+```yaml
+server:
+ port: "${PORT:-8080}"
+
+providers:
+ openai:
+ type: openai
+ api_key: "${OPENAI_API_KEY}"
+```
+
+
+ The YAML file is entirely optional. Any setting you can put in YAML can also be set via environment variables. Use YAML when you need to configure custom providers or prefer a structured config file.
+
+
+## Provider Configuration
+
+### Auto-Discovery from Environment Variables
+
+The simplest way to add providers. GOModel checks for well-known API key environment variables and automatically registers providers:
+
+```bash
+export OPENAI_API_KEY="sk-..." # Registers "openai" provider
+export ANTHROPIC_API_KEY="sk-ant-..." # Registers "anthropic" provider
+export GEMINI_API_KEY="..." # Registers "gemini" provider
+```
+
+### YAML Provider Blocks
+
+For more control (custom base URLs, model restrictions, or custom provider names), use the YAML file:
+
+```yaml
+providers:
+ # Override OpenAI base URL
+ openai:
+ type: openai
+ api_key: "sk-..."
+ base_url: "https://my-proxy.example.com/v1"
+
+ # Add a second OpenAI-compatible endpoint
+ azure-openai:
+ type: openai
+ base_url: "https://my-resource.openai.azure.com/openai/deployments/gpt-4"
+ api_key: "..."
+
+ # Restrict to specific models
+ gemini:
+ type: gemini
+ api_key: "..."
+ models:
+ - gemini-2.0-flash
+ - gemini-1.5-pro
+```
+
+### Ollama (Local Models)
+
+Ollama does not require an API key. Set the base URL to enable it:
+
+```bash
+export OLLAMA_BASE_URL="http://localhost:11434/v1"
+```
+
+Or in YAML:
+
+```yaml
+providers:
+ ollama:
+ type: ollama
+ base_url: "http://localhost:11434/v1"
+```
+
+
+ Providers with missing or unresolved API keys are automatically filtered out at startup. Ollama is the only exception — it only requires a base URL.
+
+
+## Precedence Rules
+
+When the same setting is defined in multiple places, the highest-priority source wins:
+
+```
+Environment Variable > YAML File > Code Default
+```
+
+**Example:** If `config.yaml` sets `port: "3000"` but the environment has `PORT=9090`, the server starts on port **9090**.
+
+For providers, environment variables override YAML values for the same provider name. If `OPENAI_API_KEY` is set in the environment and also defined in YAML, the environment value is used.
+
+## Default Values
+
+All settings have sensible defaults. GOModel starts with zero configuration using these values:
+
+| Setting | Default |
+|---------|---------|
+| Server port | `8080` |
+| Cache type | `local` |
+| Cache directory | `.cache` |
+| Storage type | `sqlite` |
+| SQLite path | `.cache/gomodel.db` |
+| Audit logging | Disabled |
+| Usage tracking | Enabled |
+| Metrics | Disabled |
+| HTTP timeout | 600s (10 minutes) |
+| Providers | None (auto-discovered from env) |
diff --git a/docs/docs.json b/docs/docs.json
index f2ece3020..8294cbda6 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -6,6 +6,11 @@
"primary": "#755c3d"
},
"navigation": {
- "pages": []
+ "pages": [
+ {
+ "group": "Advanced",
+ "pages": ["advanced/configuration"]
+ }
+ ]
}
}
diff --git a/go.mod b/go.mod
index 0d2e5fdbd..c2b51bb7e 100644
--- a/go.mod
+++ b/go.mod
@@ -4,19 +4,18 @@ go 1.24.0
require (
github.com/andybalholm/brotli v1.2.0
- github.com/go-viper/mapstructure/v2 v2.5.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.15.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.17.3
- github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.40.0
github.com/testcontainers/testcontainers-go/modules/mongodb v0.40.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
go.mongodb.org/mongo-driver/v2 v2.5.0
+ gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
)
@@ -41,7 +40,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
- github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
@@ -68,7 +66,6 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
@@ -76,13 +73,8 @@ require (
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
- github.com/spf13/afero v1.15.0 // indirect
- github.com/spf13/cast v1.10.0 // indirect
- github.com/spf13/pflag v1.0.10 // indirect
- github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
@@ -99,7 +91,6 @@ require (
go.opentelemetry.io/otel/sdk v1.21.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
- go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.48.0 // indirect
@@ -108,7 +99,6 @@ require (
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index 9a8195c6e..54f35a69b 100644
--- a/go.sum
+++ b/go.sum
@@ -49,10 +49,6 @@ github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0o
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
-github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
-github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -60,8 +56,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
-github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
-github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -133,8 +127,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
-github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
-github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -155,20 +147,10 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
-github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
-github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
-github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
-github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
-github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
-github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
-github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
@@ -176,8 +158,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
-github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.40.0 h1:z/1qHeliTLDKNaJ7uOHOx1FjwghbcbYfga4dTFkF0hU=
@@ -229,8 +209,6 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
-go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
-go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go
index 4a20076e8..66a67f04b 100644
--- a/internal/httpclient/client.go
+++ b/internal/httpclient/client.go
@@ -59,6 +59,10 @@ func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
// Can be overridden via environment variables (values in seconds, or Go duration format):
// - HTTP_TIMEOUT: overall request timeout (default: 600)
// - HTTP_RESPONSE_HEADER_TIMEOUT: time to wait for response headers (default: 600)
+//
+// Note: These env vars are also documented in config.HTTPConfig. The env var bridge
+// here works correctly because godotenv.Load() in config.Load() ensures .env values
+// are available in the process environment before providers are initialized.
func DefaultConfig() ClientConfig {
return ClientConfig{
MaxIdleConns: 100,
diff --git a/internal/providers/init.go b/internal/providers/init.go
index 428039bb6..42852b129 100644
--- a/internal/providers/init.go
+++ b/internal/providers/init.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
- "os"
"path/filepath"
"sort"
"time"
@@ -134,14 +133,6 @@ func InitWithConfig(ctx context.Context, cfg *config.Config, initCfg InitConfig)
}, nil
}
-// getCacheDir returns the directory for cache files.
-// Uses $GOMODEL_CACHE_DIR if set, otherwise ./.cache (working directory)
-func getCacheDir() string {
- if cacheDir := os.Getenv("GOMODEL_CACHE_DIR"); cacheDir != "" {
- return cacheDir
- }
- return ".cache"
-}
// initCache initializes the appropriate cache backend based on configuration.
func initCache(cfg *config.Config) (cache.Cache, error) {
@@ -172,7 +163,11 @@ func initCache(cfg *config.Config) (cache.Cache, error) {
return redisCache, nil
default: // "local" or any other value defaults to local
- cacheFile := filepath.Join(getCacheDir(), "models.json")
+ cacheDir := cfg.Cache.CacheDir
+ if cacheDir == "" {
+ cacheDir = ".cache"
+ }
+ cacheFile := filepath.Join(cacheDir, "models.json")
slog.Info("using local file cache", "path", cacheFile)
return cache.NewLocalCache(cacheFile), nil
}