From 9a4d3292243e4c92c13a7a49db46747d0af38717 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 7 Jun 2026 13:02:25 +0100 Subject: [PATCH] feat(config): env-driven config with validation --- internal/config/config.go | 79 ++++++++++++++++++++++++++++++++++ internal/config/config_test.go | 58 +++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f3bb9ed --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,79 @@ +// Package config loads and validates the MCP server configuration from env vars. +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// Config is the validated runtime configuration. Fields are ordered +// largest-alignment-first to satisfy the fieldalignment linter. +type Config struct { + Provider string + BaseURL string + APIKey string + Model string + AudioModel string + LogLevel string + Temperature float64 + Timeout time.Duration + MaxTokens int +} + +var validProviders = map[string]bool{ + "anthropic": true, "openai": true, "openai-compatible": true, "gemini": true, +} + +// Load reads CAPTCHA_LLM_* env vars and returns a validated Config. +func Load() (Config, error) { + c := Config{ + Provider: os.Getenv("CAPTCHA_LLM_PROVIDER"), + BaseURL: os.Getenv("CAPTCHA_LLM_BASE_URL"), + APIKey: os.Getenv("CAPTCHA_LLM_API_KEY"), + Model: os.Getenv("CAPTCHA_LLM_MODEL"), + AudioModel: os.Getenv("CAPTCHA_LLM_AUDIO_MODEL"), + LogLevel: envOr("CAPTCHA_LOG_LEVEL", "info"), + MaxTokens: 1024, + Timeout: 60 * time.Second, + } + if !validProviders[c.Provider] { + return Config{}, fmt.Errorf("CAPTCHA_LLM_PROVIDER must be one of anthropic|openai|openai-compatible|gemini, got %q", c.Provider) + } + if c.APIKey == "" && c.BaseURL == "" { + return Config{}, fmt.Errorf("CAPTCHA_LLM_API_KEY is required (or set CAPTCHA_LLM_BASE_URL for a keyless local endpoint)") + } + if c.Model == "" { + return Config{}, fmt.Errorf("CAPTCHA_LLM_MODEL is required") + } + if v := os.Getenv("CAPTCHA_LLM_TEMPERATURE"); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return Config{}, fmt.Errorf("CAPTCHA_LLM_TEMPERATURE: %w", err) + } + c.Temperature = f + } + if v := os.Getenv("CAPTCHA_LLM_MAX_TOKENS"); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return Config{}, fmt.Errorf("CAPTCHA_LLM_MAX_TOKENS: %w", err) + } + c.MaxTokens = n + } + if v := os.Getenv("CAPTCHA_LLM_TIMEOUT"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + return Config{}, fmt.Errorf("CAPTCHA_LLM_TIMEOUT: %w", err) + } + c.Timeout = d + } + return c, nil +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..91ba0b1 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "testing" + "time" +) + +func TestLoad(t *testing.T) { + tests := []struct { + name string + env map[string]string + want Config + wantErr bool + }{ + { + name: "valid anthropic", + env: map[string]string{ + "CAPTCHA_LLM_PROVIDER": "anthropic", + "CAPTCHA_LLM_API_KEY": "k", + "CAPTCHA_LLM_MODEL": "claude-opus-4-8", + }, + want: Config{ + Provider: "anthropic", APIKey: "k", Model: "claude-opus-4-8", + LogLevel: "info", MaxTokens: 1024, Timeout: 60 * time.Second, + }, + }, + { + name: "missing model", + env: map[string]string{"CAPTCHA_LLM_PROVIDER": "openai", "CAPTCHA_LLM_API_KEY": "k"}, + wantErr: true, + }, + { + name: "bad provider", + env: map[string]string{"CAPTCHA_LLM_PROVIDER": "nope", "CAPTCHA_LLM_API_KEY": "k", "CAPTCHA_LLM_MODEL": "m"}, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + got, err := Load() + if tc.wantErr { + if err == nil { + t.Fatal("want error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %+v want %+v", got, tc.want) + } + }) + } +}