feat(config): env-driven config with validation

This commit is contained in:
2026-06-07 13:02:25 +01:00
parent cb0e570bfd
commit 9a4d329224
2 changed files with 137 additions and 0 deletions
+79
View File
@@ -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
}
+58
View File
@@ -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)
}
})
}
}