diff --git a/pkg/config/config.go b/pkg/config/config.go index e5161916a..7d1a28ac3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "sync/atomic" "github.com/caarlos0/env/v11" @@ -385,24 +386,24 @@ type DevicesConfig struct { } type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI OpenAIProviderConfig `json:"openai"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` - Nvidia ProviderConfig `json:"nvidia"` - Ollama ProviderConfig `json:"ollama"` - Moonshot ProviderConfig `json:"moonshot"` - ShengSuanYun ProviderConfig `json:"shengsuanyun"` - DeepSeek ProviderConfig `json:"deepseek"` - Cerebras ProviderConfig `json:"cerebras"` - VolcEngine ProviderConfig `json:"volcengine"` - GitHubCopilot ProviderConfig `json:"github_copilot"` - Antigravity ProviderConfig `json:"antigravity"` - Qwen ProviderConfig `json:"qwen"` - Mistral ProviderConfig `json:"mistral"` + Anthropic ProviderConfig `json:"anthropic" envPrefix:"PICOCLAW_PROVIDERS_ANTHROPIC_"` + OpenAI OpenAIProviderConfig `json:"openai" envPrefix:"PICOCLAW_PROVIDERS_OPENAI_"` + OpenRouter ProviderConfig `json:"openrouter" envPrefix:"PICOCLAW_PROVIDERS_OPENROUTER_"` + Groq ProviderConfig `json:"groq" envPrefix:"PICOCLAW_PROVIDERS_GROQ_"` + Zhipu ProviderConfig `json:"zhipu" envPrefix:"PICOCLAW_PROVIDERS_ZHIPU_"` + VLLM ProviderConfig `json:"vllm" envPrefix:"PICOCLAW_PROVIDERS_VLLM_"` + Gemini ProviderConfig `json:"gemini" envPrefix:"PICOCLAW_PROVIDERS_GEMINI_"` + Nvidia ProviderConfig `json:"nvidia" envPrefix:"PICOCLAW_PROVIDERS_NVIDIA_"` + Ollama ProviderConfig `json:"ollama" envPrefix:"PICOCLAW_PROVIDERS_OLLAMA_"` + Moonshot ProviderConfig `json:"moonshot" envPrefix:"PICOCLAW_PROVIDERS_MOONSHOT_"` + ShengSuanYun ProviderConfig `json:"shengsuanyun" envPrefix:"PICOCLAW_PROVIDERS_SHENGSUANYUN_"` + DeepSeek ProviderConfig `json:"deepseek" envPrefix:"PICOCLAW_PROVIDERS_DEEPSEEK_"` + Cerebras ProviderConfig `json:"cerebras" envPrefix:"PICOCLAW_PROVIDERS_CEREBRAS_"` + VolcEngine ProviderConfig `json:"volcengine" envPrefix:"PICOCLAW_PROVIDERS_VOLCENGINE_"` + GitHubCopilot ProviderConfig `json:"github_copilot" envPrefix:"PICOCLAW_PROVIDERS_GITHUB_COPILOT_"` + Antigravity ProviderConfig `json:"antigravity" envPrefix:"PICOCLAW_PROVIDERS_ANTIGRAVITY_"` + Qwen ProviderConfig `json:"qwen" envPrefix:"PICOCLAW_PROVIDERS_QWEN_"` + Mistral ProviderConfig `json:"mistral" envPrefix:"PICOCLAW_PROVIDERS_MISTRAL_"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) @@ -439,17 +440,17 @@ func (p ProvidersConfig) MarshalJSON() ([]byte, error) { } type ProviderConfig struct { - APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` - APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` - RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"` - AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` - ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` + APIKey string `json:"api_key" env:"API_KEY"` + APIBase string `json:"api_base" env:"API_BASE"` + Proxy string `json:"proxy,omitempty" env:"PROXY"` + RequestTimeout int `json:"request_timeout,omitempty" env:"REQUEST_TIMEOUT"` + AuthMethod string `json:"auth_method,omitempty" env:"AUTH_METHOD"` + ConnectMode string `json:"connect_mode,omitempty" env:"CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` } type OpenAIProviderConfig struct { ProviderConfig - WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` + WebSearch bool `json:"web_search" env:"WEB_SEARCH"` } // ModelConfig represents a model-centric provider configuration. @@ -611,6 +612,20 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + // Resolve environment variable overrides for model_list API keys. + // Convention: PICOCLAW_MODEL__API_KEY + modelNameReplacer := strings.NewReplacer("-", "_", ".", "_") + for i := range cfg.ModelList { + if cfg.ModelList[i].APIKey == "" && cfg.ModelList[i].ModelName != "" { + envKey := "PICOCLAW_MODEL_" + strings.ToUpper( + modelNameReplacer.Replace(cfg.ModelList[i].ModelName), + ) + "_API_KEY" + if v := os.Getenv(envKey); v != "" { + cfg.ModelList[i].APIKey = v + } + } + } + // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index ced0d925b..0fd3226d3 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -447,6 +447,65 @@ func TestDefaultConfig_SummarizationThresholds(t *testing.T) { } } +func TestLoadConfig_ProviderEnvVars(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + configJSON := `{ + "providers": { + "anthropic": {}, + "openai": {} + }, + "model_list": [{"model_name":"test","model":"openai/gpt-5.2","api_key":"x"}] +}` + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatalf("WriteFile error: %v", err) + } + + t.Setenv("PICOCLAW_PROVIDERS_ANTHROPIC_API_KEY", "ant-env-key") + t.Setenv("PICOCLAW_PROVIDERS_OPENAI_API_KEY", "oai-env-key") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + + if cfg.Providers.Anthropic.APIKey != "ant-env-key" { + t.Errorf("Anthropic.APIKey = %q, want %q", cfg.Providers.Anthropic.APIKey, "ant-env-key") + } + if cfg.Providers.OpenAI.APIKey != "oai-env-key" { + t.Errorf("OpenAI.APIKey = %q, want %q", cfg.Providers.OpenAI.APIKey, "oai-env-key") + } +} + +func TestLoadConfig_ModelListEnvVars(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + configJSON := `{ + "model_list": [ + {"model_name": "gpt-5.2", "model": "openai/gpt-5.2", "api_key": ""}, + {"model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": ""} + ] +}` + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatalf("WriteFile error: %v", err) + } + + t.Setenv("PICOCLAW_MODEL_GPT_5_2_API_KEY", "gpt-env-key") + t.Setenv("PICOCLAW_MODEL_CLAUDE_SONNET_4_6_API_KEY", "claude-env-key") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + + if cfg.ModelList[0].APIKey != "gpt-env-key" { + t.Errorf("ModelList[0].APIKey = %q, want %q", cfg.ModelList[0].APIKey, "gpt-env-key") + } + if cfg.ModelList[1].APIKey != "claude-env-key" { + t.Errorf("ModelList[1].APIKey = %q, want %q", cfg.ModelList[1].APIKey, "claude-env-key") + } +} + func TestDefaultConfig_DMScope(t *testing.T) { cfg := DefaultConfig()