Files
picoclaw/pkg/config/config.go
T
2026-03-24 01:33:05 +01:00

2208 lines
81 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"github.com/caarlos0/env/v11"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
)
// 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,
// so allow_from can contain both "123" and 123.
// It also supports parsing comma-separated strings from environment variables,
// including both English (,) and Chinese () commas.
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 []any
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
}
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
// It handles comma-separated values with both English (,) and Chinese () commas.
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
if len(text) == 0 {
*f = nil
return nil
}
s := string(text)
// Replace Chinese comma with English comma, then split
s = strings.ReplaceAll(s, "", ",")
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
*f = result
return nil
}
// CurrentVersion is the latest config schema version
const CurrentVersion = 1
// Config is the current config structure with version support
type Config struct {
Version int `json:"version"` // Config schema version for migration
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
Session SessionConfig `json:"session,omitempty"`
Channels ChannelsConfig `json:"channels"`
ModelList []*ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"`
Hooks HooksConfig `json:"hooks,omitempty"`
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
Voice VoiceConfig `json:"voice"`
// BuildInfo contains build-time version information
BuildInfo BuildInfo `json:"build_info,omitempty"`
security *SecurityConfig
}
func (c *Config) WithSecurity(sec *SecurityConfig) *Config {
if sec == nil {
c.security = sec
return c
}
err := applySecurityConfig(c, sec)
if err != nil {
return nil
}
c.security = sec
return c
}
// FilterSensitiveData filters sensitive values from content before sending to LLM.
// This prevents the LLM from seeing its own credentials.
// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig).
// Short content (below FilterMinLength) is returned unchanged for performance.
func (c *Config) FilterSensitiveData(content string) string {
if c.security == nil || content == "" {
return content
}
// Check if filtering is enabled (default: true)
if !c.Tools.IsFilterSensitiveDataEnabled() {
return content
}
// Fast path: skip filtering for short content
if len(content) < c.Tools.GetFilterMinLength() {
return content
}
return c.security.SensitiveDataReplacer().Replace(content)
}
type HooksConfig struct {
Enabled bool `json:"enabled"`
Defaults HookDefaultsConfig `json:"defaults,omitempty"`
Builtins map[string]BuiltinHookConfig `json:"builtins,omitempty"`
Processes map[string]ProcessHookConfig `json:"processes,omitempty"`
}
type HookDefaultsConfig struct {
ObserverTimeoutMS int `json:"observer_timeout_ms,omitempty"`
InterceptorTimeoutMS int `json:"interceptor_timeout_ms,omitempty"`
ApprovalTimeoutMS int `json:"approval_timeout_ms,omitempty"`
}
type BuiltinHookConfig struct {
Enabled bool `json:"enabled"`
Priority int `json:"priority,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
}
type ProcessHookConfig struct {
Enabled bool `json:"enabled"`
Priority int `json:"priority,omitempty"`
Transport string `json:"transport,omitempty"`
Command []string `json:"command,omitempty"`
Dir string `json:"dir,omitempty"`
Env map[string]string `json:"env,omitempty"`
Observe []string `json:"observe,omitempty"`
Intercept []string `json:"intercept,omitempty"`
}
// BuildInfo contains build-time version information
type BuildInfo struct {
Version string `json:"version"`
GitCommit string `json:"git_commit"`
BuildTime string `json:"build_time"`
GoVersion string `json:"go_version"`
}
// 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 {
Session *SessionConfig `json:"session,omitempty"`
*Alias
}{
Alias: (*Alias)(c),
}
// 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 {
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"`
}
// RoutingConfig controls the intelligent model routing feature.
// When enabled, each incoming message is scored against structural features
// (message length, code blocks, tool call history, conversation depth, attachments).
// Messages scoring below Threshold are sent to LightModel; all others use the
// agent's primary model. This reduces cost and latency for simple tasks without
// requiring any keyword matching — all scoring is language-agnostic.
type RoutingConfig struct {
Enabled bool `json:"enabled"`
LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
}
// SubTurnConfig configures the SubTurn execution system.
type SubTurnConfig struct {
MaxDepth int `json:"max_depth" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_MAX_DEPTH"`
MaxConcurrent int `json:"max_concurrent" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_MAX_CONCURRENT"`
DefaultTimeoutMinutes int `json:"default_timeout_minutes" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_DEFAULT_TIMEOUT_MINUTES"`
DefaultTokenBudget int `json:"default_token_budget" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_DEFAULT_TOKEN_BUDGET"`
ConcurrencyTimeoutSec int `json:"concurrency_timeout_sec" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_CONCURRENCY_TIMEOUT_SEC"`
}
type ToolFeedbackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
}
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"`
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" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
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"`
ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
Routing *RoutingConfig `json:"routing,omitempty"`
SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
}
const (
DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
DefaultWeComAIBotProcessingMessage = "⏳ Processing, please wait. The results will be sent shortly."
)
func (d *AgentDefaults) GetMaxMediaSize() int {
if d.MaxMediaSize > 0 {
return d.MaxMediaSize
}
return DefaultMaxMediaSize
}
// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages.
func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int {
if d.ToolFeedback.MaxArgsLength > 0 {
return d.ToolFeedback.MaxArgsLength
}
return 300
}
// IsToolFeedbackEnabled returns true when tool feedback messages should be sent to the chat.
func (d *AgentDefaults) IsToolFeedbackEnabled() bool {
return d.ToolFeedback.Enabled
}
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
return d.ModelName
}
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"`
Matrix MatrixConfig `json:"matrix"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Weixin WeixinConfig `json:"weixin"`
Pico PicoConfig `json:"pico"`
PicoClient PicoClientConfig `json:"pico_client"`
IRC IRCConfig `json:"irc"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
type GroupTriggerConfig struct {
MentionOnly bool `json:"mention_only,omitempty"`
Prefixes []string `json:"prefixes,omitempty"`
}
// TypingConfig controls typing indicator behavior (Phase 10).
type TypingConfig struct {
Enabled bool `json:"enabled,omitempty"`
}
// PlaceholderConfig controls placeholder message behavior (Phase 10).
type PlaceholderConfig struct {
Enabled bool `json:"enabled,omitempty"`
Text string `json:"text,omitempty"`
}
type StreamingConfig struct {
Enabled bool `json:"enabled,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED"`
ThrottleSeconds int `json:"throttle_seconds,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS"`
MinGrowthChars int `json:"min_growth_chars,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS"`
}
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
token string
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
Streaming StreamingConfig `json:"streaming,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
secDirty bool
}
// Token returns the Telegram bot token
func (c *TelegramConfig) Token() string {
return c.token
}
// SetToken sets the Telegram bot token
func (c *TelegramConfig) SetToken(token string) {
c.token = token
c.secDirty = true
}
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
encryptKey string
verificationToken string
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
secDirty bool
}
// AppSecret returns the Feishu app secret
func (c *FeishuConfig) AppSecret() string {
return c.appSecret
}
// SetAppSecret sets the Feishu app secret
func (c *FeishuConfig) SetAppSecret(secret string) {
c.appSecret = secret
c.secDirty = true
}
// EncryptKey returns the Feishu encrypt key
func (c *FeishuConfig) EncryptKey() string {
return c.encryptKey
}
// SetEncryptKey sets the Feishu encrypt key
func (c *FeishuConfig) SetEncryptKey(key string) {
c.encryptKey = key
c.secDirty = true
}
// VerificationToken returns the Feishu verification token
func (c *FeishuConfig) VerificationToken() string {
return c.verificationToken
}
// SetVerificationToken sets the Feishu verification token
func (c *FeishuConfig) SetVerificationToken(token string) {
c.verificationToken = token
c.secDirty = true
}
type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
token string
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
secDirty bool
}
// Token returns the Discord bot token
func (c *DiscordConfig) Token() string {
return c.token
}
// SetToken sets the Discord bot token
func (c *DiscordConfig) SetToken(token string) {
c.token = token
c.secDirty = true
}
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"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
}
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
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"`
SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
secDirty bool
}
// AppSecret returns the QQ app secret
func (c *QQConfig) AppSecret() string {
return c.appSecret
}
// SetAppSecret sets the QQ app secret
func (c *QQConfig) SetAppSecret(secret string) {
c.appSecret = secret
c.secDirty = true
}
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
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
secDirty bool
}
// ClientSecret returns the DingTalk client secret
func (c *DingTalkConfig) ClientSecret() string {
return c.clientSecret
}
// SetClientSecret sets the DingTalk client secret
func (c *DingTalkConfig) SetClientSecret(secret string) {
c.clientSecret = secret
c.secDirty = true
}
type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
botToken string
appToken string
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
secDirty bool
}
// BotToken returns the Slack bot token
func (c *SlackConfig) BotToken() string {
return c.botToken
}
// SetBotToken sets the Slack bot token
func (c *SlackConfig) SetBotToken(token string) {
c.botToken = token
c.secDirty = true
}
// AppToken returns the Slack app token
func (c *SlackConfig) AppToken() string {
return c.appToken
}
// SetAppToken sets the Slack app token
func (c *SlackConfig) SetAppToken(token string) {
c.appToken = token
c.secDirty = true
}
type MatrixConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
accessToken string
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
secDirty bool
}
// AccessToken returns the Matrix access token
func (c *MatrixConfig) AccessToken() string {
return c.accessToken
}
// SetAccessToken sets the Matrix access token
func (c *MatrixConfig) SetAccessToken(token string) {
c.accessToken = token
c.secDirty = true
}
type LINEConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
channelSecret string
channelAccessToken string
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"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
secDirty bool
}
// ChannelSecret returns the LINE channel secret
func (c *LINEConfig) ChannelSecret() string {
return c.channelSecret
}
// SetChannelSecret sets the LINE channel secret
func (c *LINEConfig) SetChannelSecret(secret string) {
c.channelSecret = secret
c.secDirty = true
}
// ChannelAccessToken returns the LINE channel access token
func (c *LINEConfig) ChannelAccessToken() string {
return c.channelAccessToken
}
// SetChannelAccessToken sets the LINE channel access token
func (c *LINEConfig) SetChannelAccessToken(token string) {
c.channelAccessToken = token
c.secDirty = true
}
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
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"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
secDirty bool
}
// AccessToken returns the OneBot access token
func (c *OneBotConfig) AccessToken() string {
return c.accessToken
}
// SetAccessToken sets the OneBot access token
func (c *OneBotConfig) SetAccessToken(token string) {
c.accessToken = token
c.secDirty = true
}
type WeComConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
token string
encodingAESKey string
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
secDirty bool
}
// Token returns the WeCom token
func (c *WeComConfig) Token() string {
return c.token
}
// SetToken sets the WeCom token
func (c *WeComConfig) SetToken(token string) {
c.token = token
c.secDirty = true
}
// EncodingAESKey returns the WeCom encoding AES key
func (c *WeComConfig) EncodingAESKey() string {
return c.encodingAESKey
}
// SetEncodingAESKey sets the WeCom encoding AES key
func (c *WeComConfig) SetEncodingAESKey(key string) {
c.encodingAESKey = key
c.secDirty = true
}
type WeComAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
corpSecret string
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
token string
encodingAESKey string
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
secDirty bool
}
// CorpSecret returns the corporate secret for WeCom app
func (c *WeComAppConfig) CorpSecret() string {
return c.corpSecret
}
// SetCorpSecret sets the corporate secret for WeCom app
func (c *WeComAppConfig) SetCorpSecret(secret string) {
c.corpSecret = secret
c.secDirty = true
}
// Token returns the webhook token for WeCom app
func (c *WeComAppConfig) Token() string {
return c.token
}
// SetToken sets the webhook token for WeCom app
func (c *WeComAppConfig) SetToken(token string) {
c.token = token
c.secDirty = true
}
// EncodingAESKey returns the encoding AES key for WeCom app
func (c *WeComAppConfig) EncodingAESKey() string {
return c.encodingAESKey
}
// SetEncodingAESKey sets the encoding AES key for WeCom app
func (c *WeComAppConfig) SetEncodingAESKey(key string) {
c.encodingAESKey = key
c.secDirty = true
}
type WeComAIBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"`
secret string
token string
encodingAESKey string
WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
ProcessingMessage string `json:"processing_message,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_PROCESSING_MESSAGE"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
secDirty bool
}
// Token returns the webhook token for WeCom AI bot
func (c *WeComAIBotConfig) Token() string {
return c.token
}
// EncodingAESKey returns the encoding AES key for WeCom AI bot
func (c *WeComAIBotConfig) EncodingAESKey() string {
return c.encodingAESKey
}
// SetToken sets the token for WeCom AI bot
func (c *WeComAIBotConfig) SetToken(token string) {
c.token = token
c.secDirty = true
}
// SetEncodingAESKey sets the encoding AES key for WeCom AI bot
func (c *WeComAIBotConfig) SetEncodingAESKey(key string) {
c.encodingAESKey = key
c.secDirty = true
}
func (c *WeComAIBotConfig) Secret() string {
return c.secret
}
func (c *WeComAIBotConfig) SetSecret(secret string) {
c.secret = secret
c.secDirty = true
}
type WeixinConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
token string
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
secDirty bool
}
func (c *WeixinConfig) Token() string {
return c.token
}
func (c *WeixinConfig) SetToken(token string) *WeixinConfig {
c.token = token
c.secDirty = true
return c
}
type PicoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
token string
AllowTokenQuery bool `json:"allow_token_query,omitempty"`
AllowOrigins []string `json:"allow_origins,omitempty"`
PingInterval int `json:"ping_interval,omitempty"`
ReadTimeout int `json:"read_timeout,omitempty"`
WriteTimeout int `json:"write_timeout,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
secDirty bool
}
// Token returns the Pico channel token
func (c *PicoConfig) Token() string {
return c.token
}
// SetToken sets the Pico channel token
func (c *PicoConfig) SetToken(token string) {
c.token = token
c.secDirty = true
}
type PicoClientConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"`
URL string `json:"url" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"`
SessionID string `json:"session_id,omitempty"`
PingInterval int `json:"ping_interval,omitempty"`
ReadTimeout int `json:"read_timeout,omitempty"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"`
}
type IRCConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
password string
nickServPassword string
SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
saslPassword string
Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
secDirty bool
}
// Password returns the IRC password
func (c *IRCConfig) Password() string {
return c.password
}
// NickServPassword returns the NickServ password
func (c *IRCConfig) NickServPassword() string {
return c.nickServPassword
}
// SASLPassword returns the SASL password
func (c *IRCConfig) SASLPassword() string {
return c.saslPassword
}
func (c *IRCConfig) SetPassword(password string) {
c.password = password
c.secDirty = true
}
func (c *IRCConfig) SetNickServPassword(password string) {
c.nickServPassword = password
c.secDirty = true
}
func (c *IRCConfig) SetSASLPassword(password string) {
c.saslPassword = password
c.secDirty = true
}
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 VoiceConfig struct {
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"`
}
// 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 include openai, anthropic, antigravity, claude-cli,
// codex-cli, github-copilot, and named OpenAI-compatible protocols such as
// groq, deepseek, modelscope, and novita.
// 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
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover
// 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")
RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
// from security
secModelName string
apiKeys []string
secDirty bool
}
// APIKey returns the first API key from apiKeys
func (c *ModelConfig) APIKey() string {
if len(c.apiKeys) > 0 {
return c.apiKeys[0]
}
return ""
}
// 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
}
func (c *ModelConfig) SetAPIKey(value string) {
if len(c.apiKeys) > 0 {
c.apiKeys[0] = value
} else {
c.apiKeys = append(c.apiKeys, value)
}
c.secDirty = true
}
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
}
type ToolDiscoveryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"`
UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"`
UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"`
}
type ToolConfig struct {
Enabled bool `json:"enabled" env:"ENABLED"`
}
type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
apiKeys []string
secDirty bool
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
// APIKey returns the Brave API key
func (c *BraveConfig) APIKey() string {
if len(c.apiKeys) == 0 {
return ""
}
return c.apiKeys[0]
}
// APIKeys returns the Brave API keys
func (c *BraveConfig) APIKeys() []string {
return c.apiKeys
}
// SetAPIKey sets the Brave API key
func (c *BraveConfig) SetAPIKey(key string) {
c.apiKeys = []string{key}
c.secDirty = true
}
// SetAPIKeys sets the Brave API keys
func (c *BraveConfig) SetAPIKeys(keys []string) {
c.apiKeys = keys
c.secDirty = true
}
type TavilyConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
apiKeys []string
secDirty bool
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
}
// APIKey returns the Tavily API key
func (c *TavilyConfig) APIKey() string {
if len(c.apiKeys) == 0 {
return ""
}
return c.apiKeys[0]
}
// APIKeys returns the Tavily API keys
func (c *TavilyConfig) APIKeys() []string {
return c.apiKeys
}
// SetAPIKey sets the Tavily API key
func (c *TavilyConfig) SetAPIKey(key string) {
c.apiKeys = []string{key}
c.secDirty = true
}
// SetAPIKeys sets the Tavily API keys
func (c *TavilyConfig) SetAPIKeys(keys []string) {
c.apiKeys = keys
c.secDirty = true
}
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"`
apiKeys []string
secDirty bool
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
// APIKey returns the Perplexity API key
func (c *PerplexityConfig) APIKey() string {
if len(c.apiKeys) == 0 {
return ""
}
return c.apiKeys[0]
}
// SetAPIKey sets the Perplexity API key
func (c *PerplexityConfig) SetAPIKey(key string) {
c.apiKeys = []string{key}
c.secDirty = true
}
// APIKeys returns the Perplexity API keys
func (c *PerplexityConfig) APIKeys() []string {
return c.apiKeys
}
// SetAPIKeys sets the Perplexity API keys
func (c *PerplexityConfig) SetAPIKeys(keys []string) {
c.apiKeys = keys
c.secDirty = true
}
type SearXNGConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"`
}
type GLMSearchConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
apiKey string
secDirty bool
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
// SearchEngine specifies the search backend: "search_std" (default),
// "search_pro", "search_pro_sogou", or "search_pro_quark".
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
}
// APIKey returns the GLM search API key
func (c *GLMSearchConfig) APIKey() string {
return c.apiKey
}
// SetAPIKey sets the GLM search API key (internal use only)
func (c *GLMSearchConfig) SetAPIKey(key string) {
c.apiKey = key
c.secDirty = true
}
type BaiduSearchConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"`
apiKey string
secDirty bool
}
// APIKey returns the Baidu search API key
func (c *BaiduSearchConfig) APIKey() string {
return c.apiKey
}
func (c *BaiduSearchConfig) SetAPIKey(key string) {
c.apiKey = key
c.secDirty = true
}
type WebToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
Brave BraveConfig ` json:"brave"`
Tavily TavilyConfig ` json:"tavily"`
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
Perplexity PerplexityConfig ` json:"perplexity"`
SearXNG SearXNGConfig ` json:"searxng"`
GLMSearch GLMSearchConfig ` json:"glm_search"`
BaiduSearch BaiduSearchConfig ` json:"baidu_search"`
// PreferNative controls whether to use provider-native web search when
// the active LLM supports it (e.g. OpenAI web_search_preview). When true,
// the client-side web_search tool is hidden to avoid duplicate search surfaces,
// and the provider's built-in search is used instead. Falls back to client-side
// search when the provider does not support native search.
PreferNative bool `json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
}
type CronToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
AllowCommand bool ` env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND" json:"allow_command"`
}
type ExecConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"`
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
}
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"`
}
type MediaCleanupConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"`
MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"`
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
}
type ReadFileToolConfig struct {
Enabled bool `json:"enabled"`
MaxReadFileSize int `json:"max_read_file_size"`
}
type ToolsConfig struct {
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
// FilterSensitiveData controls whether to filter sensitive values (API keys,
// tokens, secrets) from tool results before sending to the LLM.
// Default: true (enabled)
FilterSensitiveData bool `json:"filter_sensitive_data" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"`
// FilterMinLength is the minimum content length required for filtering.
// Content shorter than this will be returned unchanged for performance.
// Default: 8
FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"`
Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
MCP MCPConfig `json:"mcp"`
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
}
// IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled
func (c *ToolsConfig) IsFilterSensitiveDataEnabled() bool {
return c.FilterSensitiveData
}
// GetFilterMinLength returns the minimum content length for filtering (default: 8)
func (c *ToolsConfig) GetFilterMinLength() int {
if c.FilterMinLength <= 0 {
return 8
}
return c.FilterMinLength
}
type SearchCacheConfig struct {
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
}
type SkillsRegistriesConfig struct {
ClawHub ClawHubRegistryConfig `json:"clawhub"`
}
type SkillsGithubConfig struct {
token string
secDirty bool
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
}
// Token returns the GitHub token
func (c *SkillsGithubConfig) Token() string {
return c.token
}
// SetToken sets the GitHub token
func (c *SkillsGithubConfig) SetToken(token string) {
c.token = token
c.secDirty = true
}
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"`
authToken string
secDirty bool
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
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"`
}
// AuthToken returns the ClawHub auth token
func (c *ClawHubRegistryConfig) AuthToken() string {
return c.authToken
}
// SetAuthToken sets the ClawHub auth token
func (c *ClawHubRegistryConfig) SetAuthToken(token string) {
c.authToken = token
c.secDirty = true
}
// MCPServerConfig defines configuration for a single MCP server
type MCPServerConfig struct {
// Enabled indicates whether this MCP server is active
Enabled bool `json:"enabled"`
// Deferred controls whether this server's tools are registered as hidden (deferred/discovery mode).
// When nil, the global Discovery.Enabled setting applies.
// When explicitly set to true or false, it overrides the global setting for this server only.
Deferred *bool `json:"deferred,omitempty"`
// Command is the executable to run (e.g., "npx", "python", "/path/to/server")
Command string `json:"command"`
// Args are the arguments to pass to the command
Args []string `json:"args,omitempty"`
// Env are environment variables to set for the server process (stdio only)
Env map[string]string `json:"env,omitempty"`
// EnvFile is the path to a file containing environment variables (stdio only)
EnvFile string `json:"env_file,omitempty"`
// Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
Type string `json:"type,omitempty"`
// URL is used for SSE/HTTP transport
URL string `json:"url,omitempty"`
// Headers are HTTP headers to send with requests (sse/http only)
Headers map[string]string `json:"headers,omitempty"`
}
// MCPConfig defines configuration for all MCP servers
type MCPConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"`
// Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
}
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return DefaultConfig(), nil
}
return nil, err
}
// First, try to detect config version by reading the version field
var versionInfo struct {
Version int `json:"version"`
}
if e := json.Unmarshal(data, &versionInfo); e != nil {
return nil, fmt.Errorf("failed to detect config version: %w", e)
}
if len(data) <= 10 {
return DefaultConfig().WithSecurity(&SecurityConfig{}), nil
}
// Load config based on detected version
var cfg *Config
switch versionInfo.Version {
case 0:
logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
// Legacy config (no version field)
v, e := loadConfigV0(data)
if e != nil {
return nil, e
}
cfg, e = v.Migrate()
if e != nil {
logger.DebugF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
return nil, e
}
logger.DebugF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
defer func() {
_ = SaveConfig(path, cfg)
}()
case CurrentVersion:
// Current version
cfg, err = loadConfig(data)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
}
// Load security configuration
securityPath := securityPath(path)
sec, err := loadSecurityConfig(securityPath)
if err != nil {
return nil, fmt.Errorf("failed to load security config: %w", err)
}
// Apply security references from .security.yml BEFORE resolveAPIKeys
// This resolves ref: references to actual values
if err := applySecurityConfig(cfg, sec); err != nil {
return nil, fmt.Errorf("failed to apply security config: %w", err)
}
if passphrase := credential.PassphraseProvider(); passphrase != "" {
for _, m := range cfg.ModelList {
for _, k := range m.apiKeys {
if k != "" && !strings.HasPrefix(k, "enc://") && !strings.HasPrefix(k, "file://") {
fmt.Fprintf(os.Stderr,
"picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n",
m.ModelName)
break // Only warn once per model
}
}
}
}
if err := env.Parse(cfg); err != nil {
return nil, err
}
if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil {
return nil, err
}
// Resolve security fields like authToken that may contain file:// references
if err := resolveSecurityFields(cfg, filepath.Dir(path)); err != nil {
return nil, err
}
// Expand multi-key configs into separate entries for key-level failover
cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
// Validate model_list for uniqueness and required fields
if err := cfg.ValidateModelList(); err != nil {
return nil, err
}
// Ensure Workspace has a default if not set
if cfg.Agents.Defaults.Workspace == "" {
homePath, _ := os.UserHomeDir()
if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" {
homePath = picoclawHome
} else if homePath != "" {
homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome)
}
cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName)
}
return cfg, nil
}
func copyArray[T any](dst, src *[]T) {
*dst = make([]T, len(*src))
copy(*dst, *src)
}
// applySecurityConfig resolves all security references in config
// It checks each field for "ref:" prefixed values and resolves them from .security.yml
func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
if sec == nil {
return nil
}
if sec.Web.Brave != nil && len(sec.Web.Brave.APIKeys) > 0 {
copyArray(&cfg.Tools.Web.Brave.apiKeys, &sec.Web.Brave.APIKeys)
}
if sec.Web.Tavily != nil && len(sec.Web.Tavily.APIKeys) > 0 {
copyArray(&cfg.Tools.Web.Tavily.apiKeys, &sec.Web.Tavily.APIKeys)
}
if sec.Web.Perplexity != nil && len(sec.Web.Perplexity.APIKeys) > 0 {
copyArray(&cfg.Tools.Web.Perplexity.apiKeys, &sec.Web.Perplexity.APIKeys)
}
if sec.Web.GLMSearch != nil && sec.Web.GLMSearch.APIKey != "" {
cfg.Tools.Web.GLMSearch.apiKey = sec.Web.GLMSearch.APIKey
}
if sec.Web.BaiduSearch != nil && sec.Web.BaiduSearch.APIKey != "" {
cfg.Tools.Web.BaiduSearch.apiKey = sec.Web.BaiduSearch.APIKey
}
if sec.Skills.Github != nil && sec.Skills.Github.Token != "" {
cfg.Tools.Skills.Github.token = sec.Skills.Github.Token
}
if sec.Skills.ClawHub != nil && sec.Skills.ClawHub.AuthToken != "" {
cfg.Tools.Skills.Registries.ClawHub.authToken = sec.Skills.ClawHub.AuthToken
}
names := toNameIndex(cfg.ModelList)
for i, model := range cfg.ModelList {
// Try exact match first (e.g., "abc:0" -> "abc:0")
if entry, exists := sec.ModelList[names[i]]; exists {
copyArray(&model.apiKeys, &entry.APIKeys)
model.secModelName = names[i]
continue
}
// Try match without index suffix (e.g., "abc" -> "abc")
// This allows .security.yml to use simpler keys like "test-model" instead of "test-model:0"
baseName := model.ModelName
if entry, exists := sec.ModelList[baseName]; exists {
copyArray(&model.apiKeys, &entry.APIKeys)
model.secModelName = baseName
continue
}
}
// Handle Telegram token
if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" {
cfg.Channels.Telegram.token = sec.Channels.Telegram.Token
}
// Handle Feishu credentials
if sec.Channels.Feishu != nil {
if sec.Channels.Feishu.AppSecret != "" {
cfg.Channels.Feishu.appSecret = sec.Channels.Feishu.AppSecret
}
if sec.Channels.Feishu.EncryptKey != "" {
cfg.Channels.Feishu.encryptKey = sec.Channels.Feishu.EncryptKey
}
if sec.Channels.Feishu.VerificationToken != "" {
cfg.Channels.Feishu.verificationToken = sec.Channels.Feishu.VerificationToken
}
}
// Handle Discord token
if sec.Channels.Discord != nil && sec.Channels.Discord.Token != "" {
cfg.Channels.Discord.token = sec.Channels.Discord.Token
}
// Handle Weixin token
if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" {
cfg.Channels.Weixin.token = sec.Channels.Weixin.Token
}
// Handle DingTalk client secret
if sec.Channels.DingTalk != nil && sec.Channels.DingTalk.ClientSecret != "" {
cfg.Channels.DingTalk.clientSecret = sec.Channels.DingTalk.ClientSecret
}
// Handle Slack tokens
if sec.Channels.Slack != nil {
if sec.Channels.Slack.BotToken != "" {
cfg.Channels.Slack.botToken = sec.Channels.Slack.BotToken
}
if sec.Channels.Slack.AppToken != "" {
cfg.Channels.Slack.appToken = sec.Channels.Slack.AppToken
}
}
// Handle Matrix access token
if sec.Channels.Matrix != nil && sec.Channels.Matrix.AccessToken != "" {
cfg.Channels.Matrix.accessToken = sec.Channels.Matrix.AccessToken
}
// Handle LINE credentials
if sec.Channels.LINE != nil {
if sec.Channels.LINE.ChannelSecret != "" {
cfg.Channels.LINE.channelSecret = sec.Channels.LINE.ChannelSecret
}
if sec.Channels.LINE.ChannelAccessToken != "" {
cfg.Channels.LINE.channelAccessToken = sec.Channels.LINE.ChannelAccessToken
}
}
// Handle OneBot access token
if sec.Channels.OneBot != nil && sec.Channels.OneBot.AccessToken != "" {
cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken
}
// Handle WeCom token and encoding key
if sec.Channels.WeCom != nil {
if sec.Channels.WeCom.Token != "" {
cfg.Channels.WeCom.token = sec.Channels.WeCom.Token
}
if sec.Channels.WeCom.EncodingAESKey != "" {
cfg.Channels.WeCom.encodingAESKey = sec.Channels.WeCom.EncodingAESKey
}
}
// Handle WeCom App credentials
if sec.Channels.WeComApp != nil {
if sec.Channels.WeComApp.CorpSecret != "" {
cfg.Channels.WeComApp.corpSecret = sec.Channels.WeComApp.CorpSecret
}
if sec.Channels.WeComApp.Token != "" {
cfg.Channels.WeComApp.token = sec.Channels.WeComApp.Token
}
if sec.Channels.WeComApp.EncodingAESKey != "" {
cfg.Channels.WeComApp.encodingAESKey = sec.Channels.WeComApp.EncodingAESKey
}
}
// Handle WeCom AI Bot credentials
if sec.Channels.WeComAIBot != nil {
if sec.Channels.WeComAIBot.Token != "" {
cfg.Channels.WeComAIBot.token = sec.Channels.WeComAIBot.Token
}
if sec.Channels.WeComAIBot.EncodingAESKey != "" {
cfg.Channels.WeComAIBot.encodingAESKey = sec.Channels.WeComAIBot.EncodingAESKey
}
if sec.Channels.WeComAIBot.Secret != "" {
cfg.Channels.WeComAIBot.secret = sec.Channels.WeComAIBot.Secret
}
}
// Handle Pico channel token
if sec.Channels.Pico != nil && sec.Channels.Pico.Token != "" {
cfg.Channels.Pico.token = sec.Channels.Pico.Token
}
// Handle IRC passwords
if sec.Channels.IRC != nil {
if sec.Channels.IRC.Password != "" {
cfg.Channels.IRC.password = sec.Channels.IRC.Password
}
if sec.Channels.IRC.NickServPassword != "" {
cfg.Channels.IRC.nickServPassword = sec.Channels.IRC.NickServPassword
}
if sec.Channels.IRC.SASLPassword != "" {
cfg.Channels.IRC.saslPassword = sec.Channels.IRC.SASLPassword
}
}
// Handle QQ app secret
if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" {
cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret
}
cfg.security = sec
return nil
}
func toNameIndex(list []*ModelConfig) []string {
nameList := make([]string, 0, len(list))
countMap := make(map[string]int)
for _, model := range list {
name := model.ModelName
index := countMap[name]
nameList = append(nameList, fmt.Sprintf("%s:%d", name, index))
countMap[name]++
}
return nameList
}
// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values
// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or
// empty). Returns (nil, error) if any key fails to encrypt — callers must treat
// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk.
// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig
// and leave JSON marshaling to the caller.
func encryptPlaintextAPIKeys(
models map[string]ModelSecurityEntry,
passphrase string,
) (map[string]ModelSecurityEntry, error) {
sealed := make(map[string]ModelSecurityEntry, len(models))
changed := false
for k, m := range models {
sealedEntry := ModelSecurityEntry{APIKeys: make([]string, len(m.APIKeys))}
// Encrypt each key in APIKeys
for i, key := range m.APIKeys {
if key == "" || strings.HasPrefix(key, "enc://") || strings.HasPrefix(key, "file://") {
sealedEntry.APIKeys[i] = key
continue
}
encrypted, err := credential.Encrypt(passphrase, "", key)
if err != nil {
return nil, fmt.Errorf("cannot seal api_key for model %q: %w", k, err)
}
sealedEntry.APIKeys[i] = encrypted
changed = true
}
sealed[k] = sealedEntry
}
if !changed {
return nil, nil
}
return sealed, nil
}
// resolveAPIKeys decrypts or dereferences each api_key in models in-place.
// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt).
func resolveAPIKeys(models []*ModelConfig, configDir string) error {
cr := credential.NewResolver(configDir)
for i := range models {
// Resolve APIKeys array
for j, key := range models[i].apiKeys {
resolved, err := cr.Resolve(key)
if err != nil {
return fmt.Errorf(
"model_list[%d] (%s): api_keys[%d]: %w",
i,
models[i].ModelName,
j,
err,
)
}
models[i].apiKeys[j] = resolved
}
}
return nil
}
func (c *Config) migrateChannelConfigs() {
// Discord: mention_only -> group_trigger.mention_only
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
c.Channels.Discord.GroupTrigger.MentionOnly = true
}
// OneBot: group_trigger_prefix -> group_trigger.prefixes
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
}
}
func SaveConfig(path string, cfg *Config) error {
if cfg.security == nil {
logger.Errorf("config %#v", *cfg)
if len(cfg.ModelList) > 0 {
logger.Errorf("model[0] %#v", cfg.ModelList[0])
}
logger.ErrorC("config", "security is nil")
return fmt.Errorf("security is nil")
}
// Ensure version is always set when saving
if cfg.Version == 0 {
cfg.Version = CurrentVersion
}
names := toNameIndex(cfg.ModelList)
for i, m := range cfg.ModelList {
if m.secDirty {
if m.secModelName == "" {
m.secModelName = names[i]
}
cfg.security.ModelList[m.secModelName] = ModelSecurityEntry{
APIKeys: m.apiKeys,
}
m.secDirty = false
}
}
if cfg.Channels.Pico.secDirty {
cfg.security.Channels.Pico = &PicoSecurity{
Token: cfg.Channels.Pico.Token(),
}
cfg.Channels.Pico.secDirty = false
}
if cfg.Channels.IRC.secDirty {
cfg.security.Channels.IRC = &IRCSecurity{
Password: cfg.Channels.IRC.password,
NickServPassword: cfg.Channels.IRC.nickServPassword,
SASLPassword: cfg.Channels.IRC.saslPassword,
}
cfg.Channels.IRC.secDirty = false
}
if cfg.Channels.Telegram.secDirty {
cfg.security.Channels.Telegram = &TelegramSecurity{
Token: cfg.Channels.Telegram.Token(),
}
cfg.Channels.Telegram.secDirty = false
}
if cfg.Channels.Feishu.secDirty {
cfg.security.Channels.Feishu = &FeishuSecurity{
AppSecret: cfg.Channels.Feishu.AppSecret(),
EncryptKey: cfg.Channels.Feishu.EncryptKey(),
VerificationToken: cfg.Channels.Feishu.VerificationToken(),
}
cfg.Channels.Feishu.secDirty = false
}
if cfg.Channels.Discord.secDirty {
cfg.security.Channels.Discord = &DiscordSecurity{
Token: cfg.Channels.Discord.Token(),
}
cfg.Channels.Discord.secDirty = false
}
if cfg.Channels.Weixin.secDirty {
cfg.security.Channels.Weixin = &WeixinSecurity{
Token: cfg.Channels.Weixin.Token(),
}
cfg.Channels.Discord.secDirty = false
}
if cfg.Channels.QQ.secDirty {
cfg.security.Channels.QQ = &QQSecurity{
AppSecret: cfg.Channels.QQ.AppSecret(),
}
cfg.Channels.QQ.secDirty = false
}
if cfg.Channels.DingTalk.secDirty {
cfg.security.Channels.DingTalk = &DingTalkSecurity{
ClientSecret: cfg.Channels.DingTalk.ClientSecret(),
}
cfg.Channels.DingTalk.secDirty = false
}
if cfg.Channels.Slack.secDirty {
cfg.security.Channels.Slack = &SlackSecurity{
BotToken: cfg.Channels.Slack.BotToken(),
AppToken: cfg.Channels.Slack.AppToken(),
}
cfg.Channels.Slack.secDirty = false
}
if cfg.Channels.Matrix.secDirty {
cfg.security.Channels.Matrix = &MatrixSecurity{
AccessToken: cfg.Channels.Matrix.AccessToken(),
}
cfg.Channels.Matrix.secDirty = false
}
if cfg.Channels.LINE.secDirty {
cfg.security.Channels.LINE = &LINESecurity{
ChannelSecret: cfg.Channels.LINE.ChannelSecret(),
ChannelAccessToken: cfg.Channels.LINE.ChannelAccessToken(),
}
cfg.Channels.LINE.secDirty = false
}
if cfg.Channels.OneBot.secDirty {
cfg.security.Channels.OneBot = &OneBotSecurity{
AccessToken: cfg.Channels.OneBot.AccessToken(),
}
cfg.Channels.OneBot.secDirty = false
}
if cfg.Channels.WeCom.secDirty {
cfg.security.Channels.WeCom = &WeComSecurity{
Token: cfg.Channels.WeCom.Token(),
EncodingAESKey: cfg.Channels.WeCom.EncodingAESKey(),
}
cfg.Channels.WeCom.secDirty = false
}
if cfg.Channels.WeComApp.secDirty {
cfg.security.Channels.WeComApp = &WeComAppSecurity{
CorpSecret: cfg.Channels.WeComApp.CorpSecret(),
Token: cfg.Channels.WeComApp.Token(),
EncodingAESKey: cfg.Channels.WeComApp.EncodingAESKey(),
}
cfg.Channels.WeComApp.secDirty = false
}
if cfg.Channels.WeComAIBot.secDirty {
cfg.security.Channels.WeComAIBot = &WeComAIBotSecurity{
Token: cfg.Channels.WeComAIBot.Token(),
EncodingAESKey: cfg.Channels.WeComAIBot.EncodingAESKey(),
Secret: cfg.Channels.WeComAIBot.Secret(),
}
cfg.Channels.WeComAIBot.secDirty = false
}
if cfg.Tools.Web.Brave.secDirty {
cfg.security.Web.Brave = &BraveSecurity{
APIKeys: cfg.Tools.Web.Brave.APIKeys(),
}
cfg.Tools.Web.Brave.secDirty = false
}
if cfg.Tools.Web.Tavily.secDirty {
cfg.security.Web.Tavily = &TavilySecurity{
APIKeys: cfg.Tools.Web.Tavily.APIKeys(),
}
cfg.Tools.Web.Tavily.secDirty = false
}
if cfg.Tools.Web.Perplexity.secDirty {
cfg.security.Web.Perplexity = &PerplexitySecurity{
APIKeys: cfg.Tools.Web.Perplexity.APIKeys(),
}
cfg.Tools.Web.Perplexity.secDirty = false
}
if cfg.Tools.Web.GLMSearch.secDirty {
cfg.security.Web.GLMSearch = &GLMSearchSecurity{
APIKey: cfg.Tools.Web.GLMSearch.APIKey(),
}
cfg.Tools.Web.GLMSearch.secDirty = false
}
if cfg.Tools.Web.BaiduSearch.secDirty {
cfg.security.Web.BaiduSearch = &BaiduSearchSecurity{
APIKey: cfg.Tools.Web.BaiduSearch.APIKey(),
}
cfg.Tools.Web.BaiduSearch.secDirty = false
}
if cfg.Tools.Skills.Github.secDirty {
cfg.security.Skills.Github = &GithubSecurity{
Token: cfg.Tools.Skills.Github.Token(),
}
cfg.Tools.Skills.Github.secDirty = false
}
if cfg.Tools.Skills.Registries.ClawHub.secDirty {
cfg.security.Skills.ClawHub = &ClawHubSecurity{
AuthToken: cfg.Tools.Skills.Registries.ClawHub.AuthToken(),
}
cfg.Tools.Skills.Registries.ClawHub.secDirty = false
}
if passphrase := credential.PassphraseProvider(); passphrase != "" {
sealed, err := encryptPlaintextAPIKeys(cfg.security.ModelList, passphrase)
if err != nil {
return err
}
if sealed != nil {
cfg.security.ModelList = sealed
}
}
if err := saveSecurityConfig(securityPath(path), cfg.security); err != nil {
logger.ErrorCF("config", "cannot save .security.yml", map[string]any{"error": err})
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return fileutil.WriteFileAtomic(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
return expandHome(c.Agents.Defaults.Workspace)
}
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.
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) - 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
}
// 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
}
func (c *Config) SecurityCopyFrom(cfg *Config) {
c.security = cfg.security
}
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
seen := make(map[string]struct{})
var all []string
if k := strings.TrimSpace(apiKey); k != "" {
if _, exists := seen[k]; !exists {
seen[k] = struct{}{}
all = append(all, k)
}
}
for _, k := range apiKeys {
if trimmed := strings.TrimSpace(k); trimmed != "" {
if _, exists := seen[trimmed]; !exists {
seen[trimmed] = struct{}{}
all = append(all, trimmed)
}
}
}
return all
}
// resolveSecurityFields resolves file:// and enc:// references in security-sensitive fields
// like authToken and token that are not part of ModelConfig's apiKeys
func resolveSecurityFields(cfg *Config, configDir string) error {
cr := credential.NewResolver(configDir)
// Resolve Web tool API keys - set apiKey field to first resolved apiKeys entry
if len(cfg.Tools.Web.Brave.apiKeys) > 0 {
keys := cfg.Tools.Web.Brave.apiKeys
for i, key := range keys {
resolved, err := cr.Resolve(key)
if err != nil {
return fmt.Errorf("brave api_keys[%d]: %w", i, err)
}
keys[i] = resolved
}
}
if len(cfg.Tools.Web.Tavily.apiKeys) > 0 {
keys := cfg.Tools.Web.Tavily.apiKeys
for i, key := range keys {
resolved, err := cr.Resolve(key)
if err != nil {
return fmt.Errorf("tavily api_keys[%d]: %w", i, err)
}
keys[i] = resolved
}
}
if len(cfg.Tools.Web.Perplexity.apiKeys) > 0 {
keys := cfg.Tools.Web.Perplexity.apiKeys
for i, key := range keys {
resolved, err := cr.Resolve(key)
if err != nil {
return fmt.Errorf("perplexity api_keys[%d]: %w", i, err)
}
keys[i] = resolved
}
}
// GLMSearch has a private apiKey field
if cfg.Tools.Web.GLMSearch.apiKey != "" {
resolved, err := cr.Resolve(cfg.Tools.Web.GLMSearch.apiKey)
if err != nil {
return fmt.Errorf("glm api_key: %w", err)
}
cfg.Tools.Web.GLMSearch.apiKey = resolved
}
// Resolve Skills tokens
if cfg.Tools.Skills.Github.token != "" {
resolved, err := cr.Resolve(cfg.Tools.Skills.Github.token)
if err != nil {
return fmt.Errorf("github token: %w", err)
}
cfg.Tools.Skills.Github.token = resolved
}
if cfg.Tools.Skills.Registries.ClawHub.authToken != "" {
resolved, err := cr.Resolve(cfg.Tools.Skills.Registries.ClawHub.authToken)
if err != nil {
return fmt.Errorf("clawhub auth_token: %w", err)
}
cfg.Tools.Skills.Registries.ClawHub.authToken = resolved
}
return nil
}
// expandMultiKeyModels expands ModelConfig entries with multiple API keys into
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
// and the original entry's fallbacks are set up to chain through the expanded entries.
//
// Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]}
// Becomes:
// - {"model_name": "gpt-4", "api_keys": ["k1"], "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]}
// - {"model_name": "gpt-4__key_1", "api_keys": {"k2"}}
// - {"model_name": "gpt-4__key_2", "api_keys": {"k3"}}
func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
var expanded []*ModelConfig
for _, m := range models {
keys := MergeAPIKeys("", m.apiKeys)
// Single key or no keys: keep as-is
if len(keys) <= 1 {
m.apiKeys = keys
expanded = append(expanded, m)
continue
}
// Multiple keys: expand
originalName := m.ModelName
// Create entries for additional keys (key_1, key_2, ...)
var fallbackNames []string
for i := 1; i < len(keys); i++ {
suffix := fmt.Sprintf("__key_%d", i)
expandedName := originalName + suffix
// Create a copy for the additional key
additionalEntry := &ModelConfig{
ModelName: expandedName,
Model: m.Model,
APIBase: m.APIBase,
apiKeys: []string{keys[i]},
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody,
}
expanded = append(expanded, additionalEntry)
fallbackNames = append(fallbackNames, expandedName)
}
// Create the primary entry with first key and fallbacks
primaryEntry := &ModelConfig{
ModelName: originalName,
Model: m.Model,
APIBase: m.APIBase,
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody,
apiKeys: []string{keys[0]},
}
// Prepend new fallbacks to existing ones
if len(fallbackNames) > 0 {
primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...)
} else if len(m.Fallbacks) > 0 {
primaryEntry.Fallbacks = m.Fallbacks
}
expanded = append(expanded, primaryEntry)
}
return expanded
}
func (t *ToolsConfig) IsToolEnabled(name string) bool {
switch name {
case "web":
return t.Web.Enabled
case "cron":
return t.Cron.Enabled
case "exec":
return t.Exec.Enabled
case "skills":
return t.Skills.Enabled
case "media_cleanup":
return t.MediaCleanup.Enabled
case "append_file":
return t.AppendFile.Enabled
case "edit_file":
return t.EditFile.Enabled
case "find_skills":
return t.FindSkills.Enabled
case "i2c":
return t.I2C.Enabled
case "install_skill":
return t.InstallSkill.Enabled
case "list_dir":
return t.ListDir.Enabled
case "message":
return t.Message.Enabled
case "read_file":
return t.ReadFile.Enabled
case "spawn":
return t.Spawn.Enabled
case "spawn_status":
return t.SpawnStatus.Enabled
case "spi":
return t.SPI.Enabled
case "subagent":
return t.Subagent.Enabled
case "web_fetch":
return t.WebFetch.Enabled
case "send_file":
return t.SendFile.Enabled
case "write_file":
return t.WriteFile.Enabled
case "mcp":
return t.MCP.Enabled
default:
return true
}
}