package config import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "sync/atomic" "github.com/caarlos0/env/v11" ) // FlexibleStringSlice is a []string that also accepts JSON numbers, // so allow_from can contain both "123" and 123. type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { // Try []string first var ss []string if err := json.Unmarshal(data, &ss); err == nil { *f = ss return nil } // Try []interface{} to handle mixed types var raw []interface{} if err := json.Unmarshal(data, &raw); err != nil { return err } result := make([]string, 0, len(raw)) for _, v := range raw { switch val := v.(type) { case string: result = append(result, val) case float64: result = append(result, fmt.Sprintf("%.0f", val)) default: result = append(result, fmt.Sprintf("%v", val)) } } *f = result return nil } type Config struct { Agents AgentsConfig `json:"agents"` Bindings []AgentBinding `json:"bindings,omitempty"` Session SessionConfig `json:"session,omitempty"` Channels ChannelsConfig `json:"channels"` Providers ProvidersConfig `json:"providers"` ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway"` Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` mu sync.RWMutex rrCounters map[string]*atomic.Uint64 // Round-robin counters for load balancing } type AgentsConfig struct { Defaults AgentDefaults `json:"defaults"` List []AgentConfig `json:"list,omitempty"` } // AgentModelConfig supports both string and structured model config. // String format: "gpt-4" (just primary, no fallbacks) // Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]} type AgentModelConfig struct { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } func (m *AgentModelConfig) UnmarshalJSON(data []byte) error { var s string if err := json.Unmarshal(data, &s); err == nil { m.Primary = s m.Fallbacks = nil return nil } type raw struct { Primary string `json:"primary"` Fallbacks []string `json:"fallbacks"` } var r raw if err := json.Unmarshal(data, &r); err != nil { return err } m.Primary = r.Primary m.Fallbacks = r.Fallbacks return nil } func (m AgentModelConfig) MarshalJSON() ([]byte, error) { if len(m.Fallbacks) == 0 && m.Primary != "" { return json.Marshal(m.Primary) } type raw struct { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks}) } type AgentConfig struct { ID string `json:"id"` Default bool `json:"default,omitempty"` Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } type SubagentsConfig struct { AllowAgents []string `json:"allow_agents,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` } type PeerMatch struct { Kind string `json:"kind"` ID string `json:"id"` } type BindingMatch struct { Channel string `json:"channel"` AccountID string `json:"account_id,omitempty"` Peer *PeerMatch `json:"peer,omitempty"` GuildID string `json:"guild_id,omitempty"` TeamID string `json:"team_id,omitempty"` } type AgentBinding struct { AgentID string `json:"agent_id"` Match BindingMatch `json:"match"` } type SessionConfig struct { DMScope string `json:"dm_scope,omitempty"` IdentityLinks map[string][]string `json:"identity_links,omitempty"` } type AgentDefaults struct { Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` 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"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` } type ChannelsConfig struct { WhatsApp WhatsAppConfig `json:"whatsapp"` Telegram TelegramConfig `json:"telegram"` Feishu FeishuConfig `json:"feishu"` Discord DiscordConfig `json:"discord"` MaixCam MaixCamConfig `json:"maixcam"` QQ QQConfig `json:"qq"` DingTalk DingTalkConfig `json:"dingtalk"` Slack SlackConfig `json:"slack"` LINE LINEConfig `json:"line"` OneBot OneBotConfig `json:"onebot"` } type WhatsAppConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` } type DiscordConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` } type MaixCamConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` } type QQConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` } type DingTalkConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` } type SlackConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` } type LINEConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` } type OneBotConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` } type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 } type DevicesConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } 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"` } 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"` 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` } type OpenAIProviderConfig struct { ProviderConfig WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` } // ModelConfig represents a model-centric provider configuration. // It allows adding new providers (especially OpenAI-compatible ones) via configuration only. // The model field uses protocol prefix format: [protocol/]model-identifier // Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot // Default protocol is "openai" if no prefix is specified. type ModelConfig struct { // Required fields ModelName string `json:"model_name"` // User-facing alias for the model Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-3") // HTTP-based providers APIBase string `json:"api_base,omitempty"` // API endpoint URL APIKey string `json:"api_key,omitempty"` // API authentication key Proxy string `json:"proxy,omitempty"` // HTTP proxy URL // Special providers (CLI-based, OAuth, etc.) AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers // Optional optimizations RPM int `json:"rpm,omitempty"` // Requests per minute limit MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") } // Validate checks if the ModelConfig has all required fields. func (c *ModelConfig) Validate() error { if c.ModelName == "" { return fmt.Errorf("model_name is required") } if c.Model == "" { return fmt.Errorf("model is required") } return nil } type GatewayConfig struct { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` } type BraveConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } type DuckDuckGoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` } type PerplexityConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } type WebToolsConfig struct { Brave BraveConfig `json:"brave"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` Perplexity PerplexityConfig `json:"perplexity"` } type CronToolsConfig struct { ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout } type ExecConfig struct { EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` } type ToolsConfig struct { Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` } func LoadConfig(path string) (*Config, error) { cfg := DefaultConfig() data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return cfg, nil } return nil, err } if err := json.Unmarshal(data, cfg); err != nil { return nil, err } if err := env.Parse(cfg); err != nil { return nil, err } // Auto-migrate: if only legacy providers config exists, convert to model_list if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() { cfg.ModelList = ConvertProvidersToModelList(cfg) } // Validate model_list for uniqueness and required fields if err := cfg.ValidateModelList(); err != nil { return nil, err } return cfg, nil } func SaveConfig(path string, cfg *Config) error { cfg.mu.RLock() defer cfg.mu.RUnlock() data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return err } return os.WriteFile(path, data, 0600) } func (c *Config) WorkspacePath() string { c.mu.RLock() defer c.mu.RUnlock() return expandHome(c.Agents.Defaults.Workspace) } func (c *Config) GetAPIKey() string { c.mu.RLock() defer c.mu.RUnlock() if c.Providers.OpenRouter.APIKey != "" { return c.Providers.OpenRouter.APIKey } if c.Providers.Anthropic.APIKey != "" { return c.Providers.Anthropic.APIKey } if c.Providers.OpenAI.APIKey != "" { return c.Providers.OpenAI.APIKey } if c.Providers.Gemini.APIKey != "" { return c.Providers.Gemini.APIKey } if c.Providers.Zhipu.APIKey != "" { return c.Providers.Zhipu.APIKey } if c.Providers.Groq.APIKey != "" { return c.Providers.Groq.APIKey } if c.Providers.VLLM.APIKey != "" { return c.Providers.VLLM.APIKey } if c.Providers.ShengSuanYun.APIKey != "" { return c.Providers.ShengSuanYun.APIKey } if c.Providers.Cerebras.APIKey != "" { return c.Providers.Cerebras.APIKey } return "" } func (c *Config) GetAPIBase() string { c.mu.RLock() defer c.mu.RUnlock() if c.Providers.OpenRouter.APIKey != "" { if c.Providers.OpenRouter.APIBase != "" { return c.Providers.OpenRouter.APIBase } return "https://openrouter.ai/api/v1" } if c.Providers.Zhipu.APIKey != "" { return c.Providers.Zhipu.APIBase } if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" { return c.Providers.VLLM.APIBase } return "" } func expandHome(path string) string { if path == "" { return path } if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { return home + path[1:] } return home } return path } // GetModelConfig returns the ModelConfig for the given model name. // If multiple configs exist with the same model_name, it uses round-robin // selection for load balancing. Returns an error if the model is not found. // Uses double-check locking for optimal read performance. func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { // First pass: use read lock to find matches c.mu.RLock() matches := c.findMatchesLocked(modelName) if len(matches) == 0 { c.mu.RUnlock() return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) } if len(matches) == 1 { c.mu.RUnlock() return &matches[0], nil } // Multiple configs - check if counter exists counter, ok := c.rrCounters[modelName] c.mu.RUnlock() // Double-check locking: only acquire write lock if counter needs initialization if !ok { c.mu.Lock() // Re-check after acquiring write lock if c.rrCounters == nil { c.rrCounters = make(map[string]*atomic.Uint64) } if c.rrCounters[modelName] == nil { c.rrCounters[modelName] = &atomic.Uint64{} } counter = c.rrCounters[modelName] c.mu.Unlock() } // Re-fetch matches to ensure consistency (ModelList could have changed) c.mu.RLock() matches = c.findMatchesLocked(modelName) c.mu.RUnlock() if len(matches) == 0 { return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) } idx := counter.Add(1) % uint64(len(matches)) return &matches[idx], nil } // findMatchesLocked finds all ModelConfig entries with the given model_name. // Must be called with c.mu locked (read or write). func (c *Config) findMatchesLocked(modelName string) []ModelConfig { var matches []ModelConfig for i := range c.ModelList { if c.ModelList[i].ModelName == modelName { matches = append(matches, c.ModelList[i]) } } return matches } // HasProvidersConfig checks if any provider in the old providers config has configuration. func (c *Config) HasProvidersConfig() bool { c.mu.RLock() defer c.mu.RUnlock() v := c.Providers return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" || v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" || v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" || v.Groq.APIKey != "" || v.Groq.APIBase != "" || v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" || v.VLLM.APIKey != "" || v.VLLM.APIBase != "" || v.Gemini.APIKey != "" || v.Gemini.APIBase != "" || v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" || v.Ollama.APIKey != "" || v.Ollama.APIBase != "" || v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" || v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" || v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" || v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" || v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" || v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" || v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" || v.Qwen.APIKey != "" || v.Qwen.APIBase != "" } // ValidateModelList validates all ModelConfig entries in the model_list. // It checks that each model_name/model combination is valid and that // model_name is unique across all entries. func (c *Config) ValidateModelList() error { seen := make(map[string]int) for i := range c.ModelList { if err := c.ModelList[i].Validate(); err != nil { return fmt.Errorf("model_list[%d]: %w", i, err) } // Check for duplicate model_name name := c.ModelList[i].ModelName if prevIdx, exists := seen[name]; exists { return fmt.Errorf("model_list: duplicate model_name %q at index %d and %d", name, prevIdx, i) } seen[name] = i } return nil }