// Package config loads gpu-turnstile's configuration from environment // variables and an optional .env-style config file. package config import ( "bufio" "fmt" "io" "log/slog" "strconv" "strings" "time" ) // Config holds every gpu-turnstile setting. type Config struct { ListenOllama string ListenComfy string OllamaURL string ComfyURL string UnloadTimeout time.Duration JobTimeout time.Duration LLMWaitTimeout time.Duration UnloadPollInterval time.Duration HistoryPollInterval time.Duration ProbeTimeout time.Duration FreeTimeout time.Duration WarmTimeout time.Duration ShutdownTimeout time.Duration BackoffInitial time.Duration BackoffMax time.Duration PromptCaptureLimit int64 AutoUpdate bool UpdateInterval time.Duration UpdateRepo string UpdateAsset string // LLMBusyMode is "wait" (hold requests until the lock is free or // LLMWaitTimeout expires) or "reject" (immediately answer with // LLMBusyStatus + Retry-After when an image job is active or pending). LLMBusyMode string LLMBusyStatus int BusyRetryAfter int WarmModel string LogLevel slog.Level LogJSON bool LogFile string } // Defaults returns the configuration used when neither the environment nor // a config file sets a value. func Defaults() Config { return Config{ ListenOllama: ":11434", ListenComfy: ":8188", OllamaURL: "http://127.0.0.1:11435", ComfyURL: "http://127.0.0.1:8189", UnloadTimeout: time.Minute, JobTimeout: 15 * time.Minute, LLMWaitTimeout: 10 * time.Minute, UnloadPollInterval: 500 * time.Millisecond, HistoryPollInterval: time.Second, ProbeTimeout: 5 * time.Second, FreeTimeout: 30 * time.Second, WarmTimeout: 2 * time.Minute, ShutdownTimeout: 10 * time.Second, BackoffInitial: time.Second, BackoffMax: time.Minute, PromptCaptureLimit: 64 * 1024, AutoUpdate: true, UpdateInterval: 6 * time.Hour, UpdateRepo: "https://git.rambossek.at/PUBLIC/gpu-turnstile", UpdateAsset: "gpu-turnstile.exe", LLMBusyMode: "wait", LLMBusyStatus: 503, BusyRetryAfter: 30, LogLevel: slog.LevelWarn, } } // ParseEnvFile parses a .env-style file: KEY=VALUE lines, blank lines and // #-comments are ignored, no quoting. A line without '=' is an error. func ParseEnvFile(r io.Reader) (map[string]string, error) { values := make(map[string]string) scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 64*1024), 1024*1024) lineNo := 0 for scanner.Scan() { lineNo++ line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, ok := strings.Cut(line, "=") if !ok { return nil, fmt.Errorf("line %d: expected KEY=VALUE", lineNo) } key = strings.TrimSpace(key) if key == "" { return nil, fmt.Errorf("line %d: empty key", lineNo) } values[key] = strings.TrimSpace(value) } return values, scanner.Err() } func envDuration(getenv func(string) string, name string, dst *time.Duration) error { v := getenv(name) if v == "" { return nil } d, err := time.ParseDuration(v) if err != nil { return fmt.Errorf("%s: %w", name, err) } *dst = d return nil } // Load overlays values from getenv onto the Defaults. Unknown keys are // ignored. Invalid values are fatal. func Load(getenv func(string) string) (Config, error) { cfg := Defaults() for _, e := range []struct { name string dst *string }{ {"LISTEN_OLLAMA", &cfg.ListenOllama}, {"LISTEN_COMFY", &cfg.ListenComfy}, {"OLLAMA_URL", &cfg.OllamaURL}, {"COMFY_URL", &cfg.ComfyURL}, {"WARM_MODEL", &cfg.WarmModel}, {"UPDATE_REPO", &cfg.UpdateRepo}, {"UPDATE_ASSET", &cfg.UpdateAsset}, {"LOG_FILE", &cfg.LogFile}, } { if v := getenv(e.name); v != "" { *e.dst = v } } for _, e := range []struct { name string dst *time.Duration }{ {"UNLOAD_TIMEOUT", &cfg.UnloadTimeout}, {"JOB_TIMEOUT", &cfg.JobTimeout}, {"LLM_WAIT_TIMEOUT", &cfg.LLMWaitTimeout}, {"UNLOAD_POLL_INTERVAL", &cfg.UnloadPollInterval}, {"HISTORY_POLL_INTERVAL", &cfg.HistoryPollInterval}, {"PROBE_TIMEOUT", &cfg.ProbeTimeout}, {"FREE_TIMEOUT", &cfg.FreeTimeout}, {"WARM_TIMEOUT", &cfg.WarmTimeout}, {"SHUTDOWN_TIMEOUT", &cfg.ShutdownTimeout}, {"BACKOFF_INITIAL", &cfg.BackoffInitial}, {"BACKOFF_MAX", &cfg.BackoffMax}, {"UPDATE_INTERVAL", &cfg.UpdateInterval}, } { if err := envDuration(getenv, e.name, e.dst); err != nil { return cfg, err } } if v := getenv("PROMPT_CAPTURE_LIMIT"); v != "" { n, err := strconv.ParseInt(v, 10, 64) if err != nil || n < 0 { return cfg, fmt.Errorf("PROMPT_CAPTURE_LIMIT: must be a non-negative integer (bytes)") } cfg.PromptCaptureLimit = n } if v := getenv("AUTO_UPDATE"); v != "" { b, err := strconv.ParseBool(v) if err != nil { return cfg, fmt.Errorf("AUTO_UPDATE: must be a boolean (true/false)") } cfg.AutoUpdate = b } if v := getenv("LLM_BUSY_MODE"); v != "" { if v != "wait" && v != "reject" { return cfg, fmt.Errorf("LLM_BUSY_MODE: must be \"wait\" or \"reject\"") } cfg.LLMBusyMode = v } if v := getenv("LLM_BUSY_STATUS"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 400 || n > 599 { return cfg, fmt.Errorf("LLM_BUSY_STATUS: must be an HTTP status in 400-599") } cfg.LLMBusyStatus = n } if v := getenv("BUSY_RETRY_AFTER"); v != "" { n, err := strconv.Atoi(v) if err != nil || n <= 0 { return cfg, fmt.Errorf("BUSY_RETRY_AFTER: must be a positive integer (seconds)") } cfg.BusyRetryAfter = n } // LOGLEVEL is the canonical spelling; LOG_LEVEL is kept as an alias. logLevelValue := getenv("LOGLEVEL") if logLevelValue == "" { logLevelValue = getenv("LOG_LEVEL") } if logLevelValue != "" { var level slog.Level if err := level.UnmarshalText([]byte(logLevelValue)); err != nil { return cfg, fmt.Errorf("LOGLEVEL: %w", err) } cfg.LogLevel = level } switch strings.ToLower(getenv("LOG_FORMAT")) { case "", "text": case "json": cfg.LogJSON = true default: return cfg, fmt.Errorf("LOG_FORMAT: must be \"text\" or \"json\"") } return cfg, nil }