Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 76 additions & 3 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"os"
"reflect"
"strconv"
"strings"
"sync/atomic"

Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
49 changes: 46 additions & 3 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
Loading