homelabby/internal/config/config.go
Mikkel Georgsen 0190e8583c feat(06-lab-advisor-02): AdvisorHandler SSE streaming + router wiring
- internal/advisor/handler.go: StreamChat (SSE, token-by-token),
  GetConversations, GetConversation; body limited to 64KB, message
  truncated to 8000 chars (T-06-02-03); API key never echoed (T-06-02-02)
- internal/api/router.go: /api/advisor/{chat,conversations,conversations/{id}}
  with nil-guard returning 503 when DB not configured
- internal/config/config.go: Tier3 defaults + HWLAB_AI_TIER3_* env bindings
- cmd/hwlab/main.go: store init from HWLAB_DATABASE_URL, RunMigrations,
  InventoryContextBuilder, AdvisorHandler wired into NewRouter
2026-04-10 07:36:16 +00:00

134 lines
5 KiB
Go

package config
import (
"fmt"
"github.com/joho/godotenv"
"github.com/spf13/viper"
"git.georgsen.dk/hwlab/internal/ai"
)
type Config struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
LogLevel string `mapstructure:"log_level"`
NetBoxURL string `mapstructure:"netbox_url"`
NetBoxToken string `mapstructure:"netbox_token"`
NetBoxTimeoutSeconds int `mapstructure:"netbox_timeout_seconds"`
DragonflyURL string `mapstructure:"dragonfly_url"`
WAQRetryIntervalSeconds int `mapstructure:"waq_retry_interval_seconds"`
WAQMaxAttempts int `mapstructure:"waq_max_attempts"`
QualityGateConfidenceThreshold float64 `mapstructure:"quality_gate_confidence_threshold"`
NetBoxDefaultDeviceTypeID int32 `mapstructure:"netbox_default_device_type_id"`
NetBoxDefaultRoleID int32 `mapstructure:"netbox_default_role_id"`
NetBoxDefaultSiteID int32 `mapstructure:"netbox_default_site_id"`
AI ai.AIConfig `mapstructure:"ai"`
}
func Load() (*Config, error) {
// Load .env file if present (ignore error — .env is optional in production)
_ = godotenv.Load()
v := viper.New()
// Set defaults
v.SetDefault("host", "0.0.0.0")
v.SetDefault("port", 8080)
v.SetDefault("log_level", "info")
v.SetDefault("netbox_timeout_seconds", 10)
v.SetDefault("waq_retry_interval_seconds", 30)
v.SetDefault("waq_max_attempts", 5)
v.SetDefault("quality_gate_confidence_threshold", 0.75)
v.SetDefault("netbox_default_device_type_id", 1)
v.SetDefault("netbox_default_role_id", 1)
v.SetDefault("netbox_default_site_id", 1)
// AI tier defaults
v.SetDefault("ai.tier1.base_url", "http://localhost:8000/v1")
v.SetDefault("ai.tier1.api_key", "local")
v.SetDefault("ai.tier1.model", "gemma-4-e4b")
v.SetDefault("ai.tier1.timeout_seconds", 30)
v.SetDefault("ai.tier2.base_url", "https://openrouter.ai/api/v1")
v.SetDefault("ai.tier2.api_key", "")
v.SetDefault("ai.tier2.model", "google/gemma-3-27b-it")
v.SetDefault("ai.tier2.timeout_seconds", 60)
v.SetDefault("ai.tier3.base_url", "https://openrouter.ai/api/v1")
v.SetDefault("ai.tier3.api_key", "")
v.SetDefault("ai.tier3.model", "anthropic/claude-opus-4")
v.SetDefault("ai.tier3.timeout_seconds", 120)
v.SetDefault("ai.confidence_threshold", 0.75)
v.SetDefault("ai.quick_add_enabled", false)
v.SetDefault("ai.quick_add_threshold", 0.90)
// Config file
v.SetConfigName("config")
v.SetConfigType("json")
v.AddConfigPath(".")
v.AddConfigPath("/etc/hwlab")
// Environment variables: HWLAB_PORT -> port, HWLAB_NETBOX_URL -> netbox_url, etc.
v.SetEnvPrefix("HWLAB")
v.AutomaticEnv()
// Explicit bindings ensure AutomaticEnv works correctly with mapstructure v2 unmarshalling.
_ = v.BindEnv("host", "HWLAB_HOST")
_ = v.BindEnv("port", "HWLAB_PORT")
_ = v.BindEnv("log_level", "HWLAB_LOG_LEVEL")
_ = v.BindEnv("netbox_url", "HWLAB_NETBOX_URL")
_ = v.BindEnv("netbox_token", "HWLAB_NETBOX_TOKEN")
_ = v.BindEnv("netbox_timeout_seconds", "HWLAB_NETBOX_TIMEOUT_SECONDS")
_ = v.BindEnv("dragonfly_url", "HWLAB_DRAGONFLY_URL")
_ = v.BindEnv("waq_retry_interval_seconds", "HWLAB_WAQ_RETRY_INTERVAL_SECONDS")
_ = v.BindEnv("waq_max_attempts", "HWLAB_WAQ_MAX_ATTEMPTS")
_ = v.BindEnv("quality_gate_confidence_threshold", "HWLAB_QUALITY_GATE_CONFIDENCE_THRESHOLD")
_ = v.BindEnv("netbox_default_device_type_id", "HWLAB_NETBOX_DEFAULT_DEVICE_TYPE_ID")
_ = v.BindEnv("netbox_default_role_id", "HWLAB_NETBOX_DEFAULT_ROLE_ID")
_ = v.BindEnv("netbox_default_site_id", "HWLAB_NETBOX_DEFAULT_SITE_ID")
// AI env bindings
_ = v.BindEnv("ai.tier1.base_url", "HWLAB_AI_TIER1_BASE_URL")
_ = v.BindEnv("ai.tier1.api_key", "HWLAB_AI_TIER1_API_KEY")
_ = v.BindEnv("ai.tier1.model", "HWLAB_AI_TIER1_MODEL")
_ = v.BindEnv("ai.tier2.base_url", "HWLAB_AI_TIER2_BASE_URL")
_ = v.BindEnv("ai.tier2.api_key", "HWLAB_AI_TIER2_API_KEY")
_ = v.BindEnv("ai.tier2.model", "HWLAB_AI_TIER2_MODEL")
_ = v.BindEnv("ai.tier3.base_url", "HWLAB_AI_TIER3_BASE_URL")
_ = v.BindEnv("ai.tier3.api_key", "HWLAB_AI_TIER3_API_KEY")
_ = v.BindEnv("ai.tier3.model", "HWLAB_AI_TIER3_MODEL")
_ = v.BindEnv("ai.confidence_threshold", "HWLAB_AI_CONFIDENCE_THRESHOLD")
_ = v.BindEnv("ai.quick_add_enabled", "HWLAB_AI_QUICK_ADD_ENABLED")
// Read primary config file (non-fatal if missing)
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("config file: %w", err)
}
}
// Optionally merge ai_config.json as an override (separate viper instance to avoid
// conflicts with the primary config file name).
v2 := viper.New()
v2.SetConfigName("ai_config")
v2.SetConfigType("json")
v2.AddConfigPath(".")
v2.AddConfigPath("/etc/hwlab")
if err := v2.ReadInConfig(); err == nil {
// ai_config.json present — merge AI section into main viper
if err := v.MergeConfigMap(v2.AllSettings()); err != nil {
return nil, fmt.Errorf("merge ai_config: %w", err)
}
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
return &cfg, nil
}