Merge remote-tracking branch 'origin/feat/vision-tool' into feat/vision-tool

# Conflicts:
#	pkg/config/config.go
This commit is contained in:
afjcjsbx 2026-02-20 14:31:25 +01:00
commit 9364f78ff8

View file

@ -5,14 +5,11 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync/atomic" "sync"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
) )
// rrCounter is a global counter for round-robin load balancing across models.
var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers, // FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123. // so allow_from can contain both "123" and 123.
type FlexibleStringSlice []string type FlexibleStringSlice []string
@ -51,37 +48,12 @@ type Config struct {
Bindings []AgentBinding `json:"bindings,omitempty"` Bindings []AgentBinding `json:"bindings,omitempty"`
Session SessionConfig `json:"session,omitempty"` Session SessionConfig `json:"session,omitempty"`
Channels ChannelsConfig `json:"channels"` Channels ChannelsConfig `json:"channels"`
Providers ProvidersConfig `json:"providers,omitempty"` Providers ProvidersConfig `json:"providers"`
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"` Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"` Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"` Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"` Devices DevicesConfig `json:"devices"`
} mu sync.RWMutex
// MarshalJSON implements custom JSON marshaling for Config
// to omit providers section when empty and session when empty
func (c Config) MarshalJSON() ([]byte, error) {
type Alias Config
aux := &struct {
Providers *ProvidersConfig `json:"providers,omitempty"`
Session *SessionConfig `json:"session,omitempty"`
*Alias
}{
Alias: (*Alias)(&c),
}
// Only include providers if not empty
if !c.Providers.IsEmpty() {
aux.Providers = &c.Providers
}
// Only include session if not empty
if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
aux.Session = &c.Session
}
return json.Marshal(aux)
} }
type AgentsConfig struct { type AgentsConfig struct {
@ -175,7 +147,7 @@ type AgentDefaults struct {
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
} }
@ -218,7 +190,6 @@ type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
} }
type MaixCamConfig struct { type MaixCamConfig struct {
@ -291,43 +262,7 @@ type ProvidersConfig struct {
Moonshot ProviderConfig `json:"moonshot"` Moonshot ProviderConfig `json:"moonshot"`
ShengSuanYun ProviderConfig `json:"shengsuanyun"` ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"` DeepSeek ProviderConfig `json:"deepseek"`
Cerebras ProviderConfig `json:"cerebras"`
VolcEngine ProviderConfig `json:"volcengine"`
GitHubCopilot ProviderConfig `json:"github_copilot"` GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
// Note: WebSearch is an optimization option and doesn't count as "non-empty"
func (p ProvidersConfig) IsEmpty() bool {
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
// to omit the entire section when empty
func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
if p.IsEmpty() {
return []byte("null"), nil
}
type Alias ProvidersConfig
return json.Marshal((*Alias)(&p))
} }
type ProviderConfig struct { type ProviderConfig struct {
@ -343,42 +278,6 @@ type OpenAIProviderConfig struct {
WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` 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-sonnet-4.6")
// HTTP-based providers
APIBase string `json:"api_base,omitempty"` // API endpoint URL
APIKey string `json:"api_key"` // 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 { type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
@ -416,38 +315,153 @@ type ExecConfig struct {
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
} }
type VisionToolConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_VISION_ENABLED"`
ApiKey string `json:"api_key" env:"PICOCLAW_TOOLS_VISION_API_KEY"`
ApiURL string `json:"api_url" env:"PICOCLAW_TOOLS_VISION_API_URL"`
Model string `json:"model" env:"PICOCLAW_TOOLS_VISION_MODEL"`
}
type ToolsConfig struct { type ToolsConfig struct {
Web WebToolsConfig `json:"web"` Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"` Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"` Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"` Vision VisionToolConfig `json:"vision"`
} }
type SkillsToolsConfig struct { func DefaultConfig() *Config {
Registries SkillsRegistriesConfig `json:"registries"` return &Config{
MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"` Agents: AgentsConfig{
SearchCache SearchCacheConfig `json:"search_cache"` Defaults: AgentDefaults{
} Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true,
type SearchCacheConfig struct { Provider: "",
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"` Model: "glm-4.7",
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` MaxTokens: 8192,
} Temperature: 0.7,
MaxToolIterations: 20,
type SkillsRegistriesConfig struct { },
ClawHub ClawHubRegistryConfig `json:"clawhub"` },
} Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{
type ClawHubRegistryConfig struct { Enabled: false,
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` BridgeURL: "ws://localhost:3001",
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` AllowFrom: FlexibleStringSlice{},
AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"` },
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` Telegram: TelegramConfig{
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` Enabled: false,
DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"` Token: "",
Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"` AllowFrom: FlexibleStringSlice{},
MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"` },
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` Feishu: FeishuConfig{
Enabled: false,
AppID: "",
AppSecret: "",
EncryptKey: "",
VerificationToken: "",
AllowFrom: FlexibleStringSlice{},
},
Discord: DiscordConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
},
MaixCam: MaixCamConfig{
Enabled: false,
Host: "0.0.0.0",
Port: 18790,
AllowFrom: FlexibleStringSlice{},
},
QQ: QQConfig{
Enabled: false,
AppID: "",
AppSecret: "",
AllowFrom: FlexibleStringSlice{},
},
DingTalk: DingTalkConfig{
Enabled: false,
ClientID: "",
ClientSecret: "",
AllowFrom: FlexibleStringSlice{},
},
Slack: SlackConfig{
Enabled: false,
BotToken: "",
AppToken: "",
AllowFrom: FlexibleStringSlice{},
},
LINE: LINEConfig{
Enabled: false,
ChannelSecret: "",
ChannelAccessToken: "",
WebhookHost: "0.0.0.0",
WebhookPort: 18791,
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
},
OneBot: OneBotConfig{
Enabled: false,
WSUrl: "ws://127.0.0.1:3001",
AccessToken: "",
ReconnectInterval: 5,
GroupTriggerPrefix: []string{},
AllowFrom: FlexibleStringSlice{},
},
},
Providers: ProvidersConfig{
Anthropic: ProviderConfig{},
OpenAI: OpenAIProviderConfig{WebSearch: true},
OpenRouter: ProviderConfig{},
Groq: ProviderConfig{},
Zhipu: ProviderConfig{},
VLLM: ProviderConfig{},
Gemini: ProviderConfig{},
Nvidia: ProviderConfig{},
Moonshot: ProviderConfig{},
ShengSuanYun: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
Port: 18790,
},
Tools: ToolsConfig{
Web: WebToolsConfig{
Brave: BraveConfig{
Enabled: false,
APIKey: "",
MaxResults: 5,
},
DuckDuckGo: DuckDuckGoConfig{
Enabled: true,
MaxResults: 5,
},
Perplexity: PerplexityConfig{
Enabled: false,
APIKey: "",
MaxResults: 5,
},
},
Cron: CronToolsConfig{
ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
},
Exec: ExecConfig{
EnableDenyPatterns: true,
},
Vision: VisionToolConfig{
Enabled: false,
ApiURL: "",
Model: "gpt-4o-mini",
},
},
Heartbeat: HeartbeatConfig{
Enabled: true,
Interval: 30, // default 30 minutes
},
Devices: DevicesConfig{
Enabled: false,
MonitorUSB: true,
},
}
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
@ -469,20 +483,13 @@ func LoadConfig(path string) (*Config, error) {
return nil, err 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 return cfg, nil
} }
func SaveConfig(path string, cfg *Config) error { func SaveConfig(path string, cfg *Config) error {
cfg.mu.RLock()
defer cfg.mu.RUnlock()
data, err := json.MarshalIndent(cfg, "", " ") data, err := json.MarshalIndent(cfg, "", " ")
if err != nil { if err != nil {
return err return err
@ -497,10 +504,14 @@ func SaveConfig(path string, cfg *Config) error {
} }
func (c *Config) WorkspacePath() string { func (c *Config) WorkspacePath() string {
c.mu.RLock()
defer c.mu.RUnlock()
return expandHome(c.Agents.Defaults.Workspace) return expandHome(c.Agents.Defaults.Workspace)
} }
func (c *Config) GetAPIKey() string { func (c *Config) GetAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.Providers.OpenRouter.APIKey != "" { if c.Providers.OpenRouter.APIKey != "" {
return c.Providers.OpenRouter.APIKey return c.Providers.OpenRouter.APIKey
} }
@ -525,13 +536,12 @@ func (c *Config) GetAPIKey() string {
if c.Providers.ShengSuanYun.APIKey != "" { if c.Providers.ShengSuanYun.APIKey != "" {
return c.Providers.ShengSuanYun.APIKey return c.Providers.ShengSuanYun.APIKey
} }
if c.Providers.Cerebras.APIKey != "" {
return c.Providers.Cerebras.APIKey
}
return "" return ""
} }
func (c *Config) GetAPIBase() string { func (c *Config) GetAPIBase() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.Providers.OpenRouter.APIKey != "" { if c.Providers.OpenRouter.APIKey != "" {
if c.Providers.OpenRouter.APIBase != "" { if c.Providers.OpenRouter.APIBase != "" {
return c.Providers.OpenRouter.APIBase return c.Providers.OpenRouter.APIBase
@ -547,6 +557,32 @@ func (c *Config) GetAPIBase() string {
return "" return ""
} }
// ModelConfig holds primary model and fallback list.
type ModelConfig struct {
Primary string
Fallbacks []string
}
// GetModelConfig returns the text model configuration with fallbacks.
func (c *Config) GetModelConfig() ModelConfig {
c.mu.RLock()
defer c.mu.RUnlock()
return ModelConfig{
Primary: c.Agents.Defaults.Model,
Fallbacks: c.Agents.Defaults.ModelFallbacks,
}
}
// GetImageModelConfig returns the image model configuration with fallbacks.
func (c *Config) GetImageModelConfig() ModelConfig {
c.mu.RLock()
defer c.mu.RUnlock()
return ModelConfig{
Primary: c.Agents.Defaults.ImageModel,
Fallbacks: c.Agents.Defaults.ImageModelFallbacks,
}
}
func expandHome(path string) string { func expandHome(path string) string {
if path == "" { if path == "" {
return path return path
@ -560,65 +596,3 @@ func expandHome(path string) string {
} }
return path 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.
func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
matches := c.findMatches(modelName)
if len(matches) == 0 {
return nil, fmt.Errorf("model %q not found in model_list or providers", modelName)
}
if len(matches) == 1 {
return &matches[0], nil
}
// Multiple configs - use round-robin for load balancing
idx := rrCounter.Add(1) % uint64(len(matches))
return &matches[idx], nil
}
// findMatches finds all ModelConfig entries with the given model_name.
func (c *Config) findMatches(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 {
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 config is valid.
// Note: Multiple entries with the same model_name are allowed for load balancing.
func (c *Config) ValidateModelList() error {
for i := range c.ModelList {
if err := c.ModelList[i].Validate(); err != nil {
return fmt.Errorf("model_list[%d]: %w", i, err)
}
}
return nil
}