diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a7edb4894..ffda5e6bda 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "os" + "reflect" + "strconv" "strings" "sync/atomic" @@ -222,8 +224,8 @@ type AgentDefaults struct { RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead + ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead ModelFallbacks []string `json:"model_fallbacks,omitempty"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` @@ -528,6 +530,7 @@ type ProvidersConfig struct { Avian ProviderConfig `json:"avian"` Minimax ProviderConfig `json:"minimax"` LongCat ProviderConfig `json:"longcat"` + ModelScope ProviderConfig `json:"modelscope"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) @@ -555,7 +558,8 @@ func (p ProvidersConfig) IsEmpty() bool { p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && p.Avian.APIKey == "" && p.Avian.APIBase == "" && p.Minimax.APIKey == "" && p.Minimax.APIBase == "" && - p.LongCat.APIKey == "" && p.LongCat.APIBase == "" + p.LongCat.APIKey == "" && p.LongCat.APIBase == "" && + p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" } // MarshalJSON implements custom JSON marshaling for ProvidersConfig @@ -711,6 +715,7 @@ type ExecConfig struct { type SkillsToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` Registries SkillsRegistriesConfig ` json:"registries"` + Github SkillsGithubConfig ` json:"github"` MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` SearchCache SearchCacheConfig ` json:"search_cache"` } @@ -760,6 +765,11 @@ type SkillsRegistriesConfig struct { ClawHub ClawHubRegistryConfig `json:"clawhub"` } +type SkillsGithubConfig struct { + Token string `json:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` +} + type ClawHubRegistryConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` @@ -832,6 +842,7 @@ func LoadConfig(path string) (*Config, error) { if err := env.Parse(cfg); err != nil { return nil, err } + cfg.applyProviderEnvOverrides() // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() @@ -849,6 +860,68 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +func (c *Config) applyProviderEnvOverrides() { + providersValue := reflect.ValueOf(&c.Providers).Elem() + providersType := providersValue.Type() + + for i := 0; i < providersValue.NumField(); i++ { + fieldValue := providersValue.Field(i) + fieldType := providersType.Field(i) + jsonName := strings.Split(fieldType.Tag.Get("json"), ",")[0] + if jsonName == "" { + continue + } + prefix := "PICOCLAW_PROVIDERS_" + strings.ToUpper(jsonName) + "_" + applyProviderConfigEnv(fieldValue, prefix) + } +} + +func applyProviderConfigEnv(fieldValue reflect.Value, prefix string) { + if fieldValue.Kind() != reflect.Struct { + return + } + + if nested := fieldValue.FieldByName("ProviderConfig"); nested.IsValid() { + applyProviderConfigFields(nested, prefix) + } else { + applyProviderConfigFields(fieldValue, prefix) + } + + if webSearch := fieldValue.FieldByName("WebSearch"); webSearch.IsValid() && webSearch.CanSet() { + if raw, ok := os.LookupEnv(prefix + "WEB_SEARCH"); ok { + if parsed, err := strconv.ParseBool(raw); err == nil { + webSearch.SetBool(parsed) + } + } + } +} + +func applyProviderConfigFields(fieldValue reflect.Value, prefix string) { + setStringFieldFromEnv(fieldValue, "APIKey", prefix+"API_KEY") + setStringFieldFromEnv(fieldValue, "APIBase", prefix+"API_BASE") + setStringFieldFromEnv(fieldValue, "Proxy", prefix+"PROXY") + setStringFieldFromEnv(fieldValue, "AuthMethod", prefix+"AUTH_METHOD") + setStringFieldFromEnv(fieldValue, "ConnectMode", prefix+"CONNECT_MODE") + + if requestTimeout := fieldValue.FieldByName("RequestTimeout"); requestTimeout.IsValid() && requestTimeout.CanSet() { + if raw, ok := os.LookupEnv(prefix + "REQUEST_TIMEOUT"); ok { + if parsed, err := strconv.Atoi(raw); err == nil { + requestTimeout.SetInt(int64(parsed)) + } + } + } +} + +func setStringFieldFromEnv(fieldValue reflect.Value, fieldName, envName string) { + target := fieldValue.FieldByName(fieldName) + if !target.IsValid() || !target.CanSet() { + return + } + if raw, ok := os.LookupEnv(envName); ok { + target.SetString(raw) + } +} + func (c *Config) migrateChannelConfigs() { // Discord: mention_only -> group_trigger.mention_only if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index ad89d6d2e7..1cc6069305 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -342,8 +342,8 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) { t.Fatalf("ReadFile failed: %v", err) } - if !strings.Contains(string(data), `"model": ""`) { - t.Fatalf("saved config should include empty legacy model field, got: %s", string(data)) + if !strings.Contains(string(data), `"model_name": ""`) { + t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data)) } } @@ -444,7 +444,7 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) { configPath := filepath.Join(tmpDir, "config.json") configJSON := `{ "agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}}, - "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}], + "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}], "tools": {"web":{"proxy":"http://127.0.0.1:7890"}} }` if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { @@ -460,6 +460,49 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) { } } +func TestLoadConfig_ProviderEnvVarsOverrideFileValues(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + configJSON := `{ + "providers": { + "gemini": { + "api_key": "from-file" + } + } +}` + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatalf("os.WriteFile() error: %v", err) + } + + t.Setenv("PICOCLAW_PROVIDERS_GEMINI_API_KEY", "from-env") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Providers.Gemini.APIKey != "from-env" { + t.Fatalf("Providers.Gemini.APIKey = %q, want %q", cfg.Providers.Gemini.APIKey, "from-env") + } +} + +func TestLoadConfig_OpenAIProviderEnvVarsUseNestedPrefix(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil { + t.Fatalf("os.WriteFile() error: %v", err) + } + + t.Setenv("PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH", "false") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Providers.OpenAI.WebSearch { + t.Fatal("Providers.OpenAI.WebSearch should be false when disabled via environment") + } +} + // TestDefaultConfig_DMScope verifies the default dm_scope value // TestDefaultConfig_SummarizationThresholds verifies summarization defaults func TestDefaultConfig_SummarizationThresholds(t *testing.T) {