Merge remote-tracking branch 'origin/feat/dotenv-loading' into deploy/pi-integration

This commit is contained in:
I Putu Eddy Irawan
2026-03-03 22:29:05 +07:00
4 changed files with 176 additions and 1 deletions
+77
View File
@@ -3,14 +3,23 @@ package config
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"sync/atomic"
"github.com/caarlos0/env/v11"
"github.com/joho/godotenv"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// dotenvOnce ensures .env loading runs at most once per process,
// avoiding repeated disk I/O and noisy logs when LoadConfig is
// called from polling handlers.
var dotenvOnce sync.Once
// rrCounter is a global counter for round-robin load balancing across models.
var rrCounter atomic.Uint64
@@ -642,9 +651,35 @@ type MCPConfig struct {
func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig()
// Load .env file from config directory (secrets, API keys, etc.)
// Guarded by sync.Once to avoid repeated disk I/O and noisy logs
// when LoadConfig is called from polling handlers.
dotenvOnce.Do(func() {
envFile := filepath.Join(filepath.Dir(path), ".env")
if err := godotenv.Load(envFile); err != nil {
if os.IsNotExist(err) {
log.Printf("[INFO] No .env file found at %s; skipping .env loading", envFile)
} else {
log.Printf("[WARN] Failed to load .env file from %s: %v", envFile, err)
}
}
})
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
// No config file — still apply env vars + overrides to default config
if err := env.Parse(cfg); err != nil {
return nil, err
}
loadProviderEnvOverrides(cfg)
cfg.migrateChannelConfigs()
if cfg.HasProvidersConfig() {
cfg.ModelList = ConvertProvidersToModelList(cfg)
}
if err := cfg.ValidateModelList(); err != nil {
return nil, err
}
return cfg, nil
}
return nil, err
@@ -672,6 +707,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
// Load provider-specific env overrides (PICOCLAW_PROVIDERS_<NAME>_API_KEY, etc.)
loadProviderEnvOverrides(cfg)
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
@@ -820,3 +858,42 @@ func (c *Config) ValidateModelList() error {
}
return nil
}
// loadProviderEnvOverrides reads PICOCLAW_PROVIDERS_<NAME>_API_KEY and _API_BASE
// environment variables and sets them on the corresponding provider config fields.
// This enables storing provider secrets in .env files without using struct tags.
func loadProviderEnvOverrides(cfg *Config) {
providers := []struct {
name string
apiKey *string
base *string
}{
{"ANTHROPIC", &cfg.Providers.Anthropic.APIKey, &cfg.Providers.Anthropic.APIBase},
{"OPENAI", &cfg.Providers.OpenAI.APIKey, &cfg.Providers.OpenAI.APIBase},
{"LITELLM", &cfg.Providers.LiteLLM.APIKey, &cfg.Providers.LiteLLM.APIBase},
{"OPENROUTER", &cfg.Providers.OpenRouter.APIKey, &cfg.Providers.OpenRouter.APIBase},
{"GROQ", &cfg.Providers.Groq.APIKey, &cfg.Providers.Groq.APIBase},
{"ZHIPU", &cfg.Providers.Zhipu.APIKey, &cfg.Providers.Zhipu.APIBase},
{"GEMINI", &cfg.Providers.Gemini.APIKey, &cfg.Providers.Gemini.APIBase},
{"NVIDIA", &cfg.Providers.Nvidia.APIKey, &cfg.Providers.Nvidia.APIBase},
{"OLLAMA", &cfg.Providers.Ollama.APIKey, &cfg.Providers.Ollama.APIBase},
{"MOONSHOT", &cfg.Providers.Moonshot.APIKey, &cfg.Providers.Moonshot.APIBase},
{"SHENGSUANYUN", &cfg.Providers.ShengSuanYun.APIKey, &cfg.Providers.ShengSuanYun.APIBase},
{"DEEPSEEK", &cfg.Providers.DeepSeek.APIKey, &cfg.Providers.DeepSeek.APIBase},
{"MISTRAL", &cfg.Providers.Mistral.APIKey, &cfg.Providers.Mistral.APIBase},
{"VLLM", &cfg.Providers.VLLM.APIKey, &cfg.Providers.VLLM.APIBase},
{"CEREBRAS", &cfg.Providers.Cerebras.APIKey, &cfg.Providers.Cerebras.APIBase},
{"VOLCENGINE", &cfg.Providers.VolcEngine.APIKey, &cfg.Providers.VolcEngine.APIBase},
{"QWEN", &cfg.Providers.Qwen.APIKey, &cfg.Providers.Qwen.APIBase},
// Note: GitHubCopilot and Antigravity use different auth patterns (ConnectMode/AuthMethod),
// not standard APIKey/APIBase, so they are not included here.
}
for _, p := range providers {
if v, ok := os.LookupEnv("PICOCLAW_PROVIDERS_" + p.name + "_API_KEY"); ok {
*p.apiKey = v
}
if v, ok := os.LookupEnv("PICOCLAW_PROVIDERS_" + p.name + "_API_BASE"); ok {
*p.base = v
}
}
}
+96
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
@@ -467,3 +468,98 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
}
}
func TestLoadConfig_DotenvFileLoaded(t *testing.T) {
// Reset sync.Once so .env loading runs for this test
dotenvOnce = sync.Once{}
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
// Write a minimal config.json
if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil {
t.Fatalf("WriteFile config: %v", err)
}
// Write a .env file with a provider API key
envFile := filepath.Join(dir, ".env")
if err := os.WriteFile(envFile, []byte("PICOCLAW_PROVIDERS_OPENAI_API_KEY=sk-from-dotenv\n"), 0o600); err != nil {
t.Fatalf("WriteFile .env: %v", err)
}
// Clear the env var first to ensure it comes from .env
t.Setenv("PICOCLAW_PROVIDERS_OPENAI_API_KEY", "")
os.Unsetenv("PICOCLAW_PROVIDERS_OPENAI_API_KEY")
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Providers.OpenAI.APIKey != "sk-from-dotenv" {
t.Errorf("OpenAI.APIKey = %q, want %q", cfg.Providers.OpenAI.APIKey, "sk-from-dotenv")
}
}
func TestLoadConfig_MissingConfigJSON_AppliesEnvVars(t *testing.T) {
// Reset sync.Once so .env loading runs for this test
dotenvOnce = sync.Once{}
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json") // does NOT exist
t.Setenv("PICOCLAW_PROVIDERS_ANTHROPIC_API_KEY", "sk-anthropic-test")
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Providers.Anthropic.APIKey != "sk-anthropic-test" {
t.Errorf("Anthropic.APIKey = %q, want %q", cfg.Providers.Anthropic.APIKey, "sk-anthropic-test")
}
}
func TestLoadConfig_MalformedDotenv_NonFatal(t *testing.T) {
// Reset sync.Once so .env loading runs for this test
dotenvOnce = sync.Once{}
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
// Write a minimal config.json
if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil {
t.Fatalf("WriteFile config: %v", err)
}
// Write a .env file with genuinely malformed content (bare key without '=',
// mixed with a valid line) to verify godotenv.Load errors are non-fatal.
envFile := filepath.Join(dir, ".env")
if err := os.WriteFile(envFile, []byte("THIS_LINE_HAS_NO_EQUALS\nVALID_KEY=valid_value\n"), 0o600); err != nil {
t.Fatalf("WriteFile .env: %v", err)
}
// LoadConfig should not fail even with malformed .env content
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() should not fail with .env issues, got error: %v", err)
}
if cfg == nil {
t.Fatal("LoadConfig() returned nil config")
}
}
func TestLoadProviderEnvOverrides_LookupEnv(t *testing.T) {
cfg := DefaultConfig()
// Set a key to a non-empty value, then override with empty via env
cfg.Providers.OpenRouter.APIBase = "https://original.com"
t.Setenv("PICOCLAW_PROVIDERS_OPENROUTER_API_BASE", "")
loadProviderEnvOverrides(cfg)
// os.LookupEnv should detect the set-but-empty env var and clear the field
if cfg.Providers.OpenRouter.APIBase != "" {
t.Errorf("OpenRouter.APIBase = %q, want empty (overridden by empty env var)", cfg.Providers.OpenRouter.APIBase)
}
}