diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index 4bf132685..531cb76aa 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -56,9 +56,6 @@ func authLoginOpenAI(useDeviceCode bool) error { appCfg, err := internal.LoadConfig() if err == nil { - // Update Providers (legacy format) - appCfg.Providers.OpenAI.AuthMethod = "oauth" - // Update or add openai in ModelList foundOpenAI := false for i := range appCfg.ModelList { @@ -71,7 +68,7 @@ func authLoginOpenAI(useDeviceCode bool) error { // If no openai in ModelList, add it if !foundOpenAI { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ ModelName: "gpt-5.4", Model: "openai/gpt-5.4", AuthMethod: "oauth", @@ -130,9 +127,6 @@ func authLoginGoogleAntigravity() error { appCfg, err := internal.LoadConfig() if err == nil { - // Update Providers (legacy format, for backward compatibility) - appCfg.Providers.Antigravity.AuthMethod = "oauth" - // Update or add antigravity in ModelList foundAntigravity := false for i := range appCfg.ModelList { @@ -145,7 +139,7 @@ func authLoginGoogleAntigravity() error { // If no antigravity in ModelList, add it if !foundAntigravity { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ ModelName: "gemini-flash", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth", @@ -210,8 +204,6 @@ func authLoginAnthropicSetupToken() error { appCfg, err := internal.LoadConfig() if err == nil { - appCfg.Providers.Anthropic.AuthMethod = "oauth" - found := false for i := range appCfg.ModelList { if isAnthropicModel(appCfg.ModelList[i].Model) { @@ -221,7 +213,7 @@ func authLoginAnthropicSetupToken() error { } } if !found { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ ModelName: defaultAnthropicModel, Model: "anthropic/" + defaultAnthropicModel, AuthMethod: "oauth", @@ -287,7 +279,6 @@ func authLoginPasteToken(provider string) error { if err == nil { switch provider { case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "token" // Update ModelList found := false for i := range appCfg.ModelList { @@ -298,7 +289,7 @@ func authLoginPasteToken(provider string) error { } } if !found { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ ModelName: defaultAnthropicModel, Model: "anthropic/" + defaultAnthropicModel, AuthMethod: "token", @@ -306,7 +297,6 @@ func authLoginPasteToken(provider string) error { appCfg.Agents.Defaults.ModelName = defaultAnthropicModel } case "openai": - appCfg.Providers.OpenAI.AuthMethod = "token" // Update ModelList found := false for i := range appCfg.ModelList { @@ -317,7 +307,7 @@ func authLoginPasteToken(provider string) error { } } if !found { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ ModelName: "gpt-5.4", Model: "openai/gpt-5.4", AuthMethod: "token", @@ -365,15 +355,6 @@ func authLogoutCmd(provider string) error { } } } - // Clear AuthMethod in Providers (legacy) - switch provider { - case "openai": - appCfg.Providers.OpenAI.AuthMethod = "" - case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "" - case "google-antigravity", "antigravity": - appCfg.Providers.Antigravity.AuthMethod = "" - } config.SaveConfig(internal.GetConfigPath(), appCfg) } @@ -392,10 +373,6 @@ func authLogoutCmd(provider string) error { for i := range appCfg.ModelList { appCfg.ModelList[i].AuthMethod = "" } - // Clear all AuthMethods in Providers (legacy) - appCfg.Providers.OpenAI.AuthMethod = "" - appCfg.Providers.Anthropic.AuthMethod = "" - appCfg.Providers.Antigravity.AuthMethod = "" config.SaveConfig(internal.GetConfigPath(), appCfg) } diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go index 0f45e7425..17de88ccb 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -4,11 +4,12 @@ import ( "os" "path/filepath" + "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" ) -const Logo = "🦞" +const Logo = pkg.Logo // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw @@ -17,7 +18,7 @@ func GetPicoclawHome() string { return home } home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw") + return filepath.Join(home, pkg.DefaultPicoClawHome) } func GetConfigPath() string { diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go index 583751781..953da8886 100644 --- a/cmd/picoclaw/internal/helpers_test.go +++ b/cmd/picoclaw/internal/helpers_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestGetConfigPath(t *testing.T) { @@ -20,7 +22,7 @@ func TestGetConfigPath(t *testing.T) { } func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) { - t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") + t.Setenv(config.EnvHome, "/custom/picoclaw") t.Setenv("HOME", "/tmp/home") got := GetConfigPath() @@ -31,7 +33,7 @@ func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) { func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) { t.Setenv("PICOCLAW_CONFIG", "/custom/config.json") - t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") + t.Setenv(config.EnvHome, "/custom/picoclaw") t.Setenv("HOME", "/tmp/home") got := GetConfigPath() diff --git a/cmd/picoclaw/internal/model/command.go b/cmd/picoclaw/internal/model/command.go index cad106fd5..314259d0f 100644 --- a/cmd/picoclaw/internal/model/command.go +++ b/cmd/picoclaw/internal/model/command.go @@ -56,9 +56,6 @@ Note: 'local-model' is a special value for using a local VLLM server func showCurrentModel(cfg *config.Config) { defaultModel := cfg.Agents.Defaults.ModelName - if defaultModel == "" { - defaultModel = cfg.Agents.Defaults.Model - } if defaultModel == "" { fmt.Println("No default model is currently set.") @@ -78,16 +75,13 @@ func listAvailableModels(cfg *config.Config) { } defaultModel := cfg.Agents.Defaults.ModelName - if defaultModel == "" { - defaultModel = cfg.Agents.Defaults.Model - } for _, model := range cfg.ModelList { marker := " " if model.ModelName == defaultModel { marker = "> " } - if model.APIKey == "" { + if model.APIKey() == "" { continue } fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model) @@ -98,7 +92,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er // Validate that the model exists in model_list modelFound := false for _, model := range cfg.ModelList { - if model.APIKey != "" && model.ModelName == modelName { + if model.APIKey() != "" && model.ModelName == modelName { modelFound = true break } @@ -111,12 +105,8 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er // Update the default model // Clear old model field and set new model_name oldModel := cfg.Agents.Defaults.ModelName - if oldModel == "" { - oldModel = cfg.Agents.Defaults.Model - } cfg.Agents.Defaults.ModelName = modelName - cfg.Agents.Defaults.Model = "" // Clear deprecated field // Save config back to file if err := config.SaveConfig(configPath, cfg); err != nil { diff --git a/cmd/picoclaw/internal/model/command_test.go b/cmd/picoclaw/internal/model/command_test.go index 82943e4a6..6cbbf0b55 100644 --- a/cmd/picoclaw/internal/model/command_test.go +++ b/cmd/picoclaw/internal/model/command_test.go @@ -58,17 +58,24 @@ func TestNewModelCommand(t *testing.T) { } func TestShowCurrentModel_WithDefaultModel(t *testing.T) { - cfg := &config.Config{ + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "gpt-4", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"}, - {ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"}, + ModelList: []*config.ModelConfig{ + {ModelName: "gpt-4", Model: "openai/gpt-4"}, + {ModelName: "claude-3", Model: "anthropic/claude-3"}, }, - } + }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "gpt-4": { + APIKeys: []string{"test"}, + }, + "claude-3": { + APIKeys: []string{"test"}, + }, + }}) output := captureStdout(func() { showCurrentModel(cfg) @@ -81,17 +88,20 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) { } func TestShowCurrentModel_NoDefaultModel(t *testing.T) { - cfg := &config.Config{ + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "", - Model: "", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"}, + ModelList: []*config.ModelConfig{ + {ModelName: "gpt-4", Model: "openai/gpt-4"}, }, - } + }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "gpt-4": { + APIKeys: []string{"test"}, + }, + }}) output := captureStdout(func() { showCurrentModel(cfg) @@ -101,26 +111,9 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) { assert.Contains(t, output, "Available models in your config:") } -func TestShowCurrentModel_BackwardCompatibility(t *testing.T) { - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Model: "legacy-model", - }, - }, - ModelList: []config.ModelConfig{}, - } - - output := captureStdout(func() { - showCurrentModel(cfg) - }) - - assert.Contains(t, output, "Current default model: legacy-model") -} - func TestListAvailableModels_Empty(t *testing.T) { cfg := &config.Config{ - ModelList: []config.ModelConfig{}, + ModelList: []*config.ModelConfig{}, } output := captureStdout(func() { @@ -131,18 +124,25 @@ func TestListAvailableModels_Empty(t *testing.T) { } func TestListAvailableModels_WithModels(t *testing.T) { - cfg := &config.Config{ + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "gpt-4", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"}, - {ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"}, - {ModelName: "no-key-model", Model: "openai/test", APIKey: ""}, + ModelList: []*config.ModelConfig{ + {ModelName: "gpt-4", Model: "openai/gpt-4"}, + {ModelName: "claude-3", Model: "anthropic/claude-3"}, + {ModelName: "no-key-model", Model: "openai/test"}, }, - } + }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "gpt-4": { + APIKeys: []string{"test"}, + }, + "claude-3": { + APIKeys: []string{"test"}, + }, + }}) output := captureStdout(func() { listAvailableModels(cfg) @@ -157,17 +157,24 @@ func TestListAvailableModels_WithModels(t *testing.T) { func TestSetDefaultModel_ValidModel(t *testing.T) { initTest(t) - cfg := &config.Config{ + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "old-model", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "new-model", Model: "openai/new-model", APIKey: "test"}, - {ModelName: "old-model", Model: "openai/old-model", APIKey: "test"}, + ModelList: []*config.ModelConfig{ + {ModelName: "new-model", Model: "openai/new-model"}, + {ModelName: "old-model", Model: "openai/old-model"}, }, - } + }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "new-model": { + APIKeys: []string{"test"}, + }, + "old-model": { + APIKeys: []string{"test"}, + }, + }}) output := captureStdout(func() { err := setDefaultModel(configPath, cfg, "new-model") @@ -180,44 +187,25 @@ func TestSetDefaultModel_ValidModel(t *testing.T) { updatedCfg, err := config.LoadConfig(configPath) require.NoError(t, err) assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName) - assert.Empty(t, updatedCfg.Agents.Defaults.Model) -} - -func TestSetDefaultModel_LegacyModelField(t *testing.T) { - initTest(t) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Model: "legacy-old", - }, - }, - ModelList: []config.ModelConfig{ - {ModelName: "new-model", Model: "openai/new-model", APIKey: "test"}, - }, - } - - output := captureStdout(func() { - err := setDefaultModel(configPath, cfg, "new-model") - assert.NoError(t, err) - }) - - assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'") } func TestSetDefaultModel_InvalidModel(t *testing.T) { initTest(t) - cfg := &config.Config{ + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "existing-model", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "existing-model", Model: "openai/existing", APIKey: "test"}, + ModelList: []*config.ModelConfig{ + {ModelName: "existing-model", Model: "openai/existing"}, }, - } + }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "existing-model": { + APIKeys: []string{"test"}, + }, + }}) assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model")) } @@ -225,17 +213,24 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) { func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) { initTest(t) - cfg := &config.Config{ + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "existing-model", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "existing-model", Model: "openai/existing", APIKey: "test"}, - {ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""}, + ModelList: []*config.ModelConfig{ + {ModelName: "existing-model", Model: "openai/existing"}, + {ModelName: "no-key-model", Model: "openai/nokey"}, }, - } + }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "existing-model": { + APIKeys: []string{"test"}, + }, + "no-key-model": { + APIKeys: []string{""}, + }, + }}) assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model")) } @@ -244,16 +239,20 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) { // Use an invalid path to trigger save error invalidPath := "/nonexistent/directory/config.json" - cfg := &config.Config{ + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "old-model", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "new-model", Model: "openai/new-model", APIKey: "test"}, + ModelList: []*config.ModelConfig{ + {ModelName: "new-model", Model: "openai/new-model"}, }, - } + }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "new-model": { + APIKeys: []string{"test"}, + }, + }}) err := setDefaultModel(invalidPath, cfg, "new-model") @@ -285,16 +284,20 @@ func TestModelCommandExecution_Show(t *testing.T) { initTest(t) // Create a test config - cfg := &config.Config{ + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "test-model", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "test-model", Model: "openai/test", APIKey: "test"}, + ModelList: []*config.ModelConfig{ + {ModelName: "test-model", Model: "openai/test"}, }, - } + }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "test-model": { + APIKeys: []string{"test"}, + }, + }}) err := config.SaveConfig(configPath, cfg) require.NoError(t, err) @@ -312,17 +315,25 @@ func TestModelCommandExecution_Show(t *testing.T) { func TestModelCommandExecution_Set(t *testing.T) { initTest(t) - cfg := &config.Config{ + sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "old-model": { + APIKeys: []string{"test"}, + }, + "new-model": { + APIKeys: []string{"test"}, + }, + }} + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "old-model", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "old-model", Model: "openai/old", APIKey: "test"}, - {ModelName: "new-model", Model: "openai/new", APIKey: "test"}, + ModelList: []*config.ModelConfig{ + {ModelName: "old-model", Model: "openai/old"}, + {ModelName: "new-model", Model: "openai/new"}, }, - } + }).WithSecurity(sec) err := config.SaveConfig(configPath, cfg) require.NoError(t, err) @@ -346,18 +357,28 @@ func TestModelCommandExecution_TooManyArgs(t *testing.T) { } func TestListAvailableModels_MarkerLogic(t *testing.T) { - cfg := &config.Config{ + cfg := (&config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "middle-model", }, }, - ModelList: []config.ModelConfig{ - {ModelName: "first-model", Model: "openai/first", APIKey: "test"}, - {ModelName: "middle-model", Model: "openai/middle", APIKey: "test"}, - {ModelName: "last-model", Model: "openai/last", APIKey: "test"}, + ModelList: []*config.ModelConfig{ + {ModelName: "first-model", Model: "openai/first"}, + {ModelName: "middle-model", Model: "openai/middle"}, + {ModelName: "last-model", Model: "openai/last"}, }, - } + }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "first-model": { + APIKeys: []string{"test"}, + }, + "middle-model": { + APIKeys: []string{"test"}, + }, + "last-model": { + APIKeys: []string{"test"}, + }, + }}) output := captureStdout(func() { listAvailableModels(cfg) diff --git a/cmd/picoclaw/internal/onboard/weixin.go b/cmd/picoclaw/internal/onboard/weixin.go index 721b4f0e9..2e1c2ad75 100644 --- a/cmd/picoclaw/internal/onboard/weixin.go +++ b/cmd/picoclaw/internal/onboard/weixin.go @@ -96,7 +96,7 @@ func saveWeixinConfig(token, baseURL, proxy string) error { } cfg.Channels.Weixin.Enabled = true - cfg.Channels.Weixin.Token = token + cfg.Channels.Weixin.SetToken(token) const defaultBase = "https://ilinkai.weixin.qq.com/" if baseURL != "" && baseURL != defaultBase { cfg.Channels.Weixin.BaseURL = baseURL diff --git a/cmd/picoclaw/internal/skills/command.go b/cmd/picoclaw/internal/skills/command.go index 8c666b810..4f64ef3f9 100644 --- a/cmd/picoclaw/internal/skills/command.go +++ b/cmd/picoclaw/internal/skills/command.go @@ -31,7 +31,7 @@ func NewSkillsCommand() *cobra.Command { d.workspace = cfg.WorkspacePath() installer, err := skills.NewSkillInstaller( d.workspace, - cfg.Tools.Skills.Github.Token, + cfg.Tools.Skills.Github.Token(), cfg.Tools.Skills.Github.Proxy, ) if err != nil { diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index a59a2013a..a246f7da5 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -64,9 +64,20 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName) + clawHubConfig := cfg.Tools.Skills.Registries.ClawHub registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + ClawHub: skills.ClawHubConfig{ + Enabled: clawHubConfig.Enabled, + BaseURL: clawHubConfig.BaseURL, + AuthToken: clawHubConfig.AuthToken(), + SearchPath: clawHubConfig.SearchPath, + SkillsPath: clawHubConfig.SkillsPath, + DownloadPath: clawHubConfig.DownloadPath, + Timeout: clawHubConfig.Timeout, + MaxZipSize: clawHubConfig.MaxZipSize, + MaxResponseSize: clawHubConfig.MaxResponseSize, + }, }) registry := registryMgr.GetRegistry(registryName) @@ -226,9 +237,20 @@ func skillsSearchCmd(query string) { return } + clawHubConfig := cfg.Tools.Skills.Registries.ClawHub registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + ClawHub: skills.ClawHubConfig{ + Enabled: clawHubConfig.Enabled, + BaseURL: clawHubConfig.BaseURL, + AuthToken: clawHubConfig.AuthToken(), + SearchPath: clawHubConfig.SearchPath, + SkillsPath: clawHubConfig.SkillsPath, + DownloadPath: clawHubConfig.DownloadPath, + Timeout: clawHubConfig.Timeout, + MaxZipSize: clawHubConfig.MaxZipSize, + MaxResponseSize: clawHubConfig.MaxResponseSize, + }, }) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go index dd7063fe6..43c5786a8 100644 --- a/cmd/picoclaw/internal/status/helpers.go +++ b/cmd/picoclaw/internal/status/helpers.go @@ -42,48 +42,6 @@ func statusCmd() { if _, err := os.Stat(configPath); err == nil { fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName()) - hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" - hasAnthropic := cfg.Providers.Anthropic.APIKey != "" - hasOpenAI := cfg.Providers.OpenAI.APIKey != "" - hasGemini := cfg.Providers.Gemini.APIKey != "" - hasZhipu := cfg.Providers.Zhipu.APIKey != "" - hasQwen := cfg.Providers.Qwen.APIKey != "" - hasGroq := cfg.Providers.Groq.APIKey != "" - hasVLLM := cfg.Providers.VLLM.APIBase != "" - hasMoonshot := cfg.Providers.Moonshot.APIKey != "" - hasDeepSeek := cfg.Providers.DeepSeek.APIKey != "" - hasVolcEngine := cfg.Providers.VolcEngine.APIKey != "" - hasNvidia := cfg.Providers.Nvidia.APIKey != "" - hasOllama := cfg.Providers.Ollama.APIBase != "" - - status := func(enabled bool) string { - if enabled { - return "✓" - } - return "not set" - } - fmt.Println("OpenRouter API:", status(hasOpenRouter)) - fmt.Println("Anthropic API:", status(hasAnthropic)) - fmt.Println("OpenAI API:", status(hasOpenAI)) - fmt.Println("Gemini API:", status(hasGemini)) - fmt.Println("Zhipu API:", status(hasZhipu)) - fmt.Println("Qwen API:", status(hasQwen)) - fmt.Println("Groq API:", status(hasGroq)) - fmt.Println("Moonshot API:", status(hasMoonshot)) - fmt.Println("DeepSeek API:", status(hasDeepSeek)) - fmt.Println("VolcEngine API:", status(hasVolcEngine)) - fmt.Println("Nvidia API:", status(hasNvidia)) - if hasVLLM { - fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase) - } else { - fmt.Println("vLLM/Local: not set") - } - if hasOllama { - fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase) - } else { - fmt.Println("Ollama: not set") - } - store, _ := auth.LoadStore() if store != nil && len(store.Credentials) > 0 { fmt.Println("\nOAuth/Token Auth:") diff --git a/docs/config-versioning.md b/docs/config-versioning.md new file mode 100644 index 000000000..36d7fdd25 --- /dev/null +++ b/docs/config-versioning.md @@ -0,0 +1,230 @@ +# Config Schema Versioning Guide + +## Overview + +PicoClaw uses a schema versioning system for `config.json` to ensure smooth upgrades as the configuration format evolves. + +## Version History + +### Version 1 +- **Introduction**: Initial version with version field support +- **Changes**: Added `version` field to Config struct +- **Migration**: No structural changes needed for existing configs + +## How It Works + +### Automatic Migration +When you load a config file: +1. The system first reads the `version` field from the JSON +2. Based on the detected version, it loads the appropriate config struct (`ConfigV0`, `ConfigV1`, etc.) +3. If the loaded version is less than the latest, migrations are applied incrementally +4. The version number is updated automatically +5. The migrated config is automatically saved back to disk + +### Version Field +The `version` field in `config.json` indicates the schema version: +- `0` or missing: Legacy config (no version field) +- `1`: Current version with versioning support + +```json +{ + "version": 1, + "agents": {...}, + ... +} +``` + +## Adding a New Migration + +When making breaking changes to the config schema: + +### Step 1: Define the New Version Struct + +Create a new struct for the new version if the structure changes significantly: + +```go +// ConfigV2 represents version 2 config structure +type ConfigV2 struct { + Version int `json:"version"` + Agents AgentsConfig `json:"agents"` + // ... other fields with new structure +} +``` + +### Step 2: Update Current Config Version + +```go +const CurrentConfigVersion = 2 // Increment this +``` + +### Step 3: Add a Loader Function + +```go +// loadConfigV2 loads a version 2 config +func loadConfigV2(data []byte) (*Config, error) { + cfg := DefaultConfig() + + // Parse to ConfigV2 struct + var v2 ConfigV2 + if err := json.Unmarshal(data, &v2); err != nil { + return nil, err + } + + // Convert to current Config + cfg.Version = v2.Version + cfg.Agents = v2.Agents + // ... map other fields + + return cfg, nil +} +``` + +### Step 4: Add Migration Logic + +```go +// applyMigration applies a single migration step from fromVersion to toVersion +func applyMigration(cfg *Config, fromVersion, toVersion int) (*Config, error) { + switch toVersion { + case 1: + // Migration from version 0 to 1 + return &Config{ + Version: 1, + Agents: cfg.Agents, + // ... copy all fields + }, nil + case 2: + // Migration from version 1 to 2 + // Example: Move or rename fields + migrated := *cfg + migrated.Version = 2 + // Apply structural changes + if cfg.SomeOldField != "" { + migrated.SomeNewField = cfg.SomeOldField + } + return &migrated, nil + default: + return nil, fmt.Errorf("unsupported migration target version: %d", toVersion) + } +} +``` + +### Step 5: Update LoadConfig Switch + +```go +func LoadConfig(path string) (*Config, error) { + // ... read file ... + + switch versionInfo.Version { + case 0: + cfg, err = loadConfigV0(data) + case 1: + cfg, err = loadConfigV1(data) + case 2: + cfg, err = loadConfigV2(data) + default: + return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) + } + + // ... migrate and validate ... +} +``` + +### Step 6: Test Your Migration + +Create a test in `config_migration_test.go`: + +```go +func TestMigrateV1ToV2(t *testing.T) { + // Create a version 1 config + v1Config := Config{ + Version: 1, + // ... set up test data + } + + // Apply migration + migrated, err := applyMigration(&v1Config, 1, 2) + if err != nil { + t.Fatalf("Migration failed: %v", err) + } + + // Verify version is updated + if migrated.Version != 2 { + t.Errorf("Expected version 2, got %d", migrated.Version) + } + + // Verify data is preserved/transformed correctly + // ... +} +``` + +## Migration Best Practices + +1. **Version-Specific Structs**: Define a separate struct for each version that has structural changes +2. **Backward Compatibility**: Ensure old configs can still be loaded with their specific structs +3. **No Data Loss**: Migrations should preserve all user settings +4. **Idempotent**: Running the same migration multiple times should be safe +5. **Auto-Save**: Migrated configs are automatically saved to update the user's file +6. **Test Thoroughly**: Test with real user config files +7. **Update Defaults**: Keep `defaults.go` in sync with the latest schema + +## Example Migration + +### Scenario: Adding a new field with default value + +Old config (version 1): +```json +{ + "version": 1, + "agents": { + "defaults": { + "max_tokens": 32768 + } + } +} +``` + +Migration to version 2: +```go +case 2: + migrated := *cfg + migrated.Version = 2 + + // Add new field with default value if not set + if migrated.Agents.Defaults.NewFeatureEnabled == false { + // Use default value + } + + return &migrated, nil +``` + +New config (version 2): +```json +{ + "version": 2, + "agents": { + "defaults": { + "max_tokens": 32768, + "new_feature_enabled": false + } + } +} +``` + +## Troubleshooting + +### Config Not Upgrading +- Check that `CurrentConfigVersion` is incremented +- Verify migration logic in `applyMigration()` handles the target version +- Ensure `migrateConfig()` is called in `LoadConfig()` + +### Migration Errors +- Check error messages for specific migration failures +- Review migration logic for edge cases +- Ensure all required fields are properly initialized +- Verify the loader function for the source version + +### Data Loss After Migration +- Ensure all fields are copied during migration +- Check that the migration doesn't overwrite values with defaults unnecessarily +- Review the conversion logic in the loader functions + diff --git a/go.mod b/go.mod index cfc930d37..d283f7f5e 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/sipeed/picoclaw go 1.25.8 require ( - github.com/BurntSushi/toml v1.6.0 fyne.io/systray v1.12.0 + github.com/BurntSushi/toml v1.6.0 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/bwmarrin/discordgo v0.29.0 diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 022230d41..36ee5dce4 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -59,7 +60,7 @@ func getGlobalConfigDir() string { if err != nil { return "" } - return filepath.Join(home, ".picoclaw") + return filepath.Join(home, pkg.DefaultPicoClawHome) } func NewContextBuilder(workspace string) *ContextBuilder { diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 9acc6ddd8..19a1ea9eb 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -109,7 +109,7 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -228,7 +228,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -353,7 +353,7 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -443,7 +443,7 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, ContextWindow: 8000, @@ -500,7 +500,7 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, diff --git a/pkg/agent/hook_mount_test.go b/pkg/agent/hook_mount_test.go index a9d8f27c5..85d8f5c11 100644 --- a/pkg/agent/hook_mount_test.go +++ b/pkg/agent/hook_mount_test.go @@ -47,7 +47,7 @@ func newConfiguredHookLoop(t *testing.T, provider *llmHookTestProvider, hooks co Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: t.TempDir(), - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index e6471e9cc..49e1b1784 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -28,7 +28,7 @@ func newHookTestLoop( Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index b3318ad1f..e073cb929 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -22,7 +22,7 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 1234, MaxToolIterations: 5, }, @@ -54,7 +54,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 1234, MaxToolIterations: 5, }, @@ -83,7 +83,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 1234, MaxToolIterations: 5, }, @@ -137,10 +137,10 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: tt.aliasName, + ModelName: tt.aliasName, }, }, - ModelList: []config.ModelConfig{ + ModelList: []*config.ModelConfig{ { ModelName: tt.aliasName, Model: tt.modelName, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 354b8865e..c837d8d70 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -161,30 +161,33 @@ func registerSharedTools( if cfg.Tools.IsToolEnabled("web") { searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys), - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys), + BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey(), cfg.Tools.Web.Brave.APIKeys()), + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKeys: config.MergeAPIKeys( + cfg.Tools.Web.Tavily.APIKey(), + cfg.Tools.Web.Tavily.APIKeys(), + ), TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, PerplexityAPIKeys: config.MergeAPIKeys( - cfg.Tools.Web.Perplexity.APIKey, - cfg.Tools.Web.Perplexity.APIKeys, + cfg.Tools.Web.Perplexity.APIKey(), + cfg.Tools.Web.Perplexity.APIKeys(), ), PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, - GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey, + GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey(), GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, - BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey, + BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey(), BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled, @@ -250,9 +253,20 @@ func registerSharedTools( find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") if skills_enabled && (find_skills_enable || install_skills_enable) { + clawHubConfig := cfg.Tools.Skills.Registries.ClawHub registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + ClawHub: skills.ClawHubConfig{ + Enabled: clawHubConfig.Enabled, + BaseURL: clawHubConfig.BaseURL, + AuthToken: clawHubConfig.AuthToken(), + SearchPath: clawHubConfig.SearchPath, + SkillsPath: clawHubConfig.SkillsPath, + DownloadPath: clawHubConfig.DownloadPath, + Timeout: clawHubConfig.Timeout, + MaxZipSize: clawHubConfig.MaxZipSize, + MaxResponseSize: clawHubConfig.MaxResponseSize, + }, }) if find_skills_enable { diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 71f2d15e4..6cc5fe981 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -67,7 +67,7 @@ func newTestAgentLoop( Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -90,7 +90,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -179,7 +179,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -215,7 +215,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -272,7 +272,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -308,7 +308,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Workspace = tmpDir - cfg.Agents.Defaults.Model = "test-model" + cfg.Agents.Defaults.ModelName = "test-model" cfg.Agents.Defaults.MaxTokens = 4096 cfg.Agents.Defaults.MaxToolIterations = 10 @@ -352,7 +352,7 @@ func TestAgentLoop_Stop(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -558,7 +558,7 @@ func TestProcessMessage_UsesRouteSessionKey(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -614,7 +614,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -694,26 +694,34 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { Defaults: config.AgentDefaults{ Workspace: tmpDir, Provider: "openai", - Model: "local", + ModelName: "local", MaxTokens: 4096, MaxToolIterations: 10, }, }, - ModelList: []config.ModelConfig{ + ModelList: []*config.ModelConfig{ { ModelName: "local", Model: "openai/local-model", - APIKey: "test-key", APIBase: "https://local.example.invalid/v1", }, { ModelName: "deepseek", Model: "openrouter/deepseek/deepseek-v3.2", - APIKey: "test-key", APIBase: "https://openrouter.ai/api/v1", }, }, } + cfg.WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "local": { + APIKeys: []string{"test-key"}, + }, + "deepseek": { + APIKeys: []string{"test-key"}, + }, + }, + }) msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} @@ -765,20 +773,26 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { Defaults: config.AgentDefaults{ Workspace: tmpDir, Provider: "openai", - Model: "local", + ModelName: "local", MaxTokens: 4096, MaxToolIterations: 10, }, }, - ModelList: []config.ModelConfig{ + ModelList: []*config.ModelConfig{ { ModelName: "local", Model: "openai/local-model", - APIKey: "test-key", APIBase: "https://local.example.invalid/v1", }, }, } + cfg.WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "local": { + APIKeys: []string{"test-key"}, + }, + }, + }) msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} @@ -840,26 +854,34 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t Defaults: config.AgentDefaults{ Workspace: tmpDir, Provider: "openai", - Model: "local", + ModelName: "local", MaxTokens: 4096, MaxToolIterations: 10, }, }, - ModelList: []config.ModelConfig{ + ModelList: []*config.ModelConfig{ { ModelName: "local", Model: "openai/Qwen3.5-35B-A3B", - APIKey: "local-key", APIBase: localServer.URL, }, { ModelName: "deepseek", Model: "openrouter/deepseek/deepseek-v3.2", - APIKey: "remote-key", APIBase: remoteServer.URL, }, }, } + cfg.WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "local": { + APIKeys: []string{"local-key"}, + }, + "deepseek": { + APIKeys: []string{"remote-key"}, + }, + }, + }) msgBus := bus.NewMessageBus() provider, _, err := providers.CreateProvider(cfg) @@ -946,7 +968,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -988,7 +1010,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -1059,7 +1081,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -1139,7 +1161,7 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 3, }, @@ -1170,7 +1192,7 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 1, }, @@ -1227,7 +1249,7 @@ func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -1279,7 +1301,7 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -1349,7 +1371,7 @@ func TestHandleReasoning(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index 518bb441f..b173ef967 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -29,7 +29,7 @@ func testCfg(agents []config.AgentConfig) *config.Config { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: "/tmp/picoclaw-test-registry", - Model: "gpt-4", + ModelName: "gpt-4", MaxTokens: 8192, MaxToolIterations: 10, }, diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index fe4863f05..75ba9861d 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -267,7 +267,7 @@ func TestAgentLoop_SteeringMode_ConfiguredFromConfig(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, SteeringMode: "all", @@ -318,7 +318,7 @@ func TestAgentLoop_Continue_WithMessages(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -351,7 +351,7 @@ func TestDrainBusToSteering_RequeuesDifferentScopeMessage(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -646,7 +646,7 @@ func TestAgentLoop_Steering_SkipsRemainingTools(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -751,7 +751,7 @@ func TestAgentLoop_Steering_InitialPoll(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -818,7 +818,7 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -942,7 +942,7 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing. Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -1024,7 +1024,7 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -1127,7 +1127,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -1295,7 +1295,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -1454,7 +1454,7 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index bac786eb3..6a2ba835d 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -844,7 +844,7 @@ func TestSpawnSubTurn_PanicRecovery(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: t.TempDir(), - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -938,8 +938,8 @@ func TestGetActiveTurn(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Model: "gpt-4o-mini", - Provider: "mock", + ModelName: "gpt-4o-mini", + Provider: "mock", }, }, } @@ -996,8 +996,8 @@ func TestGetActiveTurn_WithChildren(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Model: "gpt-4o-mini", - Provider: "mock", + ModelName: "gpt-4o-mini", + Provider: "mock", }, }, } @@ -1077,8 +1077,8 @@ func TestInjectFollowUp(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Model: "gpt-4o-mini", - Provider: "mock", + ModelName: "gpt-4o-mini", + Provider: "mock", }, }, } @@ -1106,8 +1106,8 @@ func TestAPIAliases(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Model: "gpt-4o-mini", - Provider: "mock", + ModelName: "gpt-4o-mini", + Provider: "mock", }, }, } @@ -1145,8 +1145,8 @@ func TestInterruptHard_Alias(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Model: "gpt-4o-mini", - Provider: "mock", + ModelName: "gpt-4o-mini", + Provider: "mock", }, }, } diff --git a/pkg/auth/store.go b/pkg/auth/store.go index f7813ca57..8a878d553 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -6,6 +6,7 @@ import ( "path/filepath" "time" + "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -44,7 +45,7 @@ func authFilePath() string { return filepath.Join(home, "auth.json") } home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw", "auth.json") + return filepath.Join(home, pkg.DefaultPicoClawHome, "auth.json") } func LoadStore() (*AuthStore, error) { diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index c03122892..7ac2c073f 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -36,7 +36,7 @@ type DingTalkChannel struct { // NewDingTalkChannel creates a new DingTalk channel instance func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) { - if cfg.ClientID == "" || cfg.ClientSecret == "" { + if cfg.ClientID == "" || cfg.ClientSecret() == "" { return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } @@ -53,7 +53,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( BaseChannel: base, config: cfg, clientID: cfg.ClientID, - clientSecret: cfg.ClientSecret, + clientSecret: cfg.ClientSecret(), }, nil } diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 297bfe89f..3b5b4f8bb 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -53,7 +53,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC discordgo.LogDebug: logger.DEBUG, }).Log - session, err := discordgo.New("Bot " + cfg.Token) + session, err := discordgo.New("Bot " + cfg.Token()) if err != nil { return nil, fmt.Errorf("failed to create discord session: %w", err) } diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index abc9291f6..0ab70649f 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -63,14 +63,14 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan BaseChannel: base, config: cfg, tokenCache: tc, - client: lark.NewClient(cfg.AppID, cfg.AppSecret, opts...), + client: lark.NewClient(cfg.AppID, cfg.AppSecret(), opts...), } ch.SetOwner(ch) return ch, nil } func (c *FeishuChannel) Start(ctx context.Context) error { - if c.config.AppID == "" || c.config.AppSecret == "" { + if c.config.AppID == "" || c.config.AppSecret() == "" { return fmt.Errorf("feishu app_id or app_secret is empty") } @@ -81,7 +81,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error { }) } - dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey). + dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken(), c.config.EncryptKey()). OnP2MessageReceiveV1(c.handleMessageReceive) runCtx, cancel := context.WithCancel(ctx) @@ -94,7 +94,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error { } c.wsClient = larkws.NewClient( c.config.AppID, - c.config.AppSecret, + c.config.AppSecret(), larkws.WithEventHandler(dispatcher), larkws.WithDomain(domain), ) diff --git a/pkg/channels/irc/handler.go b/pkg/channels/irc/handler.go index aca4ddd11..3fe9548f4 100644 --- a/pkg/channels/irc/handler.go +++ b/pkg/channels/irc/handler.go @@ -17,8 +17,8 @@ import ( // onConnect is called after a successful connection (and on reconnect). func (c *IRCChannel) onConnect(conn *ircevent.Connection) { // NickServ auth (only if SASL is not configured) - if c.config.NickServPassword != "" && c.config.SASLUser == "" { - conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword) + if c.config.NickServPassword() != "" && c.config.SASLUser == "" { + conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword()) } // Join configured channels diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go index 28c59b540..289ce2c9b 100644 --- a/pkg/channels/irc/irc.go +++ b/pkg/channels/irc/irc.go @@ -68,7 +68,7 @@ func (c *IRCChannel) Start(ctx context.Context) error { Nick: c.config.Nick, User: user, RealName: realName, - Password: c.config.Password, + Password: c.config.Password(), UseTLS: c.config.TLS, RequestCaps: caps, QuitMessage: "Goodbye", @@ -83,9 +83,9 @@ func (c *IRCChannel) Start(ctx context.Context) error { } // SASL auth (takes priority over NickServ) - if c.config.SASLUser != "" && c.config.SASLPassword != "" { + if c.config.SASLUser != "" && c.config.SASLPassword() != "" { conn.SASLLogin = c.config.SASLUser - conn.SASLPassword = c.config.SASLPassword + conn.SASLPassword = c.config.SASLPassword() } // Register event handlers diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index b2cdb6267..4eaadae70 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -62,7 +62,7 @@ type LINEChannel struct { // NewLINEChannel creates a new LINE channel instance. func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) { - if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" { + if cfg.ChannelSecret() == "" || cfg.ChannelAccessToken() == "" { return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } @@ -110,7 +110,7 @@ func (c *LINEChannel) fetchBotInfo() error { if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken()) resp, err := c.infoClient.Do(req) if err != nil { @@ -216,7 +216,7 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool { return false } - mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret)) + mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret())) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) @@ -655,7 +655,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken()) resp, err := c.apiClient.Do(req) if err != nil { @@ -680,7 +680,7 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string { return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "line", ExtraHeaders: map[string]string{ - "Authorization": "Bearer " + c.config.ChannelAccessToken, + "Authorization": "Bearer " + c.config.ChannelAccessToken(), }, }) } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index dd0b129e4..f04d989a3 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -319,7 +319,7 @@ func (m *Manager) initChannel(name, displayName string) { func (m *Manager) initChannels(channels *config.ChannelsConfig) error { logger.InfoC("channels", "Initializing channel manager") - if channels.Telegram.Enabled && channels.Telegram.Token != "" { + if channels.Telegram.Enabled && channels.Telegram.Token() != "" { m.initChannel("telegram", "Telegram") } @@ -336,7 +336,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("feishu", "Feishu") } - if channels.Discord.Enabled && channels.Discord.Token != "" { + if channels.Discord.Enabled && channels.Discord.Token() != "" { m.initChannel("discord", "Discord") } @@ -352,18 +352,18 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("dingtalk", "DingTalk") } - if channels.Slack.Enabled && channels.Slack.BotToken != "" { + if channels.Slack.Enabled && channels.Slack.BotToken() != "" { m.initChannel("slack", "Slack") } if channels.Matrix.Enabled && m.config.Channels.Matrix.Homeserver != "" && m.config.Channels.Matrix.UserID != "" && - m.config.Channels.Matrix.AccessToken != "" { + m.config.Channels.Matrix.AccessToken() != "" { m.initChannel("matrix", "Matrix") } - if channels.LINE.Enabled && channels.LINE.ChannelAccessToken != "" { + if channels.LINE.Enabled && channels.LINE.ChannelAccessToken() != "" { m.initChannel("line", "LINE") } @@ -371,13 +371,12 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("onebot", "OneBot") } - if channels.WeCom.Enabled && channels.WeCom.Token != "" { + if channels.WeCom.Enabled && channels.WeCom.Token() != "" { m.initChannel("wecom", "WeCom") } - if m.config.Channels.WeComAIBot.Enabled && - ((m.config.Channels.WeComAIBot.BotID != "" && m.config.Channels.WeComAIBot.Secret != "") || - m.config.Channels.WeComAIBot.Token != "") { + if channels.WeComAIBot.Enabled && (channels.WeComAIBot.Token() != "" || + (channels.WeComAIBot.Secret() != "" && channels.WeComAIBot.BotID != "")) { m.initChannel("wecom_aibot", "WeCom AI Bot") } @@ -385,11 +384,11 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("wecom_app", "WeCom App") } - if channels.Weixin.Enabled && channels.Weixin.Token != "" { + if channels.Weixin.Enabled && channels.Weixin.Token() != "" { m.initChannel("weixin", "Weixin") } - if channels.Pico.Enabled && channels.Pico.Token != "" { + if channels.Pico.Enabled && channels.Pico.Token() != "" { m.initChannel("pico", "Pico") } diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go index 57cb05412..86572e336 100644 --- a/pkg/channels/manager_channel.go +++ b/pkg/channels/manager_channel.go @@ -21,6 +21,7 @@ func toChannelHashes(cfg *config.Config) map[string]string { if !value["enabled"].(bool) { continue } + hiddenValues(key, value, ch) valueBytes, _ := json.Marshal(value) hash := md5.Sum(valueBytes) result[key] = hex.EncodeToString(hash[:]) @@ -29,6 +30,49 @@ func toChannelHashes(cfg *config.Config) map[string]string { return result } +func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) { + switch key { + case "pico": + value["token"] = ch.Pico.Token() + case "telegram": + value["token"] = ch.Telegram.Token() + case "discord": + value["token"] = ch.Discord.Token() + case "slack": + value["bot_token"] = ch.Slack.BotToken() + value["app_token"] = ch.Slack.AppToken() + case "matrix": + value["token"] = ch.Matrix.AccessToken() + case "onebot": + value["token"] = ch.OneBot.AccessToken() + case "line": + value["token"] = ch.LINE.ChannelAccessToken() + value["secret"] = ch.LINE.ChannelSecret() + case "wecom": + value["token"] = ch.WeCom.Token() + value["key"] = ch.WeCom.EncodingAESKey() + case "wecom_app": + value["token"] = ch.WeComApp.Token() + value["secret"] = ch.WeComApp.CorpSecret() + case "wecom_aibot": + value["token"] = ch.WeComAIBot.Token() + value["key"] = ch.WeComAIBot.EncodingAESKey() + value["secret"] = ch.WeComAIBot.Secret() + case "dingtalk": + value["secret"] = ch.QQ.AppSecret() + case "qq": + value["secret"] = ch.DingTalk.ClientSecret() + case "irc": + value["password"] = ch.IRC.Password() + value["serv_password"] = ch.IRC.NickServPassword() + value["sasl_password"] = ch.IRC.SASLPassword() + case "feishu": + value["app_secret"] = ch.Feishu.AppSecret() + value["encrypt_key"] = ch.Feishu.EncryptKey() + value["verification_token"] = ch.Feishu.VerificationToken() + } +} + func compareChannels(old, news map[string]string) (added, removed []string) { for key, newHash := range news { if oldHash, ok := old[key]; ok { @@ -82,5 +126,61 @@ func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig, return nil, err } + updateKeys(result, &ch) + return result, nil } + +func updateKeys(newcfg, old *config.ChannelsConfig) { + if newcfg.Pico.Enabled { + newcfg.Pico.SetToken(old.Pico.Token()) + } + if newcfg.Telegram.Enabled { + newcfg.Telegram.SetToken(old.Telegram.Token()) + } + if newcfg.Discord.Enabled { + newcfg.Discord.SetToken(old.Discord.Token()) + } + if newcfg.Slack.Enabled { + newcfg.Slack.SetBotToken(old.Slack.BotToken()) + newcfg.Slack.SetAppToken(old.Slack.AppToken()) + } + if newcfg.Matrix.Enabled { + newcfg.Matrix.SetAccessToken(old.Matrix.AccessToken()) + } + if newcfg.OneBot.Enabled { + newcfg.OneBot.SetAccessToken(old.OneBot.AccessToken()) + } + if newcfg.LINE.Enabled { + newcfg.LINE.SetChannelAccessToken(old.LINE.ChannelAccessToken()) + newcfg.LINE.SetChannelSecret(old.LINE.ChannelSecret()) + } + if newcfg.WeCom.Enabled { + newcfg.WeCom.SetToken(old.WeCom.Token()) + newcfg.WeCom.SetEncodingAESKey(old.WeCom.EncodingAESKey()) + } + if newcfg.WeComApp.Enabled { + newcfg.WeComApp.SetToken(old.WeComApp.Token()) + newcfg.WeComApp.SetCorpSecret(old.WeComApp.CorpSecret()) + } + if newcfg.WeComAIBot.Enabled { + newcfg.WeComAIBot.SetToken(old.WeComAIBot.Token()) + newcfg.WeComAIBot.SetEncodingAESKey(old.WeComAIBot.EncodingAESKey()) + } + if newcfg.DingTalk.Enabled { + newcfg.DingTalk.SetClientSecret(old.DingTalk.ClientSecret()) + } + if newcfg.QQ.Enabled { + newcfg.QQ.SetAppSecret(old.QQ.AppSecret()) + } + if newcfg.IRC.Enabled { + newcfg.IRC.SetPassword(old.IRC.Password()) + newcfg.IRC.SetNickServPassword(old.IRC.NickServPassword()) + newcfg.IRC.SetSASLPassword(old.IRC.SASLPassword()) + } + if newcfg.Feishu.Enabled { + newcfg.Feishu.SetAppSecret(old.Feishu.AppSecret()) + newcfg.Feishu.SetEncryptKey(old.Feishu.EncryptKey()) + newcfg.Feishu.SetVerificationToken(old.Feishu.VerificationToken()) + } +} diff --git a/pkg/channels/manager_channel_test.go b/pkg/channels/manager_channel_test.go index 651764c4f..e17dcf17d 100644 --- a/pkg/channels/manager_channel_test.go +++ b/pkg/channels/manager_channel_test.go @@ -31,7 +31,7 @@ func TestToChannelHashes(t *testing.T) { added, removed = compareChannels(results2, results3) assert.EqualValues(t, []string{"dingtalk"}, removed) assert.EqualValues(t, []string{"telegram"}, added) - cfg3.Channels.Telegram.Token = "114314" + cfg3.Channels.Telegram.SetToken("114314") results4 := toChannelHashes(cfg3) assert.Equal(t, 1, len(results4)) logger.Debugf("results4: %v", results4) @@ -41,11 +41,11 @@ func TestToChannelHashes(t *testing.T) { cc, err := toChannelConfig(cfg3, added) assert.NoError(t, err) logger.Debugf("cc: %#v", cc.Telegram) - assert.Equal(t, "114314", cc.Telegram.Token) + assert.Equal(t, "114314", cc.Telegram.Token()) assert.Equal(t, true, cc.Telegram.Enabled) cc, err = toChannelConfig(cfg2, added) assert.NoError(t, err) logger.Debugf("cc: %#v", cc.Telegram) - assert.Equal(t, "", cc.Telegram.Token) + assert.Equal(t, "", cc.Telegram.Token()) assert.Equal(t, false, cc.Telegram.Enabled) } diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index fa16dd414..98c607d0b 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -186,7 +186,7 @@ type MatrixChannel struct { func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) { homeserver := strings.TrimSpace(cfg.Homeserver) userID := strings.TrimSpace(cfg.UserID) - accessToken := strings.TrimSpace(cfg.AccessToken) + accessToken := strings.TrimSpace(cfg.AccessToken()) if homeserver == "" { return nil, fmt.Errorf("matrix homeserver is required") } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index b4bd1970c..048be48eb 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -184,8 +184,8 @@ func (c *OneBotChannel) connect() error { dialer.HandshakeTimeout = 10 * time.Second header := make(map[string][]string) - if c.config.AccessToken != "" { - header["Authorization"] = []string{"Bearer " + c.config.AccessToken} + if c.config.AccessToken() != "" { + header["Authorization"] = []string{"Bearer " + c.config.AccessToken()} } conn, resp, err := dialer.Dial(c.config.WSUrl, header) diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 77e7bbdb6..86ce98b06 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -64,7 +64,7 @@ type PicoChannel struct { // NewPicoChannel creates a new Pico Protocol channel. func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) { - if cfg.Token == "" { + if cfg.Token() == "" { return nil, fmt.Errorf("pico token is required") } @@ -297,7 +297,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { // 2. Sec-WebSocket-Protocol "token." (for browsers that can't set headers) // 3. Query parameter "token" (only when AllowTokenQuery is on) func (c *PicoChannel) authenticate(r *http.Request) bool { - token := c.config.Token + token := c.config.Token() if token == "" { return false } @@ -328,7 +328,7 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { // matchedSubprotocol returns the "token." subprotocol that matches // the configured token, or "" if none do. func (c *PicoChannel) matchedSubprotocol(r *http.Request) string { - token := c.config.Token + token := c.config.Token() for _, proto := range websocket.Subprotocols(r) { if after, ok := strings.CutPrefix(proto, "token."); ok && after == token { return proto diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 9daf24f93..cd66964dd 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -98,7 +98,7 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, } func (c *QQChannel) Start(ctx context.Context) error { - if c.config.AppID == "" || c.config.AppSecret == "" { + if c.config.AppID == "" || c.config.AppSecret() == "" { return fmt.Errorf("QQ app_id and app_secret not configured") } @@ -112,7 +112,7 @@ func (c *QQChannel) Start(ctx context.Context) error { // create token source credentials := &token.QQBotCredentials{ AppID: c.config.AppID, - AppSecret: c.config.AppSecret, + AppSecret: c.config.AppSecret(), } c.tokenSource = token.NewQQBotTokenSource(credentials) diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index f12c74cd7..f03283ea4 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -37,13 +37,13 @@ type slackMessageRef struct { } func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) { - if cfg.BotToken == "" || cfg.AppToken == "" { + if cfg.BotToken() == "" || cfg.AppToken() == "" { return nil, fmt.Errorf("slack bot_token and app_token are required") } api := slack.New( - cfg.BotToken, - slack.OptionAppLevelToken(cfg.AppToken), + cfg.BotToken(), + slack.OptionAppLevelToken(cfg.AppToken()), ) socketClient := socketmode.New(api) @@ -516,7 +516,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string { return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{ LoggerPrefix: "slack", ExtraHeaders: map[string]string{ - "Authorization": "Bearer " + c.config.BotToken, + "Authorization": "Bearer " + c.config.BotToken(), }, }) } diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go index 30e0d2d73..23a7ee5c4 100644 --- a/pkg/channels/slack/slack_test.go +++ b/pkg/channels/slack/slack_test.go @@ -102,10 +102,8 @@ func TestNewSlackChannel(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("missing bot token", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "", - AppToken: "xapp-test", - } + cfg := config.SlackConfig{} + cfg.SetAppToken("xapp-test") _, err := NewSlackChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing bot_token, got nil") @@ -113,10 +111,8 @@ func TestNewSlackChannel(t *testing.T) { }) t.Run("missing app token", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "", - } + cfg := config.SlackConfig{} + cfg.SetBotToken("xoxb-test") _, err := NewSlackChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing app_token, got nil") @@ -125,10 +121,10 @@ func TestNewSlackChannel(t *testing.T) { t.Run("valid config", func(t *testing.T) { cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "xapp-test", AllowFrom: []string{"U123"}, } + cfg.SetBotToken("xoxb-test") + cfg.SetAppToken("xapp-test") ch, err := NewSlackChannel(cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -147,10 +143,10 @@ func TestSlackChannelIsAllowed(t *testing.T) { t.Run("empty allowlist allows all", func(t *testing.T) { cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "xapp-test", AllowFrom: []string{}, } + cfg.SetBotToken("xoxb-test") + cfg.SetAppToken("xapp-test") ch, _ := NewSlackChannel(cfg, msgBus) if !ch.IsAllowed("U_ANYONE") { t.Error("empty allowlist should allow all users") @@ -159,10 +155,10 @@ func TestSlackChannelIsAllowed(t *testing.T) { t.Run("allowlist restricts users", func(t *testing.T) { cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "xapp-test", AllowFrom: []string{"U_ALLOWED"}, } + cfg.SetBotToken("xoxb-test") + cfg.SetAppToken("xapp-test") ch, _ := NewSlackChannel(cfg, msgBus) if !ch.IsAllowed("U_ALLOWED") { t.Error("allowed user should pass allowlist check") diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 18b034213..f62d6d008 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -83,7 +83,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann } opts = append(opts, telego.WithLogger(logger.NewLogger("telego"))) - bot, err := telego.NewBot(telegramCfg.Token, opts...) + bot, err := telego.NewBot(telegramCfg.Token(), opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) } diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 2264b8492..c5e148185 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -139,7 +139,7 @@ type WeComAIBotEncryptedResponse struct { } // NewWeComAIBotChannel creates a WeCom AI Bot channel instance. -// If cfg.BotID and cfg.Secret are both set, it returns a WeComAIBotWSChannel +// If cfg.BotID and cfg.secret are both set, it returns a WeComAIBotWSChannel // using the WebSocket long-connection API. // Otherwise it returns the webhook-mode WeComAIBotChannel (requires Token + // EncodingAESKey). @@ -147,13 +147,13 @@ func NewWeComAIBotChannel( cfg config.WeComAIBotConfig, messageBus *bus.MessageBus, ) (channels.Channel, error) { - // WebSocket long-connection mode takes priority when BotID + Secret are set. - if cfg.BotID != "" && cfg.Secret != "" { - logger.InfoC("wecom_aibot", "BotID and Secret provided, using WebSocket mode") + // WebSocket long-connection mode takes priority when BotID + secret are set. + if cfg.BotID != "" && cfg.Secret() != "" { + logger.InfoC("wecom_aibot", "BotID and secret provided, using WebSocket mode") return newWeComAIBotWSChannel(cfg, messageBus) } // Webhook (short-connection) mode. - if cfg.Token == "" || cfg.EncodingAESKey == "" { + if cfg.Token() == "" || cfg.EncodingAESKey() == "" { return nil, fmt.Errorf( "WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " + "or (token + encoding_aes_key) for webhook mode") @@ -350,7 +350,7 @@ func (c *WeComAIBotChannel) handleVerification( }) // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { + if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) { logger.ErrorC("wecom_aibot", "Signature verification failed") http.Error(w, "Signature verification failed", http.StatusUnauthorized) return @@ -358,7 +358,7 @@ func (c *WeComAIBotChannel) handleVerification( // Decrypt echostr // For WeCom AI Bot (智能机器人), receiveid should be empty string - decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") + decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), "") if err != nil { logger.ErrorCF("wecom_aibot", "Failed to decrypt echostr", map[string]any{ "error": err, @@ -417,7 +417,7 @@ func (c *WeComAIBotChannel) handleMessageCallback( } // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { + if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { logger.ErrorC("wecom_aibot", "Signature verification failed") http.Error(w, "Signature verification failed", http.StatusUnauthorized) return @@ -425,7 +425,7 @@ func (c *WeComAIBotChannel) handleMessageCallback( // Decrypt message // For WeCom AI Bot (智能机器人), receiveid is empty string - decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") + decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), "") if err != nil { logger.ErrorCF("wecom_aibot", "Failed to decrypt message", map[string]any{ "error": err, @@ -859,7 +859,7 @@ func (c *WeComAIBotChannel) encryptResponse( } // Generate signature - signature := computeSignature(c.config.Token, timestamp, nonce, encrypted) + signature := computeSignature(c.config.Token(), timestamp, nonce, encrypted) // Build encrypted response encryptedResp := WeComAIBotEncryptedResponse{ @@ -894,7 +894,7 @@ func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string // encryptMessage encrypts a plain text message for WeCom AI Bot func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) { - aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) + aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey()) if err != nil { return "", err } diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go index 957b51c38..11c4393d6 100644 --- a/pkg/channels/wecom/aibot_test.go +++ b/pkg/channels/wecom/aibot_test.go @@ -15,12 +15,11 @@ import ( func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) { t.Run("success with valid config", func(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - WebhookPath: "/webhook/test", - } + cfg := config.WeComAIBotConfig{} + cfg.Enabled = true + cfg.SetToken("test_token") + cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567") + cfg.WebhookPath = "/webhook/test" messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) @@ -40,10 +39,10 @@ func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) { }) t.Run("error with missing token", func(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - EncodingAESKey: "testkey1234567890123456789012345678901234567", - } + cfg := config.WeComAIBotConfig{} + cfg.Enabled = true + cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567") + messageBus := bus.NewMessageBus() _, err := NewWeComAIBotChannel(cfg, messageBus) if err == nil { @@ -52,10 +51,10 @@ func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) { }) t.Run("error with missing encoding key", func(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - } + cfg := config.WeComAIBotConfig{} + cfg.Enabled = true + cfg.SetToken("test_token") + messageBus := bus.NewMessageBus() _, err := NewWeComAIBotChannel(cfg, messageBus) if err == nil { @@ -66,10 +65,10 @@ func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) { func TestWeComAIBotWebhookChannelStartStop(t *testing.T) { cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", + Enabled: true, } + cfg.SetToken("test_token") + cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567") messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) @@ -96,11 +95,11 @@ func TestWeComAIBotWebhookChannelStartStop(t *testing.T) { func TestWeComAIBotChannelWebhookPath(t *testing.T) { t.Run("default path", func(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - } + cfg := config.WeComAIBotConfig{} + cfg.Enabled = true + cfg.SetToken("test_token") + cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567") + messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) @@ -116,12 +115,12 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) { t.Run("custom path", func(t *testing.T) { customPath := "/custom/webhook" - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - WebhookPath: customPath, - } + cfg := config.WeComAIBotConfig{} + cfg.Enabled = true + cfg.SetToken("test_token") + cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567") + cfg.WebhookPath = customPath + messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) @@ -140,10 +139,10 @@ func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) { t.Run("uses default processing message", func(t *testing.T) { cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: validAESKey, + Enabled: true, } + cfg.SetToken("test_token") + cfg.SetEncodingAESKey(validAESKey) messageBus := bus.NewMessageBus() channel, err := NewWeComAIBotChannel(cfg, messageBus) @@ -187,10 +186,10 @@ func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) { t.Run("uses custom processing message", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, - Token: "test_token", - EncodingAESKey: validAESKey, ProcessingMessage: "Please wait a moment. The result will be delivered in a follow-up message.", } + cfg.SetToken("test_token") + cfg.SetEncodingAESKey(validAESKey) messageBus := bus.NewMessageBus() channel, err := NewWeComAIBotChannel(cfg, messageBus) @@ -217,11 +216,11 @@ func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) { } func TestGenerateStreamID(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - } + cfg := config.WeComAIBotConfig{} + cfg.Enabled = true + cfg.SetToken("test_token") + cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567") + messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) webhookCh, ok := ch.(*WeComAIBotChannel) @@ -243,11 +242,12 @@ func TestGenerateStreamID(t *testing.T) { } func TestEncryptDecrypt(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters - } + // Use a valid 43-character base64 key (企业微信标准格式) + cfg := config.WeComAIBotConfig{} + cfg.Enabled = true + cfg.SetToken("test_token") + cfg.SetEncodingAESKey("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") // 43 characters + messageBus := bus.NewMessageBus() ch, _ := NewWeComAIBotChannel(cfg, messageBus) webhookCh, ok := ch.(*WeComAIBotChannel) @@ -266,7 +266,8 @@ func TestEncryptDecrypt(t *testing.T) { t.Fatal("Encrypted message is empty") } - decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid) + // Decrypt + decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey(), receiveid) if err != nil { t.Fatalf("Failed to decrypt message: %v", err) } @@ -298,7 +299,7 @@ func decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse t.Fatalf("Failed to unmarshal encrypted response: %v", err) } - plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey, "") + plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey(), "") if err != nil { t.Fatalf("Failed to decrypt response: %v", err) } @@ -318,8 +319,8 @@ func TestNewWeComAIBotChannel_WSMode(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, BotID: "test_bot_id", - Secret: "test_secret", } + cfg.SetSecret("test_secret") messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { @@ -339,27 +340,27 @@ func TestNewWeComAIBotChannel_WSMode(t *testing.T) { t.Run("ws mode takes priority over webhook fields", func(t *testing.T) { cfg := config.WeComAIBotConfig{ - Enabled: true, - BotID: "test_bot_id", - Secret: "test_secret", - Token: "also_set", - EncodingAESKey: "testkey1234567890123456789012345678901234567", + Enabled: true, + BotID: "test_bot_id", } + cfg.SetSecret("test_secret") + cfg.SetToken("also_set") + cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567") messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { t.Fatalf("Expected no error, got %v", err) } if _, ok := ch.(*WeComAIBotWSChannel); !ok { - t.Error("Expected WebSocket mode channel when both BotID+Secret and Token+Key are set") + t.Error("Expected WebSocket mode channel when both BotID+secret and Token+Key are set") } }) t.Run("error with missing bot_id", func(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, - Secret: "test_secret", } + cfg.SetSecret("test_secret") messageBus := bus.NewMessageBus() _, err := NewWeComAIBotChannel(cfg, messageBus) // Missing bot_id alone means neither WS mode nor webhook mode is fully configured. @@ -385,8 +386,8 @@ func TestWeComAIBotWSChannelStartStop(t *testing.T) { cfg := config.WeComAIBotConfig{ Enabled: true, BotID: "test_bot_id", - Secret: "test_secret", } + cfg.SetSecret("test_secret") messageBus := bus.NewMessageBus() ch, err := NewWeComAIBotChannel(cfg, messageBus) if err != nil { @@ -446,10 +447,10 @@ func TestWSGenerateID(t *testing.T) { func makeWebhookChannel(t *testing.T) *WeComAIBotChannel { t.Helper() cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", + Enabled: true, } + cfg.SetToken("test_token") + cfg.SetEncodingAESKey("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") ch, err := NewWeComAIBotChannel(cfg, bus.NewMessageBus()) if err != nil { t.Fatalf("create channel: %v", err) diff --git a/pkg/channels/wecom/aibot_ws.go b/pkg/channels/wecom/aibot_ws.go index feecd1f4b..53dd7071f 100644 --- a/pkg/channels/wecom/aibot_ws.go +++ b/pkg/channels/wecom/aibot_ws.go @@ -225,7 +225,7 @@ func newWeComAIBotWSChannel( cfg config.WeComAIBotConfig, messageBus *bus.MessageBus, ) (*WeComAIBotWSChannel, error) { - if cfg.BotID == "" || cfg.Secret == "" { + if cfg.BotID == "" || cfg.Secret() == "" { return nil, fmt.Errorf("bot_id and secret are required for WeCom AI Bot WebSocket mode") } @@ -433,7 +433,7 @@ func (c *WeComAIBotWSChannel) runConnection() error { Headers: wsHeaders{ReqID: reqID}, Body: map[string]string{ "bot_id": c.config.BotID, - "secret": c.config.Secret, + "secret": c.config.Secret(), }, }, wsSubscribeTimeout) if err != nil { diff --git a/pkg/channels/wecom/aibot_ws_test.go b/pkg/channels/wecom/aibot_ws_test.go index 0a533da5d..f2f8833a1 100644 --- a/pkg/channels/wecom/aibot_ws_test.go +++ b/pkg/channels/wecom/aibot_ws_test.go @@ -21,8 +21,8 @@ func newTestWSChannel(t *testing.T) *WeComAIBotWSChannel { cfg := config.WeComAIBotConfig{ Enabled: true, BotID: "test_bot_id", - Secret: "test_secret", } + cfg.SetSecret("test_secret") ch, err := newWeComAIBotWSChannel(cfg, bus.NewMessageBus()) if err != nil { t.Fatalf("create WS channel: %v", err) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 2098fcd4e..fccfc60a3 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -119,7 +119,7 @@ type PKCS7Padding struct{} // NewWeComAppChannel creates a new WeCom App channel instance func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) { - if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 { + if cfg.CorpID == "" || cfg.CorpSecret() == "" || cfg.AgentID == 0 { return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required") } @@ -497,9 +497,9 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons } // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { + if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) { logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{ - "token": c.config.Token, + "token": c.config.Token(), "msg_signature": msgSignature, "timestamp": timestamp, "nonce": nonce, @@ -513,10 +513,10 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons // Decrypt echostr with CorpID verification // For WeCom App (自建应用), receiveid should be corp_id logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{ - "encoding_aes_key": c.config.EncodingAESKey, + "encoding_aes_key": c.config.EncodingAESKey(), "corp_id": c.config.CorpID, }) - decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) + decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), c.config.CorpID) if err != nil { logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), @@ -575,7 +575,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp } // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { + if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { logger.WarnC("wecom_app", "Message signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return @@ -583,7 +583,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp // Decrypt message with CorpID verification // For WeCom App (自建应用), receiveid should be corp_id - decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) + decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), c.config.CorpID) if err != nil { logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{ "error": err.Error(), @@ -689,7 +689,7 @@ func (c *WeComAppChannel) tokenRefreshLoop() { // refreshAccessToken gets a new access token from WeCom API func (c *WeComAppChannel) refreshAccessToken() error { apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s", - wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret)) + wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret())) resp, err := http.Get(apiURL) if err != nil { diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go index 7d07041ad..502544441 100644 --- a/pkg/channels/wecom/app_test.go +++ b/pkg/channels/wecom/app_test.go @@ -91,10 +91,10 @@ func TestNewWeComAppChannel(t *testing.T) { t.Run("missing corp_id", func(t *testing.T) { cfg := config.WeComAppConfig{ - CorpID: "", - CorpSecret: "test_secret", - AgentID: 1000002, + CorpID: "", + AgentID: 1000002, } + cfg.SetCorpSecret("test_secret") _, err := NewWeComAppChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing corp_id, got nil") @@ -103,9 +103,8 @@ func TestNewWeComAppChannel(t *testing.T) { t.Run("missing corp_secret", func(t *testing.T) { cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "", - AgentID: 1000002, + CorpID: "test_corp_id", + AgentID: 1000002, } _, err := NewWeComAppChannel(cfg, msgBus) if err == nil { @@ -115,10 +114,10 @@ func TestNewWeComAppChannel(t *testing.T) { t.Run("missing agent_id", func(t *testing.T) { cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 0, + CorpID: "test_corp_id", + AgentID: 0, } + cfg.SetCorpSecret("test_secret") _, err := NewWeComAppChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing agent_id, got nil") @@ -127,11 +126,11 @@ func TestNewWeComAppChannel(t *testing.T) { t.Run("valid config", func(t *testing.T) { cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - AllowFrom: []string{"user1", "user2"}, + CorpID: "test_corp_id", + AgentID: 1000002, + AllowFrom: []string{"user1", "user2"}, } + cfg.SetCorpSecret("test_secret") ch, err := NewWeComAppChannel(cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -150,11 +149,11 @@ func TestWeComAppChannelIsAllowed(t *testing.T) { t.Run("empty allowlist allows all", func(t *testing.T) { cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - AllowFrom: []string{}, + CorpID: "test_corp_id", + AgentID: 1000002, + AllowFrom: []string{}, } + cfg.SetCorpSecret("test_secret") ch, _ := NewWeComAppChannel(cfg, msgBus) if !ch.IsAllowed("any_user") { t.Error("empty allowlist should allow all users") @@ -163,11 +162,11 @@ func TestWeComAppChannelIsAllowed(t *testing.T) { t.Run("allowlist restricts users", func(t *testing.T) { cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - AllowFrom: []string{"allowed_user"}, + CorpID: "test_corp_id", + AgentID: 1000002, + AllowFrom: []string{"allowed_user"}, } + cfg.SetCorpSecret("test_secret") ch, _ := NewWeComAppChannel(cfg, msgBus) if !ch.IsAllowed("allowed_user") { t.Error("allowed user should pass allowlist check") @@ -180,12 +179,11 @@ func TestWeComAppChannelIsAllowed(t *testing.T) { func TestWeComAppVerifySignature(t *testing.T) { msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - } + cfg := config.WeComAppConfig{} + cfg.CorpID = "test_corp_id" + cfg.SetCorpSecret("test_secret") + cfg.AgentID = 1000002 + cfg.SetToken("test_token") ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("valid signature", func(t *testing.T) { @@ -194,7 +192,7 @@ func TestWeComAppVerifySignature(t *testing.T) { msgEncrypt := "test_message" expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt) - if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { + if !verifySignature(ch.config.Token(), expectedSig, timestamp, nonce, msgEncrypt) { t.Error("valid signature should pass verification") } }) @@ -204,21 +202,20 @@ func TestWeComAppVerifySignature(t *testing.T) { nonce := "test_nonce" msgEncrypt := "test_message" - if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { + if verifySignature(ch.config.Token(), "invalid_sig", timestamp, nonce, msgEncrypt) { t.Error("invalid signature should fail verification") } }) t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) { - cfgEmpty := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "", - } + cfgEmpty := config.WeComAppConfig{} + cfgEmpty.CorpID = "test_corp_id" + cfgEmpty.SetCorpSecret("test_secret") + cfgEmpty.AgentID = 1000002 + cfgEmpty.SetToken("") chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) - if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { + if verifySignature(chEmpty.config.Token(), "any_sig", "any_ts", "any_nonce", "any_msg") { t.Error("empty token should reject verification (fail-closed)") } }) @@ -228,19 +225,18 @@ func TestWeComAppDecryptMessage(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("decrypt without AES key", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: "", - } + cfg := config.WeComAppConfig{} + cfg.CorpID = "test_corp_id" + cfg.SetCorpSecret("test_secret") + cfg.AgentID = 1000002 + cfg.SetEncodingAESKey("") ch, _ := NewWeComAppChannel(cfg, msgBus) // Without AES key, message should be base64 decoded only plainText := "hello world" encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) - result, err := decryptMessage(encoded, ch.config.EncodingAESKey) + result, err := decryptMessage(encoded, ch.config.EncodingAESKey()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -252,11 +248,11 @@ func TestWeComAppDecryptMessage(t *testing.T) { t.Run("decrypt with AES key", func(t *testing.T) { aesKey := generateTestAESKeyApp() cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: aesKey, + CorpID: "test_corp_id", + AgentID: 1000002, } + cfg.SetCorpSecret("test_secret") + cfg.SetEncodingAESKey(aesKey) ch, _ := NewWeComAppChannel(cfg, msgBus) originalMsg := "Hello" @@ -265,7 +261,7 @@ func TestWeComAppDecryptMessage(t *testing.T) { t.Fatalf("failed to encrypt test message: %v", err) } - result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) + result, err := decryptMessage(encrypted, ch.config.EncodingAESKey()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -276,29 +272,28 @@ func TestWeComAppDecryptMessage(t *testing.T) { t.Run("invalid base64", func(t *testing.T) { cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: "", + CorpID: "test_corp_id", + AgentID: 1000002, } + cfg.SetCorpSecret("test_secret") + cfg.SetEncodingAESKey("") ch, _ := NewWeComAppChannel(cfg, msgBus) - _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) + _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey()) if err == nil { t.Error("expected error for invalid base64, got nil") } }) t.Run("invalid AES key", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: "invalid_key", - } + cfg := config.WeComAppConfig{} + cfg.CorpID = "test_corp_id" + cfg.SetCorpSecret("test_secret") + cfg.AgentID = 1000002 + cfg.SetEncodingAESKey("invalid_key") ch, _ := NewWeComAppChannel(cfg, msgBus) - _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) + _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey()) if err == nil { t.Error("expected error for invalid AES key, got nil") } @@ -306,17 +301,16 @@ func TestWeComAppDecryptMessage(t *testing.T) { t.Run("ciphertext too short", func(t *testing.T) { aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: aesKey, - } + cfg := config.WeComAppConfig{} + cfg.CorpID = "test_corp_id" + cfg.SetCorpSecret("test_secret") + cfg.AgentID = 1000002 + cfg.SetEncodingAESKey(aesKey) ch, _ := NewWeComAppChannel(cfg, msgBus) // Encrypt a very short message that results in ciphertext less than block size shortData := make([]byte, 8) - _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey) + _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey()) if err == nil { t.Error("expected error for short ciphertext, got nil") } @@ -326,13 +320,12 @@ func TestWeComAppDecryptMessage(t *testing.T) { func TestWeComAppHandleVerification(t *testing.T) { msgBus := bus.NewMessageBus() aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - EncodingAESKey: aesKey, - } + cfg := config.WeComAppConfig{} + cfg.CorpID = "test_corp_id" + cfg.SetCorpSecret("test_secret") + cfg.AgentID = 1000002 + cfg.SetToken("test_token") + cfg.SetEncodingAESKey(aesKey) ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("valid verification request", func(t *testing.T) { @@ -394,13 +387,12 @@ func TestWeComAppHandleVerification(t *testing.T) { func TestWeComAppHandleMessageCallback(t *testing.T) { msgBus := bus.NewMessageBus() aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - EncodingAESKey: aesKey, - } + cfg := config.WeComAppConfig{} + cfg.CorpID = "test_corp_id" + cfg.SetCorpSecret("test_secret") + cfg.AgentID = 1000002 + cfg.SetToken("test_token") + cfg.SetEncodingAESKey(aesKey) ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("valid message callback", func(t *testing.T) { @@ -509,10 +501,10 @@ func TestWeComAppHandleMessageCallback(t *testing.T) { func TestWeComAppProcessMessage(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, + CorpID: "test_corp_id", + AgentID: 1000002, } + cfg.SetCorpSecret("test_secret") ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("process text message", func(t *testing.T) { @@ -594,12 +586,11 @@ func TestWeComAppProcessMessage(t *testing.T) { func TestWeComAppHandleWebhook(t *testing.T) { msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - } + cfg := config.WeComAppConfig{} + cfg.CorpID = "test_corp_id" + cfg.SetCorpSecret("test_secret") + cfg.AgentID = 1000002 + cfg.SetToken("test_token") ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("GET request calls verification", func(t *testing.T) { @@ -666,10 +657,10 @@ func TestWeComAppHandleWebhook(t *testing.T) { func TestWeComAppHandleHealth(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, + CorpID: "test_corp_id", + AgentID: 1000002, } + cfg.SetCorpSecret("test_secret") ch, _ := NewWeComAppChannel(cfg, msgBus) req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", nil) @@ -695,10 +686,10 @@ func TestWeComAppHandleHealth(t *testing.T) { func TestWeComAppAccessToken(t *testing.T) { msgBus := bus.NewMessageBus() cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, + CorpID: "test_corp_id", + AgentID: 1000002, } + cfg.SetCorpSecret("test_secret") ch, _ := NewWeComAppChannel(cfg, msgBus) t.Run("get empty access token initially", func(t *testing.T) { diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 96d5a961f..22461b768 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -82,7 +82,7 @@ type WeComBotReplyMessage struct { // NewWeComBotChannel creates a new WeCom Bot channel instance func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) { - if cfg.Token == "" || cfg.WebhookURL == "" { + if cfg.Token() == "" || cfg.WebhookURL == "" { return nil, fmt.Errorf("wecom token and webhook_url are required") } @@ -216,7 +216,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons } // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { + if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) { logger.WarnC("wecom", "Signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return @@ -225,7 +225,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons // Decrypt echostr // For AIBOT (智能机器人), receiveid should be empty string "" // Reference: https://developer.work.weixin.qq.com/document/path/101033 - decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") + decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), "") if err != nil { logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), @@ -278,7 +278,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp } // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { + if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { logger.WarnC("wecom", "Message signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return @@ -287,7 +287,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp // Decrypt message // For AIBOT (智能机器人), receiveid should be empty string "" // Reference: https://developer.work.weixin.qq.com/document/path/101033 - decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") + decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), "") if err != nil { logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{ "error": err.Error(), diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go index d223bb6b6..7b50a86f7 100644 --- a/pkg/channels/wecom/bot_test.go +++ b/pkg/channels/wecom/bot_test.go @@ -89,10 +89,9 @@ func TestNewWeComBotChannel(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("missing token", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } + cfg := config.WeComConfig{} + cfg.SetToken("") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" _, err := NewWeComBotChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing token, got nil") @@ -100,10 +99,9 @@ func TestNewWeComBotChannel(t *testing.T) { }) t.Run("missing webhook_url", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "" _, err := NewWeComBotChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing webhook_url, got nil") @@ -111,11 +109,10 @@ func TestNewWeComBotChannel(t *testing.T) { }) t.Run("valid config", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - AllowFrom: []string{"user1", "user2"}, - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" + cfg.AllowFrom = []string{"user1", "user2"} ch, err := NewWeComBotChannel(cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -133,11 +130,10 @@ func TestWeComBotChannelIsAllowed(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("empty allowlist allows all", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - AllowFrom: []string{}, - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" + cfg.AllowFrom = []string{} ch, _ := NewWeComBotChannel(cfg, msgBus) if !ch.IsAllowed("any_user") { t.Error("empty allowlist should allow all users") @@ -145,11 +141,10 @@ func TestWeComBotChannelIsAllowed(t *testing.T) { }) t.Run("allowlist restricts users", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - AllowFrom: []string{"allowed_user"}, - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" + cfg.AllowFrom = []string{"allowed_user"} ch, _ := NewWeComBotChannel(cfg, msgBus) if !ch.IsAllowed("allowed_user") { t.Error("allowed user should pass allowlist check") @@ -162,10 +157,9 @@ func TestWeComBotChannelIsAllowed(t *testing.T) { func TestWeComBotVerifySignature(t *testing.T) { msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" ch, _ := NewWeComBotChannel(cfg, msgBus) t.Run("valid signature", func(t *testing.T) { @@ -174,7 +168,7 @@ func TestWeComBotVerifySignature(t *testing.T) { msgEncrypt := "test_message" expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt) - if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { + if !verifySignature(ch.config.Token(), expectedSig, timestamp, nonce, msgEncrypt) { t.Error("valid signature should pass verification") } }) @@ -184,21 +178,20 @@ func TestWeComBotVerifySignature(t *testing.T) { nonce := "test_nonce" msgEncrypt := "test_message" - if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { + if verifySignature(ch.config.Token(), "invalid_sig", timestamp, nonce, msgEncrypt) { t.Error("invalid signature should fail verification") } }) t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) { - cfgEmpty := config.WeComConfig{ - Token: "", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } + cfgEmpty := config.WeComConfig{} + cfgEmpty.SetToken("") + cfgEmpty.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" chEmpty := &WeComBotChannel{ config: cfgEmpty, } - if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { + if verifySignature(chEmpty.config.Token(), "any_sig", "any_ts", "any_nonce", "any_msg") { t.Error("empty token should reject verification (fail-closed)") } }) @@ -208,18 +201,17 @@ func TestWeComBotDecryptMessage(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("decrypt without AES key", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: "", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" + cfg.SetEncodingAESKey("") ch, _ := NewWeComBotChannel(cfg, msgBus) // Without AES key, message should be base64 decoded only plainText := "hello world" encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) - result, err := decryptMessage(encoded, ch.config.EncodingAESKey) + result, err := decryptMessage(encoded, ch.config.EncodingAESKey()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -230,11 +222,10 @@ func TestWeComBotDecryptMessage(t *testing.T) { t.Run("decrypt with AES key", func(t *testing.T) { aesKey := generateTestAESKey() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: aesKey, - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" + cfg.SetEncodingAESKey(aesKey) ch, _ := NewWeComBotChannel(cfg, msgBus) originalMsg := "Hello" @@ -243,7 +234,7 @@ func TestWeComBotDecryptMessage(t *testing.T) { t.Fatalf("failed to encrypt test message: %v", err) } - result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) + result, err := decryptMessage(encrypted, ch.config.EncodingAESKey()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -253,28 +244,26 @@ func TestWeComBotDecryptMessage(t *testing.T) { }) t.Run("invalid base64", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: "", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" + cfg.SetEncodingAESKey("") ch, _ := NewWeComBotChannel(cfg, msgBus) - _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) + _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey()) if err == nil { t.Error("expected error for invalid base64, got nil") } }) t.Run("invalid AES key", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: "invalid_key", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" + cfg.SetEncodingAESKey("invalid_key") ch, _ := NewWeComBotChannel(cfg, msgBus) - _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) + _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey()) if err == nil { t.Error("expected error for invalid AES key, got nil") } @@ -338,11 +327,10 @@ func TestWeComBotPKCS7Unpad(t *testing.T) { func TestWeComBotHandleVerification(t *testing.T) { msgBus := bus.NewMessageBus() aesKey := generateTestAESKey() - cfg := config.WeComConfig{ - Token: "test_token", - EncodingAESKey: aesKey, - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.SetEncodingAESKey(aesKey) + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" ch, _ := NewWeComBotChannel(cfg, msgBus) t.Run("valid verification request", func(t *testing.T) { @@ -404,11 +392,10 @@ func TestWeComBotHandleVerification(t *testing.T) { func TestWeComBotHandleMessageCallback(t *testing.T) { msgBus := bus.NewMessageBus() aesKey := generateTestAESKey() - cfg := config.WeComConfig{ - Token: "test_token", - EncodingAESKey: aesKey, - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.SetEncodingAESKey(aesKey) + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" ch, _ := NewWeComBotChannel(cfg, msgBus) runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder { @@ -530,10 +517,9 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { func TestWeComBotProcessMessage(t *testing.T) { msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" ch, _ := NewWeComBotChannel(cfg, msgBus) t.Run("process direct text message", func(t *testing.T) { @@ -599,10 +585,9 @@ func TestWeComBotProcessMessage(t *testing.T) { func TestWeComBotHandleWebhook(t *testing.T) { msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" ch, _ := NewWeComBotChannel(cfg, msgBus) t.Run("GET request calls verification", func(t *testing.T) { @@ -668,10 +653,9 @@ func TestWeComBotHandleWebhook(t *testing.T) { func TestWeComBotHandleHealth(t *testing.T) { msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } + cfg := config.WeComConfig{} + cfg.SetToken("test_token") + cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test" ch, _ := NewWeComBotChannel(cfg, msgBus) req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil) diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go index 02c137b83..9672e614d 100644 --- a/pkg/channels/weixin/state.go +++ b/pkg/channels/weixin/state.go @@ -46,7 +46,7 @@ func picoclawHomeDir() string { func buildWeixinSyncBufPath(cfg config.WeixinConfig) string { key := "default" - token := strings.TrimSpace(cfg.Token) + token := strings.TrimSpace(cfg.Token()) if token != "" { sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token)) key = hex.EncodeToString(sum[:8]) diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index 43c776f98..b9e821ef1 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -42,7 +42,7 @@ func init() { // NewWeixinChannel creates a new WeixinChannel from config. func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) { - api, err := NewApiClient(cfg.BaseURL, cfg.Token, cfg.Proxy) + api, err := NewApiClient(cfg.BaseURL, cfg.Token(), cfg.Proxy) if err != nil { return nil, fmt.Errorf("weixin: failed to create API client: %w", err) } diff --git a/pkg/channels/weixin/weixin_test.go b/pkg/channels/weixin/weixin_test.go index 115675395..62984c965 100644 --- a/pkg/channels/weixin/weixin_test.go +++ b/pkg/channels/weixin/weixin_test.go @@ -149,10 +149,11 @@ func TestBuildWeixinSyncBufPathUsesPicoclawHome(t *testing.T) { home := t.TempDir() t.Setenv(config.EnvHome, home) - got := buildWeixinSyncBufPath(config.WeixinConfig{ + wxCfg := config.WeixinConfig{ BaseURL: "https://ilinkai.weixin.qq.com/", - Token: "token-123", - }) + } + wxCfg.SetToken("token-123") + got := buildWeixinSyncBufPath(wxCfg) if filepath.Dir(got) != filepath.Join(home, "channels", "weixin", "sync") { t.Fatalf("sync path dir = %q", filepath.Dir(got)) } diff --git a/pkg/config/SECURITY_CONFIG.md b/pkg/config/SECURITY_CONFIG.md new file mode 100644 index 000000000..c5aed54ae --- /dev/null +++ b/pkg/config/SECURITY_CONFIG.md @@ -0,0 +1,551 @@ +# Security Configuration Refactoring + +## Overview + +This refactoring introduces a `.security.yml` file to store all sensitive data (API keys, tokens, secrets, passwords) separately from the main configuration. This improves security by: + +1. **Separation of concerns**: Configuration settings and secrets are in separate files +2. **Easier sharing**: The main config can be shared without exposing sensitive data +3. **Better version control**: `.security.yml` can be added to `.gitignore` +4. **Flexible deployment**: Different environments can use different security files + +## File Structure + +``` +~/.picoclaw/ +├── config.json # Main configuration (safe to share) +└── .security.yml # Security data (never share) +``` + +## Usage + +### Basic Configuration + +In your `config.json`, use `ref:` references to point to values in `.security.yml`: + +```json +{ + "version": 1, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1", + "api_key": "ref:model_list.gpt-5.4.api_key" + } + ], + "channels": { + "telegram": { + "enabled": true, + "token": "ref:channels.telegram.token" + } + } +} +``` + +### Security Configuration + +In your `.security.yml`, store the actual values: + +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-your-actual-api-key-1" + - "sk-your-actual-api-key-2" # Optional: Multiple keys for failover + claude-sonnet-4.6: + api_keys: + - "sk-your-actual-anthropic-key" # Single key in array format + +channels: + telegram: + token: "your-telegram-bot-token" + +web: + brave: + api_keys: + - "BSAyour-brave-api-key-1" + - "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover + tavily: + api_keys: + - "tvly-your-tavily-api-key" # Single key in array format + glm_search: + api_key: "your-glm-search-api-key" # GLMSearch uses single key format +``` + +## Reference Format + +### Model API Keys + +Format: `ref:model_list..api_key` + +Example: `ref:model_list.gpt-5.4.api_key` + +### Channel Tokens/Secrets + +Format: `ref:channels..` + +Examples: +- `ref:channels.telegram.token` +- `ref:channels.feishu.app_secret` +- `ref:channels.feishu.encrypt_key` +- `ref:channels.feishu.verification_token` +- `ref:channels.discord.token` +- `ref:channels.qq.app_secret` +- `ref:channels.dingtalk.client_secret` +- `ref:channels.slack.bot_token` +- `ref:channels.slack.app_token` +- `ref:channels.matrix.access_token` +- `ref:channels.line.channel_secret` +- `ref:channels.line.channel_access_token` +- `ref:channels.onebot.access_token` +- `ref:channels.wecom.token` +- `ref:channels.wecom.encoding_aes_key` +- `ref:channels.wecom_app.corp_secret` +- `ref:channels.wecom_app.token` +- `ref:channels.wecom_app.encoding_aes_key` +- `ref:channels.wecom_aibot.token` +- `ref:channels.wecom_aibot.encoding_aes_key` +- `ref:channels.pico.token` +- `ref:channels.irc.password` +- `ref:channels.irc.nickserv_password` +- `ref:channels.irc.sasl_password` + +### Web Tool API Keys + +Format: `ref:web..` + +Examples: +- `ref:web.brave.api_key` +- `ref:web.tavily.api_key` +- `ref:web.perplexity.api_key` +- `ref:web.glm_search.api_key` + +### Skills Registry Tokens + +Format: `ref:skills..` + +Examples: +- `ref:skills.github.token` +- `ref:skills.clawhub.auth_token` + +## Backward Compatibility + +The refactoring maintains full backward compatibility: + +1. **Direct values**: You can still use direct values in `config.json` (not recommended for production) +2. **Mixed usage**: You can mix `ref:` references and direct values +3. **Optional security file**: If `.security.yml` doesn't exist, all references will fail (but direct values still work) + +### API Key Formats in .security.yml + +**Models (gpt-5.4, claude-sonnet-4.6, etc.):** +- Must use `api_keys` (array) format +- Both single and multiple keys use array format + +**Web Tools (Brave, Tavily, Perplexity):** +- Must use `api_keys` (array) format +- Both single and multiple keys use array format + +**Web Tools (GLMSearch):** +- Must use `api_key` (single string) format +- Does NOT support array format + +**Channels (Telegram, Discord, etc.):** +- Use single field names (e.g., `token`, `app_secret`) +- Each channel uses its specific field names + +### Single Key (Models) + +Use array format with one element: +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-your-key" +``` + +In `config.json`: +```json +{ + "api_key": "ref:model_list.gpt-5.4.api_key" +} +``` + +### Single Key (GLMSearch) + +Use single string format: +```yaml +web: + glm_search: + api_key: "your-glm-key" +``` + +In `config.json`: +```json +{ + "api_key": "ref:web.glm_search.api_key" +} +``` + +## Migration Guide + +### Step 1: Create .security.yml + +Copy the example template: +```bash +cp security.example.yml ~/.picoclaw/.security.yml +``` + +### Step 2: Fill in your actual values + +Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual API keys and tokens. + +### Step 3: Update config.json + +Replace sensitive values in `~/.picoclaw/config.json` with `ref:` references: + +**Before:** +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-actual-api-key-here" + } + ] +} +``` + +**After:** +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "ref:model_list.gpt-5.4.api_key" + } + ] +} +``` + +### Step 4: Verify + +Restart PicoClaw and verify it loads correctly: +```bash +picoclaw --version +``` + +## Security Best Practices + +1. **Never commit `.security.yml`** to version control +2. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml` +3. **Use different keys** for different environments (dev, staging, production) +4. **Rotate keys regularly** and update `.security.yml` +5. **Backup securely**: Encrypt backups containing `.security.yml` + +## API + +### LoadSecurityConfig + +```go +func LoadSecurityConfig(securityPath string) (*SecurityConfig, error) +``` + +Loads the security configuration from `.security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist. + +### SaveSecurityConfig + +```go +func SaveSecurityConfig(securityPath string, sec *SecurityConfig) error +``` + +Saves the security configuration to `.security.yml` with `0o600` permissions. + +### ResolveReference + +```go +func (sec *SecurityConfig) ResolveReference(ref string) (string, error) +``` + +Resolves a reference string (e.g., `"ref:model_list.test.api_key"`) and returns the actual value. + +### SecurityPath + +```go +func SecurityPath(configPath string) string +``` + +Returns the path to `.security.yml` relative to the config file. + +## Example: Complete Configuration + +### config.json +```json +{ + "version": 1, + "agents": { + "defaults": { + "workspace": "~/picoclaw-workspace", + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1", + "api_key": "ref:model_list.gpt-5.4.api_key" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_base": "https://api.anthropic.com/v1", + "api_key": "ref:model_list.claude-sonnet-4.6.api_key" + } + ], + "channels": { + "telegram": { + "enabled": true, + "token": "ref:channels.telegram.token" + } + }, + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "ref:web.brave.api_key" + } + } + } +} +``` + +### .security.yml +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-proj-actual-openai-key-1" + - "sk-proj-actual-openai-key-2" + claude-sonnet-4.6: + api_keys: + - "sk-ant-actual-anthropic-key" # Single key in array format + +channels: + telegram: + token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" + +web: + brave: + api_keys: + - "BSAactualbravekey-1" + - "BSAactualbravekey-2" + tavily: + api_keys: + - "tvly-your-tavily-key" # Single key in array format + glm_search: + api_key: "your-glm-key" # GLMSearch uses single key format +``` + +## Testing + +The refactoring includes comprehensive tests: + +```bash +go test ./pkg/config -run TestSecurityConfig +``` + +## Troubleshooting + +### Error: "model security entry not found" + +- Ensure the model name in your reference matches exactly in `.security.yml` +- Check that the `model_list` section exists in `.security.yml` +- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index + +### Error: "failed to load security config" + +- Verify `.security.yml` exists in the same directory as `config.json` +- Check the YAML syntax is valid (use a YAML validator) +- Ensure file permissions allow reading + +### Error: "unknown reference path" + +- Verify the reference format is correct +- Check the path structure matches the examples above +- Ensure all required sections exist in `.security.yml` + +## Advanced Features + +### Multiple API Keys (Load Balancing & Failover) + +Both models and web tools support multiple API keys for improved reliability: + +**Benefits:** +- **Load balancing**: Requests are distributed across multiple keys +- **Failover**: Automatic switching to another key if one fails +- **Rate limit management**: Distribute usage across multiple keys +- **High availability**: Reduce downtime during API provider issues + +#### Example: Model with Multiple Keys + +**.security.yml:** +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-proj-key-1" + - "sk-proj-key-2" + - "sk-proj-key-3" +``` + +**config.json:** +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "ref:model_list.gpt-5.4.api_key" + } + ] +} +``` + +#### Example: Web Tool with Multiple Keys + +**.security.yml:** +```yaml +web: + brave: + api_keys: + - "BSA-key-1" + - "BSA-key-2" + tavily: + api_keys: + - "tvly-your-key" # Single key in array format + glm_search: + api_key: "your-glm-key" # GLMSearch uses single key format +``` + +**config.json:** +```json +{ + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "ref:web.brave.api_key" + }, + "tavily": { + "enabled": true, + "api_key": "ref:web.tavily.api_key" + } + } + } +} +``` + +#### Supported Formats + +**Models - Single key:** +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-your-key" # Array with one element +``` + +**Models - Multiple keys:** +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-your-key-1" + - "sk-your-key-2" + - "sk-your-key-3" +``` + +**Web Tools (Brave/Tavily/Perplexity) - Single key:** +```yaml +web: + brave: + api_keys: + - "BSA-your-key" # Array with one element +``` + +**Web Tools (Brave/Tavily/Perplexity) - Multiple keys:** +```yaml +web: + brave: + api_keys: + - "BSA-key-1" + - "BSA-key-2" +``` + +**Web Tool (GLMSearch) - Single key only:** +```yaml +web: + glm_search: + api_key: "your-glm-key" # Single string (NOT array) +``` + +All formats work identically in `config.json` - you always use the same reference format: +```json +{ + "api_key": "ref:model_list.gpt-5.4.api_key" +} +``` + +### Model Indexing for Load Balancing + +When you have multiple models with the same base name but different API keys, you can use indexed names: + +**.security.yml:** +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-proj-key-1" + - "sk-proj-key-2" +``` + +The system will automatically expand this into multiple model entries with fallback support. + +### Environment Variables + +You can override any security value using environment variables: + +**For models:** +```bash +export PICOCLAW_MODEL_LIST_GPT-5.4_API_KEY="sk-from-env" +``` + +**For channels:** +```bash +export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env" +``` + +**For web tools:** +```bash +export PICOCLAW_WEB_BRAVE_API_KEY="key-from-env" +``` + +Environment variables follow this pattern: `PICOCLAW_
___` with dots replaced by underscores and converted to uppercase. + +### Multiple API Keys Not Working + +- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch) +- Check that the array format is correct in YAML (proper indentation) +- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format) +- GLMSearch MUST use `api_key` (single string format) +- The reference in `config.json` is the same regardless of single or multiple keys + +### Load Balancing/Failover Issues + +- Verify all API keys in the `api_keys` array are valid +- Check that all keys have the same rate limits and permissions +- Monitor logs to see which keys are being used and failing diff --git a/pkg/config/config.go b/pkg/config/config.go index 070e8e499..c56c2645e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,8 +10,10 @@ import ( "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. @@ -76,13 +78,17 @@ func (f *FlexibleStringSlice) UnmarshalText(text []byte) error { 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"` - Providers ProvidersConfig `json:"providers,omitempty"` - ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration + ModelList []*ModelConfig `json:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway"` Hooks HooksConfig `json:"hooks,omitempty"` Tools ToolsConfig `json:"tools"` @@ -91,6 +97,21 @@ type Config struct { 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 } type HooksConfig struct { @@ -133,19 +154,13 @@ type BuildInfo struct { // MarshalJSON implements custom JSON marshaling for Config // to omit providers section when empty and session when empty -func (c Config) MarshalJSON() ([]byte, error) { +func (c *Config) MarshalJSON() ([]byte, error) { type Alias Config aux := &struct { - Providers *ProvidersConfig `json:"providers,omitempty"` - Session *SessionConfig `json:"session,omitempty"` + Session *SessionConfig `json:"session,omitempty"` *Alias }{ - Alias: (*Alias)(&c), - } - - // Only include providers if not empty - if !c.Providers.IsEmpty() { - aux.Providers = &c.Providers + Alias: (*Alias)(c), } // Only include session if not empty @@ -270,7 +285,6 @@ type AgentDefaults struct { 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"` - Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead ModelFallbacks []string `json:"model_fallbacks,omitempty"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` @@ -315,10 +329,7 @@ func (d *AgentDefaults) IsToolFeedbackEnabled() bool { // 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 { - if d.ModelName != "" { - return d.ModelName - } - return d.Model + return d.ModelName } type ChannelsConfig struct { @@ -375,8 +386,8 @@ type WhatsAppConfig struct { } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + 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"` @@ -386,25 +397,71 @@ type TelegramConfig struct { 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 `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"` + 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 `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + 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"` @@ -412,6 +469,18 @@ type DiscordConfig struct { 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 { @@ -423,42 +492,89 @@ type MaixCamConfig struct { } 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"` + 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 `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` + 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 `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + 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 `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` + 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"` @@ -466,12 +582,24 @@ type MatrixConfig struct { 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 `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` - ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` + 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"` @@ -480,12 +608,35 @@ type LINEConfig struct { 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 `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + 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"` @@ -493,12 +644,24 @@ type OneBotConfig struct { 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 `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` + 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"` @@ -507,15 +670,38 @@ type WeComConfig struct { 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 `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` - AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` + 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"` @@ -523,14 +709,48 @@ type WeComAppConfig struct { 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 `json:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"` - Token string `json:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` + 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"` @@ -538,21 +758,64 @@ type WeComAIBotConfig struct { 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 `json:"token" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"` + 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 `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` + 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"` @@ -561,6 +824,18 @@ type PicoConfig struct { 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 { @@ -574,22 +849,53 @@ type PicoClientConfig struct { } 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 `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` - NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` - SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` - SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` + 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 { @@ -607,88 +913,6 @@ type VoiceConfig struct { EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` } -type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI OpenAIProviderConfig `json:"openai"` - LiteLLM ProviderConfig `json:"litellm"` - 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"` - Vivgrid ProviderConfig `json:"vivgrid"` - VolcEngine ProviderConfig `json:"volcengine"` - GitHubCopilot ProviderConfig `json:"github_copilot"` - Antigravity ProviderConfig `json:"antigravity"` - Qwen ProviderConfig `json:"qwen"` - Mistral ProviderConfig `json:"mistral"` - Avian ProviderConfig `json:"avian"` - Minimax ProviderConfig `json:"minimax"` - LongCat ProviderConfig `json:"longcat"` - ModelScope ProviderConfig `json:"modelscope"` - Novita ProviderConfig `json:"novita"` -} - -// 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.LiteLLM.APIKey == "" && p.LiteLLM.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.Vivgrid.APIKey == "" && p.Vivgrid.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 == "" && - p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && - p.Avian.APIKey == "" && p.Avian.APIBase == "" && - p.Minimax.APIKey == "" && p.Minimax.APIBase == "" && - p.LongCat.APIKey == "" && p.LongCat.APIBase == "" && - p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" && - p.Novita.APIKey == "" && p.Novita.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 { - 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"` - RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"` - 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 @@ -703,8 +927,6 @@ type ModelConfig struct { // HTTP-based providers APIBase string `json:"api_base,omitempty"` // API endpoint URL - APIKey string `json:"api_key"` // API authentication key (single key) - APIKeys []string `json:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) Proxy string `json:"proxy,omitempty"` // HTTP proxy URL Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover @@ -718,6 +940,19 @@ type ModelConfig struct { 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 + + // 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. @@ -731,6 +966,15 @@ func (c *ModelConfig) Validate() error { 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"` @@ -751,18 +995,68 @@ type ToolConfig struct { } 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"` - APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` + 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"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` - APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` + 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 { @@ -771,10 +1065,35 @@ type DuckDuckGoConfig struct { } 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"` - APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` + 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 { @@ -784,20 +1103,43 @@ type SearXNGConfig struct { } type GLMSearchConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` + 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"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"` 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 { @@ -893,14 +1235,27 @@ type SkillsRegistriesConfig struct { } type SkillsGithubConfig struct { - Token string `json:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN"` - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` + 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 `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"` + 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"` @@ -909,6 +1264,17 @@ type ClawHubRegistryConfig struct { 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 @@ -942,43 +1308,76 @@ type MCPConfig struct { } func LoadConfig(path string) (*Config, error) { - cfg := DefaultConfig() - data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return cfg, nil + return DefaultConfig(), nil } return nil, err } - // Pre-scan the JSON to check how many model_list entries the user provided. - // Go's JSON decoder reuses existing slice backing-array elements rather than - // zero-initializing them, so fields absent from the user's JSON (e.g. api_base) - // would silently inherit values from the DefaultConfig template at the same - // index position. We only reset cfg.ModelList when the user actually provides - // entries; when count is 0 we keep DefaultConfig's built-in list as fallback. - var tmp Config - if err := json.Unmarshal(data, &tmp); err != nil { - return nil, err + // First, try to detect config version by reading the version field + var versionInfo struct { + Version int `json:"version"` } - if len(tmp.ModelList) > 0 { - cfg.ModelList = nil + 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 } - if err := json.Unmarshal(data, cfg); err != nil { - return nil, err + // 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 { - if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") && - !strings.HasPrefix(m.APIKey, "file://") { - fmt.Fprintf( - os.Stderr, - "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n", - m.ModelName, - ) + 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 + } } } } @@ -991,56 +1390,264 @@ func LoadConfig(path string) (*Config, error) { 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) + cfg.ModelList = expandMultiKeyModels(cfg.ModelList) // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() - // Auto-migrate: if only legacy providers config exists, convert to model_list - if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() { - cfg.ModelList = ConvertProvidersToModelList(cfg) - } - - // Inherit credentials from providers to model_list entries (#1635). - // When both providers and model_list are present, model_list entries - // whose api_key/api_base are empty will inherit from the matching - // provider (matched by protocol prefix). Explicit model_list values - // always take precedence. - if cfg.HasProvidersConfig() { - InheritProviderCredentials(cfg.ModelList, cfg.Providers) - } - // 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.Discord.token = sec.Channels.Discord.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 []ModelConfig, passphrase string) ([]ModelConfig, error) { - sealed := make([]ModelConfig, len(models)) - copy(sealed, models) +func encryptPlaintextAPIKeys( + models map[string]ModelSecurityEntry, + passphrase string, +) (map[string]ModelSecurityEntry, error) { + sealed := make(map[string]ModelSecurityEntry, len(models)) changed := false - for i := range sealed { - m := &sealed[i] - if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || - strings.HasPrefix(m.APIKey, "file://") { - continue + 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 } - encrypted, err := credential.Encrypt(passphrase, "", m.APIKey) - if err != nil { - return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err) - } - m.APIKey = encrypted - changed = true + + sealed[k] = sealedEntry } if !changed { return nil, nil @@ -1050,19 +1657,11 @@ func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelCo // resolveAPIKeys decrypts or dereferences each api_key in models in-place. // Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt). -// Also resolves api_keys array if present. -func resolveAPIKeys(models []ModelConfig, configDir string) error { +func resolveAPIKeys(models []*ModelConfig, configDir string) error { cr := credential.NewResolver(configDir) for i := range models { - // Resolve single APIKey - resolved, err := cr.Resolve(models[i].APIKey) - if err != nil { - return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err) - } - models[i].APIKey = resolved - // Resolve APIKeys array - for j, key := range models[i].APIKeys { + for j, key := range models[i].apiKeys { resolved, err := cr.Resolve(key) if err != nil { return fmt.Errorf( @@ -1073,7 +1672,7 @@ func resolveAPIKeys(models []ModelConfig, configDir string) error { err, ) } - models[i].APIKeys[j] = resolved + models[i].apiKeys[j] = resolved } } return nil @@ -1093,17 +1692,187 @@ func (c *Config) migrateChannelConfigs() { } 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.ModelList, passphrase) + sealed, err := encryptPlaintextAPIKeys(cfg.security.ModelList, passphrase) if err != nil { return err } if sealed != nil { - tmp := *cfg - tmp.ModelList = sealed - cfg = &tmp + 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 { @@ -1116,53 +1885,6 @@ func (c *Config) WorkspacePath() string { return expandHome(c.Agents.Defaults.Workspace) } -func (c *Config) GetAPIKey() string { - 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 { - 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 @@ -1186,17 +1908,17 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) } if len(matches) == 1 { - return &matches[0], nil + return matches[0], nil } // Multiple configs - use round-robin for load balancing idx := (rrCounter.Add(1) - 1) % uint64(len(matches)) - return &matches[idx], nil + return matches[idx], nil } // findMatches finds all ModelConfig entries with the given model_name. -func (c *Config) findMatches(modelName string) []ModelConfig { - var matches []ModelConfig +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]) @@ -1205,11 +1927,6 @@ func (c *Config) findMatches(modelName string) []ModelConfig { return matches } -// HasProvidersConfig checks if any provider in the old providers config has configuration. -func (c *Config) HasProvidersConfig() bool { - return !c.Providers.IsEmpty() -} - // 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. @@ -1222,6 +1939,10 @@ func (c *Config) ValidateModelList() error { 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 @@ -1245,28 +1966,92 @@ func MergeAPIKeys(apiKey string, apiKeys []string) []string { return all } -// ExpandMultiKeyModels expands ModelConfig entries with multiple API keys into +// 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_key": "k1", "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]} -// - {"model_name": "gpt-4__key_1", "api_key": "k2"} -// - {"model_name": "gpt-4__key_2", "api_key": "k3"} -func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig { - var expanded []ModelConfig +// - {"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.APIKey, m.APIKeys) + keys := MergeAPIKeys("", m.apiKeys) // Single key or no keys: keep as-is if len(keys) <= 1 { - // Ensure APIKey is set from APIKeys if needed - if m.APIKey == "" && len(keys) == 1 { - m.APIKey = keys[0] - } - m.APIKeys = nil // Clear APIKeys to avoid confusion + m.apiKeys = keys expanded = append(expanded, m) continue } @@ -1281,11 +2066,11 @@ func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig { expandedName := originalName + suffix // Create a copy for the additional key - additionalEntry := ModelConfig{ + additionalEntry := &ModelConfig{ ModelName: expandedName, Model: m.Model, APIBase: m.APIBase, - APIKey: keys[i], + apiKeys: []string{keys[i]}, Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, @@ -1300,11 +2085,10 @@ func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig { } // Create the primary entry with first key and fallbacks - primaryEntry := ModelConfig{ + primaryEntry := &ModelConfig{ ModelName: originalName, Model: m.Model, APIBase: m.APIBase, - APIKey: keys[0], Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, @@ -1313,6 +2097,7 @@ func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, + apiKeys: []string{keys[0]}, } // Prepend new fallbacks to existing ones diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go new file mode 100644 index 000000000..c7c7f0028 --- /dev/null +++ b/pkg/config/config_old.go @@ -0,0 +1,1032 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import "encoding/json" + +type agentDefaultsV0 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,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + 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"` +} + +// 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 *agentDefaultsV0) GetModelName() string { + if d.ModelName != "" { + return d.ModelName + } + return d.Model +} + +type agentsConfigV0 struct { + Defaults agentDefaultsV0 `json:"defaults"` + List []AgentConfig `json:"list,omitempty"` +} + +// configV0 represents the config structure before versioning was introduced. +// This struct is used for loading legacy config files (version 0). +// It is unexported since it's only used internally for migration. +type configV0 struct { + Agents agentsConfigV0 `json:"agents"` + Bindings []AgentBinding `json:"bindings,omitempty"` + Session SessionConfig `json:"session,omitempty"` + Channels channelsConfigV0 `json:"channels"` + Providers providersConfigV0 `json:"providers,omitempty"` + ModelList []modelConfigV0 `json:"model_list"` + Gateway GatewayConfig `json:"gateway"` + Tools toolsConfigV0 `json:"tools"` + Heartbeat HeartbeatConfig `json:"heartbeat"` + Devices DevicesConfig `json:"devices"` +} + +type toolsConfigV0 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"` + Web webToolsConfigV0 `json:"web"` + Cron CronToolsConfig `json:"cron"` + Exec ExecConfig `json:"exec"` + Skills skillsToolsConfigV0 `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_"` +} + +type channelsConfigV0 struct { + WhatsApp WhatsAppConfig `json:"whatsapp"` + Telegram telegramConfigV0 `json:"telegram"` + Feishu feishuConfigV0 `json:"feishu"` + Discord discordConfigV0 `json:"discord"` + MaixCam maixcamConfigV0 `json:"maixcam"` + Weixin weixinConfigV0 `json:"weixin"` + QQ qqConfigV0 `json:"qq"` + DingTalk dingtalkConfigV0 `json:"dingtalk"` + Slack slackConfigV0 `json:"slack"` + Matrix matrixConfigV0 `json:"matrix"` + LINE lineConfigV0 `json:"line"` + OneBot onebotConfigV0 `json:"onebot"` + WeCom wecomConfigV0 `json:"wecom"` + WeComApp wecomappConfigV0 `json:"wecom_app"` + WeComAIBot wecomaibotConfigV0 `json:"wecom_aibot"` + Pico picoConfigV0 `json:"pico"` + IRC ircConfigV0 `json:"irc"` +} + +func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity) { + telegram, telegramSecurity := v.Telegram.ToTelegramConfig() + feishu, feishuSecurity := v.Feishu.ToFeishuConfig() + discord, discordSecurity := v.Discord.ToDiscordConfig() + maixcam := v.MaixCam.ToMaixCamConfig() + qq, qqSecurity := v.QQ.ToQQConfig() + weixin, weixinSecurity := v.Weixin.ToWeiXinConfig() + dingtalk, dingtalkSecurity := v.DingTalk.ToDingTalkConfig() + slack, slackSecurity := v.Slack.ToSlackConfig() + matrix, matrixSecurity := v.Matrix.ToMatrixConfig() + line, lineSecurity := v.LINE.ToLINEConfig() + onebot, onebotSecurity := v.OneBot.ToOneBotConfig() + wecom, wecomSecurity := v.WeCom.ToWeComConfig() + wecomapp, wecomappSecurity := v.WeComApp.ToWeComAppConfig() + wecomaibot, wecomaibotSecurity := v.WeComAIBot.ToWeComAIBotConfig() + pico, picoSecurity := v.Pico.ToPicoConfig() + irc, ircSecurity := v.IRC.ToIRCConfig() + + return ChannelsConfig{ + WhatsApp: v.WhatsApp, + Telegram: telegram, + Feishu: feishu, + Discord: discord, + MaixCam: maixcam, + QQ: qq, + Weixin: weixin, + DingTalk: dingtalk, + Slack: slack, + Matrix: matrix, + LINE: line, + OneBot: onebot, + WeCom: wecom, + WeComApp: wecomapp, + WeComAIBot: wecomaibot, + Pico: pico, + IRC: irc, + }, ChannelsSecurity{ + Telegram: &telegramSecurity, + Feishu: &feishuSecurity, + Discord: &discordSecurity, + QQ: &qqSecurity, + Weixin: &weixinSecurity, + DingTalk: &dingtalkSecurity, + Slack: &slackSecurity, + Matrix: &matrixSecurity, + LINE: &lineSecurity, + OneBot: &onebotSecurity, + WeCom: &wecomSecurity, + WeComApp: &wecomappSecurity, + WeComAIBot: &wecomaibotSecurity, + Pico: &picoSecurity, + IRC: &ircSecurity, + } +} + +type qqConfigV0 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"` + 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"` +} + +func (v *qqConfigV0) ToQQConfig() (QQConfig, QQSecurity) { + return QQConfig{ + Enabled: v.Enabled, + AppID: v.AppID, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + MaxMessageLength: v.MaxMessageLength, + MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB, + SendMarkdown: v.SendMarkdown, + ReasoningChannelID: v.ReasoningChannelID, + }, QQSecurity{ + AppSecret: v.AppSecret, + } +} + +type telegramConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + 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"` + 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"` +} + +func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, TelegramSecurity) { + return TelegramConfig{ + Enabled: v.Enabled, + token: v.Token, + BaseURL: v.BaseURL, + Proxy: v.Proxy, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + UseMarkdownV2: v.UseMarkdownV2, + }, TelegramSecurity{ + Token: v.Token, + } +} + +type feishuConfigV0 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"` + 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"` +} + +func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, FeishuSecurity) { + return FeishuConfig{ + Enabled: v.Enabled, + AppID: v.AppID, + appSecret: v.AppSecret, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, FeishuSecurity{ + AppSecret: v.AppSecret, + EncryptKey: v.EncryptKey, + VerificationToken: v.VerificationToken, + } +} + +type discordConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + 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"` +} + +func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, DiscordSecurity) { + return DiscordConfig{ + Enabled: v.Enabled, + token: v.Token, + Proxy: v.Proxy, + AllowFrom: v.AllowFrom, + MentionOnly: v.MentionOnly, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, DiscordSecurity{ + Token: v.Token, + } +} + +type maixcamConfigV0 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"` +} + +func (v *maixcamConfigV0) ToMaixCamConfig() MaixCamConfig { + return MaixCamConfig{ + Enabled: v.Enabled, + Host: v.Host, + Port: v.Port, + AllowFrom: v.AllowFrom, + ReasoningChannelID: v.ReasoningChannelID, + } +} + +type dingtalkConfigV0 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"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` +} + +func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, DingTalkSecurity) { + return DingTalkConfig{ + Enabled: v.Enabled, + ClientID: v.ClientID, + clientSecret: v.ClientSecret, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + ReasoningChannelID: v.ReasoningChannelID, + }, DingTalkSecurity{ + ClientSecret: v.ClientSecret, + } +} + +type slackConfigV0 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"` + 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"` +} + +func (v *slackConfigV0) ToSlackConfig() (SlackConfig, SlackSecurity) { + return SlackConfig{ + Enabled: v.Enabled, + botToken: v.BotToken, + appToken: v.AppToken, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, SlackSecurity{ + BotToken: v.BotToken, + AppToken: v.AppToken, + } +} + +type matrixConfigV0 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 `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` + 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"` +} + +func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, MatrixSecurity) { + return MatrixConfig{ + Enabled: v.Enabled, + Homeserver: v.Homeserver, + UserID: v.UserID, + accessToken: v.AccessToken, + DeviceID: v.DeviceID, + JoinOnInvite: v.JoinOnInvite, + MessageFormat: v.MessageFormat, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, MatrixSecurity{ + AccessToken: v.AccessToken, + } +} + +type lineConfigV0 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"` + 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"` +} + +func (v *lineConfigV0) ToLINEConfig() (LINEConfig, LINESecurity) { + return LINEConfig{ + Enabled: v.Enabled, + channelSecret: v.ChannelSecret, + channelAccessToken: v.ChannelAccessToken, + WebhookHost: v.WebhookHost, + WebhookPort: v.WebhookPort, + WebhookPath: v.WebhookPath, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, LINESecurity{ + ChannelSecret: v.ChannelSecret, + ChannelAccessToken: v.ChannelAccessToken, + } +} + +type onebotConfigV0 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"` + 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"` +} + +func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, OneBotSecurity) { + return OneBotConfig{ + Enabled: v.Enabled, + WSUrl: v.WSUrl, + accessToken: v.AccessToken, + ReconnectInterval: v.ReconnectInterval, + GroupTriggerPrefix: v.GroupTriggerPrefix, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, OneBotSecurity{ + AccessToken: v.AccessToken, + } +} + +type wecomConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` + 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"` +} + +func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, WeComSecurity) { + return WeComConfig{ + Enabled: v.Enabled, + token: v.Token, + encodingAESKey: v.EncodingAESKey, + WebhookURL: v.WebhookURL, + WebhookHost: v.WebhookHost, + WebhookPort: v.WebhookPort, + WebhookPath: v.WebhookPath, + AllowFrom: v.AllowFrom, + ReplyTimeout: v.ReplyTimeout, + GroupTrigger: v.GroupTrigger, + ReasoningChannelID: v.ReasoningChannelID, + }, WeComSecurity{ + Token: v.Token, + EncodingAESKey: v.EncodingAESKey, + } +} + +type weixinConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"` + 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"` +} + +func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, WeixinSecurity) { + return WeixinConfig{ + Enabled: v.Enabled, + token: v.Token, + BaseURL: v.BaseURL, + CDNBaseURL: v.CDNBaseURL, + Proxy: v.Proxy, + AllowFrom: v.AllowFrom, + ReasoningChannelID: v.ReasoningChannelID, + }, WeixinSecurity{ + Token: v.Token, + } +} + +type wecomappConfigV0 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 `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` + AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` + 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"` +} + +func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, WeComAppSecurity) { + return WeComAppConfig{ + Enabled: v.Enabled, + CorpID: v.CorpID, + corpSecret: v.CorpSecret, + AgentID: v.AgentID, + token: v.Token, + encodingAESKey: v.EncodingAESKey, + WebhookHost: v.WebhookHost, + WebhookPort: v.WebhookPort, + WebhookPath: v.WebhookPath, + AllowFrom: v.AllowFrom, + ReplyTimeout: v.ReplyTimeout, + GroupTrigger: v.GroupTrigger, + ReasoningChannelID: v.ReasoningChannelID, + }, WeComAppSecurity{ + CorpSecret: v.CorpSecret, + Token: v.Token, + EncodingAESKey: v.EncodingAESKey, + } +} + +type wecomaibotConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` + Secret string `json:"secret" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` + WebhookPath string `json:"webhook_path" 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"` + WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` +} + +func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, WeComAIBotSecurity) { + return WeComAIBotConfig{ + Enabled: v.Enabled, + WebhookPath: v.WebhookPath, + AllowFrom: v.AllowFrom, + ReplyTimeout: v.ReplyTimeout, + MaxSteps: v.MaxSteps, + WelcomeMessage: v.WelcomeMessage, + ReasoningChannelID: v.ReasoningChannelID, + }, WeComAIBotSecurity{ + Token: v.Token, + Secret: v.Secret, + EncodingAESKey: v.EncodingAESKey, + } +} + +type picoConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` + 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"` +} + +func (v *picoConfigV0) ToPicoConfig() (PicoConfig, PicoSecurity) { + return PicoConfig{ + Enabled: v.Enabled, + token: v.Token, + AllowTokenQuery: v.AllowTokenQuery, + AllowOrigins: v.AllowOrigins, + PingInterval: v.PingInterval, + ReadTimeout: v.ReadTimeout, + WriteTimeout: v.WriteTimeout, + MaxConnections: v.MaxConnections, + AllowFrom: v.AllowFrom, + Placeholder: v.Placeholder, + }, PicoSecurity{ + Token: v.Token, + } +} + +type ircConfigV0 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 `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` + NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` + SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` + SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` + 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"` +} + +func (v *ircConfigV0) ToIRCConfig() (IRCConfig, IRCSecurity) { + return IRCConfig{ + Enabled: v.Enabled, + Server: v.Server, + TLS: v.TLS, + Nick: v.Nick, + User: v.User, + RealName: v.RealName, + password: v.Password, + nickServPassword: v.NickServPassword, + SASLUser: v.SASLUser, + saslPassword: v.SASLPassword, + Channels: v.Channels, + RequestCaps: v.RequestCaps, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + ReasoningChannelID: v.ReasoningChannelID, + }, IRCSecurity{ + Password: v.Password, + NickServPassword: v.NickServPassword, + SASLPassword: v.SASLPassword, + } +} + +type providersConfigV0 struct { + Anthropic providerConfigV0 `json:"anthropic"` + OpenAI openAIProviderConfigV0 `json:"openai"` + LiteLLM providerConfigV0 `json:"litellm"` + OpenRouter providerConfigV0 `json:"openrouter"` + Groq providerConfigV0 `json:"groq"` + Zhipu providerConfigV0 `json:"zhipu"` + VLLM providerConfigV0 `json:"vllm"` + Gemini providerConfigV0 `json:"gemini"` + Nvidia providerConfigV0 `json:"nvidia"` + Ollama providerConfigV0 `json:"ollama"` + Moonshot providerConfigV0 `json:"moonshot"` + ShengSuanYun providerConfigV0 `json:"shengsuanyun"` + DeepSeek providerConfigV0 `json:"deepseek"` + Cerebras providerConfigV0 `json:"cerebras"` + Vivgrid providerConfigV0 `json:"vivgrid"` + VolcEngine providerConfigV0 `json:"volcengine"` + GitHubCopilot providerConfigV0 `json:"github_copilot"` + Antigravity providerConfigV0 `json:"antigravity"` + Qwen providerConfigV0 `json:"qwen"` + Mistral providerConfigV0 `json:"mistral"` + Avian providerConfigV0 `json:"avian"` + Minimax providerConfigV0 `json:"minimax"` + LongCat providerConfigV0 `json:"longcat"` + ModelScope providerConfigV0 `json:"modelscope"` + Novita providerConfigV0 `json:"novita"` +} + +// 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 providersConfigV0) IsEmpty() bool { + return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" && + p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" && + p.LiteLLM.APIKey == "" && p.LiteLLM.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.Vivgrid.APIKey == "" && p.Vivgrid.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 == "" && + p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && + p.Avian.APIKey == "" && p.Avian.APIBase == "" && + p.Minimax.APIKey == "" && p.Minimax.APIBase == "" && + p.LongCat.APIKey == "" && p.LongCat.APIBase == "" && + p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" && + p.Novita.APIKey == "" && p.Novita.APIBase == "" +} + +type providerConfigV0 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"` + RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"` + 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` +} + +// MarshalJSON implements custom JSON marshaling for providersConfig +// to omit the entire section when empty +func (p providersConfigV0) MarshalJSON() ([]byte, error) { + if p.IsEmpty() { + return []byte("null"), nil + } + type Alias providersConfigV0 + return json.Marshal((*Alias)(&p)) +} + +type openAIProviderConfigV0 struct { + providerConfigV0 + WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` +} + +type modelConfigV0 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 (single key) + APIKeys []string `json:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) + 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 +} + +func (c *configV0) 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 (c *configV0) Migrate() (*Config, error) { + // Migrate legacy channel config fields to new unified structures + cfg := DefaultConfig() + + // Always copy user's Agents config to preserve settings like Provider, Model, MaxTokens + cfg.Agents.List = c.Agents.List + cfg.Agents.Defaults.Workspace = c.Agents.Defaults.Workspace + cfg.Agents.Defaults.RestrictToWorkspace = c.Agents.Defaults.RestrictToWorkspace + cfg.Agents.Defaults.AllowReadOutsideWorkspace = c.Agents.Defaults.AllowReadOutsideWorkspace + cfg.Agents.Defaults.Provider = c.Agents.Defaults.Provider + cfg.Agents.Defaults.ModelName = c.Agents.Defaults.GetModelName() + cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks + cfg.Agents.Defaults.ImageModel = c.Agents.Defaults.ImageModel + cfg.Agents.Defaults.ImageModelFallbacks = c.Agents.Defaults.ImageModelFallbacks + cfg.Agents.Defaults.MaxTokens = c.Agents.Defaults.MaxTokens + cfg.Agents.Defaults.Temperature = c.Agents.Defaults.Temperature + cfg.Agents.Defaults.MaxToolIterations = c.Agents.Defaults.MaxToolIterations + cfg.Agents.Defaults.SummarizeMessageThreshold = c.Agents.Defaults.SummarizeMessageThreshold + cfg.Agents.Defaults.SummarizeTokenPercent = c.Agents.Defaults.SummarizeTokenPercent + cfg.Agents.Defaults.MaxMediaSize = c.Agents.Defaults.MaxMediaSize + cfg.Agents.Defaults.Routing = c.Agents.Defaults.Routing + + // Copy other top-level fields + cfg.Bindings = c.Bindings + cfg.Session = c.Session + var secChannels ChannelsSecurity + cfg.Channels, secChannels = c.Channels.ToChannelsConfig() + cfg.Gateway = c.Gateway + var secWeb WebToolsSecurity + cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig() + cfg.Tools.Cron = c.Tools.Cron + cfg.Tools.Exec = c.Tools.Exec + var secSkills SkillsSecurity + cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig() + cfg.Tools.MediaCleanup = c.Tools.MediaCleanup + cfg.Tools.MCP = c.Tools.MCP + cfg.Tools.AppendFile = c.Tools.AppendFile + cfg.Tools.EditFile = c.Tools.EditFile + cfg.Tools.FindSkills = c.Tools.FindSkills + cfg.Tools.I2C = c.Tools.I2C + cfg.Tools.InstallSkill = c.Tools.InstallSkill + cfg.Tools.ListDir = c.Tools.ListDir + cfg.Tools.Message = c.Tools.Message + cfg.Tools.ReadFile = c.Tools.ReadFile + cfg.Tools.SendFile = c.Tools.SendFile + cfg.Tools.Spawn = c.Tools.Spawn + cfg.Tools.SpawnStatus = c.Tools.SpawnStatus + cfg.Tools.SPI = c.Tools.SPI + cfg.Tools.Subagent = c.Tools.Subagent + cfg.Tools.WebFetch = c.Tools.WebFetch + cfg.Tools.AllowReadPaths = c.Tools.AllowReadPaths + cfg.Tools.AllowWritePaths = c.Tools.AllowWritePaths + cfg.Heartbeat = c.Heartbeat + cfg.Devices = c.Devices + + secModels := make(map[string]ModelSecurityEntry, 0) + // Only override ModelList if user provided values + if len(c.ModelList) > 0 { + // Convert []modelConfigV0 to []ModelConfig + cfg.ModelList = make([]*ModelConfig, len(c.ModelList)) + for i, m := range c.ModelList { + // Merge APIKey and APIKeys, deduplicating + mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys) + + cfg.ModelList[i] = &ModelConfig{ + ModelName: m.ModelName, + Model: m.Model, + APIBase: m.APIBase, + Proxy: m.Proxy, + Fallbacks: m.Fallbacks, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + apiKeys: mergedKeys, + } + } + names := toNameIndex(cfg.ModelList) + for i, m := range c.ModelList { + // Merge APIKey and APIKeys, deduplicating + mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys) + secModels[names[i]] = ModelSecurityEntry{ + APIKeys: mergedKeys, + } + } + } + + cfg.WithSecurity(&SecurityConfig{ + ModelList: secModels, + Channels: secChannels, + Web: secWeb, + Skills: secSkills, + }) + cfg.Version = CurrentVersion + return cfg, nil +} + +type webToolsConfigV0 struct { + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` + Brave braveConfigV0 ` json:"brave"` + Tavily tavilyConfigV0 ` json:"tavily"` + DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"` + Perplexity perplexityConfigV0 ` json:"perplexity"` + SearXNG SearXNGConfig ` json:"searxng"` + GLMSearch glmSearchConfigV0 ` json:"glm_search"` + PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` + 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 braveConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` +} + +func (v *braveConfigV0) ToBraveConfig() (BraveConfig, BraveSecurity) { + return BraveConfig{ + Enabled: v.Enabled, + MaxResults: v.MaxResults, + }, BraveSecurity{ + APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys), + } +} + +type tavilyConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` +} + +func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, TavilySecurity) { + return TavilyConfig{ + Enabled: v.Enabled, + BaseURL: v.BaseURL, + MaxResults: v.MaxResults, + }, TavilySecurity{ + APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys), + } +} + +type perplexityConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` +} + +func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, PerplexitySecurity) { + return PerplexityConfig{ + Enabled: v.Enabled, + MaxResults: v.MaxResults, + }, PerplexitySecurity{ + APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys), + } +} + +type glmSearchConfigV0 struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` + SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` +} + +func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, GLMSearchSecurity) { + return GLMSearchConfig{ + Enabled: v.Enabled, + apiKey: v.APIKey, + BaseURL: v.BaseURL, + SearchEngine: v.SearchEngine, + }, GLMSearchSecurity{ + APIKey: v.APIKey, + } +} + +func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) { + brave, braveSecurity := v.Brave.ToBraveConfig() + tavily, tavilySecurity := v.Tavily.ToTavilyConfig() + perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig() + glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig() + + return WebToolsConfig{ + ToolConfig: v.ToolConfig, + Brave: brave, + Tavily: tavily, + DuckDuckGo: v.DuckDuckGo, + Perplexity: perplexity, + SearXNG: v.SearXNG, + GLMSearch: glmSearch, + PreferNative: v.PreferNative, + Proxy: v.Proxy, + FetchLimitBytes: v.FetchLimitBytes, + Format: v.Format, + PrivateHostWhitelist: v.PrivateHostWhitelist, + }, WebToolsSecurity{ + Brave: &braveSecurity, + Tavily: &tavilySecurity, + Perplexity: &perplexitySecurity, + GLMSearch: &glmSearchSecurity, + } +} + +type skillsToolsConfigV0 struct { + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` + Registries skillsRegistriesConfigV0 ` json:"registries"` + Github skillsGithubConfigV0 ` json:"github"` + MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` + SearchCache SearchCacheConfig ` json:"search_cache"` +} + +type skillsRegistriesConfigV0 struct { + ClawHub clawHubRegistryConfigV0 `json:"clawhub"` +} + +type clawHubRegistryConfigV0 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 `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"` + SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` + SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` +} + +func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, ClawHubSecurity) { + return ClawHubRegistryConfig{ + Enabled: v.Enabled, + BaseURL: v.BaseURL, + authToken: v.AuthToken, + SearchPath: v.SearchPath, + SkillsPath: v.SkillsPath, + }, ClawHubSecurity{ + AuthToken: v.AuthToken, + } +} + +type skillsGithubConfigV0 struct { + Token string `json:"token" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` +} + +func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, GithubSecurity) { + return SkillsGithubConfig{ + token: v.Token, + Proxy: v.Proxy, + }, GithubSecurity{ + Token: v.Token, + } +} + +func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) { + clawHub, clawHubSecurity := v.ClawHub.ToClawHubRegistryConfig() + + return SkillsRegistriesConfig{ + ClawHub: clawHub, + }, &clawHubSecurity +} + +func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, SkillsSecurity) { + registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig() + github, githubSecurity := v.Github.ToSkillsGithubConfig() + + return SkillsToolsConfig{ + ToolConfig: v.ToolConfig, + Registries: registries, + Github: github, + MaxConcurrentSearches: v.MaxConcurrentSearches, + SearchCache: v.SearchCache, + }, SkillsSecurity{ + Github: &githubSecurity, + ClawHub: registriesSecurity, + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index f1e94afbc..a4c207470 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -8,6 +8,9 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + "github.com/sipeed/picoclaw/pkg/credential" ) @@ -78,18 +81,19 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) { } func TestProvidersConfig_IsEmpty(t *testing.T) { - var empty ProvidersConfig + var empty providersConfigV0 + t.Logf("empty: %+v", empty) if !empty.IsEmpty() { - t.Fatal("empty ProvidersConfig should report empty") + t.Fatal("empty providersConfig should report empty") } - novita := ProvidersConfig{ - Novita: ProviderConfig{ + novita := providersConfigV0{ + Novita: providerConfigV0{ APIKey: "test-key", }, } if novita.IsEmpty() { - t.Fatal("ProvidersConfig with novita settings should not report empty") + t.Fatal("providersConfig with novita settings should not report empty") } } @@ -237,15 +241,6 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) { } } -// TestDefaultConfig_Model verifies model is set -func TestDefaultConfig_Model(t *testing.T) { - cfg := DefaultConfig() - - if cfg.Agents.Defaults.Model != "" { - t.Error("Model should be empty") - } -} - // TestDefaultConfig_MaxTokens verifies max tokens has default value func TestDefaultConfig_MaxTokens(t *testing.T) { cfg := DefaultConfig() @@ -288,21 +283,6 @@ func TestDefaultConfig_Gateway(t *testing.T) { } } -// TestDefaultConfig_Providers verifies provider structure -func TestDefaultConfig_Providers(t *testing.T) { - cfg := DefaultConfig() - - if cfg.Providers.Anthropic.APIKey != "" { - t.Error("Anthropic API key should be empty by default") - } - if cfg.Providers.OpenAI.APIKey != "" { - t.Error("OpenAI API key should be empty by default") - } - if cfg.Providers.OpenRouter.APIKey != "" { - t.Error("OpenRouter API key should be empty by default") - } -} - // TestDefaultConfig_Channels verifies channels are disabled by default func TestDefaultConfig_Channels(t *testing.T) { cfg := DefaultConfig() @@ -329,7 +309,7 @@ func TestDefaultConfig_WebTools(t *testing.T) { if cfg.Tools.Web.Brave.MaxResults != 5 { t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) } - if len(cfg.Tools.Web.Brave.APIKeys) != 0 { + if len(cfg.Tools.Web.Brave.APIKeys()) != 0 { t.Error("Brave API key should be empty by default") } if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { @@ -387,9 +367,6 @@ func TestConfig_Complete(t *testing.T) { if cfg.Agents.Defaults.Workspace == "" { t.Error("Workspace should not be empty") } - if cfg.Agents.Defaults.Model != "" { - t.Error("Model should be empty") - } if cfg.Agents.Defaults.Temperature != nil { t.Error("Temperature should be nil when not provided") } @@ -408,12 +385,8 @@ func TestConfig_Complete(t *testing.T) { if !cfg.Heartbeat.Enabled { t.Error("Heartbeat should be enabled by default") } -} - -func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { - cfg := DefaultConfig() - if !cfg.Providers.OpenAI.WebSearch { - t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true") + if !cfg.Tools.Exec.AllowRemote { + t.Error("Exec.AllowRemote should be true by default") } } @@ -427,7 +400,7 @@ func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) { func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"enabled":true}}}`), 0o600); err != nil { + if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"web":{"enabled":true}}}`), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } @@ -493,26 +466,11 @@ func TestDefaultConfig_LogLevel(t *testing.T) { } } -func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil { - t.Fatalf("WriteFile() error: %v", err) - } - - cfg, err := LoadConfig(configPath) - if err != nil { - t.Fatalf("LoadConfig() error: %v", err) - } - if !cfg.Providers.OpenAI.WebSearch { - t.Fatal("OpenAI codex web search should remain true when unset in config file") - } -} - func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil { + if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"exec":{"enable_deny_patterns":true}}}`), + 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } @@ -528,7 +486,11 @@ func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) { func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(configPath, []byte(`{"tools":{"cron":{"exec_timeout_minutes":5}}}`), 0o600); err != nil { + if err := os.WriteFile( + configPath, + []byte(`{"version":1,"tools":{"cron":{"exec_timeout_minutes":5}}}`), + 0o600, + ); err != nil { t.Fatalf("WriteFile() error: %v", err) } @@ -541,22 +503,6 @@ func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) { } } -func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil { - t.Fatalf("WriteFile() error: %v", err) - } - - cfg, err := LoadConfig(configPath) - if err != nil { - t.Fatalf("LoadConfig() error: %v", err) - } - if cfg.Providers.OpenAI.WebSearch { - t.Fatal("OpenAI codex web search should be false when disabled in config file") - } -} - func TestLoadConfig_WebToolsProxy(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") @@ -582,6 +528,7 @@ func TestLoadConfig_HooksProcessConfig(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") configJSON := `{ + "version": 1, "hooks": { "processes": { "review-gate": { @@ -834,7 +781,20 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) { func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") - const original = `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` + const original = `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` + if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + secPath := filepath.Join(dir, SecurityConfigFile) + const securityConfig = ` +model_list: + test:0: + api_keys: + - "sk-plaintext" +` + if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { t.Fatalf("setup: %v", err) } @@ -847,10 +807,10 @@ func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) { t.Fatalf("LoadConfig: %v", err) } // In-memory value must be the resolved plaintext. - if cfg.ModelList[0].APIKey != "sk-plaintext" { - t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext") + if cfg.ModelList[0].APIKey() != "sk-plaintext" { + t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey(), "sk-plaintext") } - // The file on disk must remain unchanged — LoadConfig must not write anything. + // The file on disk must remain unchanged — no need upgrade version raw, _ := os.ReadFile(cfgPath) if string(raw) != original { t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw)) @@ -867,15 +827,19 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) { mustSetupSSHKey(t) cfg := DefaultConfig() - cfg.ModelList = []ModelConfig{ - {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + cfg.ModelList = []*ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", apiKeys: []string{"sk-plaintext"}}, + } + cfg.security = &SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"sk-plaintext"}}}, } if err := SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("SaveConfig: %v", err) } // Disk must contain enc://, not the raw key. - raw, _ := os.ReadFile(cfgPath) + secPath := filepath.Join(dir, SecurityConfigFile) + raw, _ := os.ReadFile(secPath) if !strings.Contains(string(raw), "enc://") { t.Errorf("saved file should contain enc://, got:\n%s", string(raw)) } @@ -888,8 +852,8 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) { if err != nil { t.Fatalf("LoadConfig after SaveConfig: %v", err) } - if cfg2.ModelList[0].APIKey != "sk-plaintext" { - t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext") + if cfg2.ModelList[0].APIKey() != "sk-plaintext" { + t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey(), "sk-plaintext") } } @@ -925,10 +889,17 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) { if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { t.Fatalf("setup: %v", err) } - data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}` + data := `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4"}]}` if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { t.Fatalf("setup: %v", err) } + secPath := filepath.Join(dir, SecurityConfigFile) + if err := saveSecurityConfig( + secPath, + &SecurityConfig{ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"file://openai.key"}}}}, + ); err != nil { + t.Fatalf("saveSecurityConfig: %v", err) + } t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") t.Setenv("PICOCLAW_SSH_KEY_PATH", "") @@ -937,7 +908,7 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) { t.Fatalf("LoadConfig: %v", err) } - raw, _ := os.ReadFile(cfgPath) + raw, _ := os.ReadFile(secPath) if !strings.Contains(string(raw), "file://openai.key") { t.Error("file:// reference should be preserved unchanged in the config file") } @@ -957,23 +928,28 @@ func TestSaveConfig_MixedKeys(t *testing.T) { // Pre-encrypt one key so we have a genuine enc:// value to put in the config. if err := SaveConfig(cfgPath, &Config{ - ModelList: []ModelConfig{ - {ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"}, + ModelList: []*ModelConfig{ + {ModelName: "pre", Model: "openai/gpt-4"}, + }, + security: &SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{ + "pre:0": {APIKeys: []string{"sk-already-plain"}}, + }, }, }); err != nil { t.Fatalf("setup SaveConfig: %v", err) } - raw, _ := os.ReadFile(cfgPath) + raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile)) // Extract the enc:// value from the saved file. var tmp struct { - ModelList []struct { - APIKey string `json:"api_key"` - } `json:"model_list"` + ModelList map[string]struct { + APIKeys []string `yaml:"api_keys"` + } `yaml:"model_list"` } - if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 { + if err := yaml.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 { t.Fatalf("setup: could not parse saved config: %v", err) } - alreadyEncrypted := tmp.ModelList[0].APIKey + alreadyEncrypted := tmp.ModelList["pre:0"].APIKeys[0] if !strings.HasPrefix(alreadyEncrypted, "enc://") { t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted) } @@ -987,19 +963,28 @@ func TestSaveConfig_MixedKeys(t *testing.T) { t.Fatalf("setup: %v", err) } cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"}, - {ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted}, - {ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"}, + ModelList: []*ModelConfig{ + {ModelName: "plain", Model: "openai/gpt-4", apiKeys: []string{"sk-new-plaintext"}}, + {ModelName: "enc", Model: "openai/gpt-4", apiKeys: []string{alreadyEncrypted}}, + {ModelName: "file", Model: "openai/gpt-4", apiKeys: []string{"file://api.key"}}, + }, + security: &SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{ + "plain:0": {APIKeys: []string{"sk-new-plaintext"}}, + "enc:0": {APIKeys: []string{alreadyEncrypted}}, + "file:0": {APIKeys: []string{"file://api.key"}}, + }, }, } if err := SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("SaveConfig: %v", err) } - raw, _ = os.ReadFile(cfgPath) + raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile)) s := string(raw) + t.Logf("saved file:\n%s", s) + // 1. Plaintext must be encrypted. if strings.Contains(s, "sk-new-plaintext") { t.Error("plaintext key must not appear in saved file") @@ -1020,7 +1005,7 @@ func TestSaveConfig_MixedKeys(t *testing.T) { } byName := make(map[string]string) for _, m := range cfg2.ModelList { - byName[m.ModelName] = m.APIKey + byName[m.ModelName] = m.APIKey() } if byName["plain"] != "sk-new-plaintext" { t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext") @@ -1044,26 +1029,26 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) { t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") mustSetupSSHKey(t) if err := SaveConfig(cfgPath, &Config{ - ModelList: []ModelConfig{ - {ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"}, + ModelList: []*ModelConfig{ + {ModelName: "m", Model: "openai/gpt-4", apiKeys: []string{"sk-secret"}}, + }, + security: &SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{ + "m:0": {APIKeys: []string{"sk-secret"}}, + }, }, }); err != nil { t.Fatalf("setup SaveConfig: %v", err) } - raw, _ := os.ReadFile(cfgPath) - var tmp struct { - ModelList []struct { - APIKey string `json:"api_key"` - } `json:"model_list"` - } - if err := json.Unmarshal(raw, &tmp); err != nil { - t.Fatalf("setup parse: %v", err) - } - encValue := tmp.ModelList[0].APIKey + raw, err := LoadConfig(cfgPath) + assert.NoError(t, err) + encValue := raw.security.ModelList["m:0"].APIKeys[0] + assert.NotEmpty(t, encValue) + assert.Equal(t, "enc://", encValue[:6]) // Write a mixed config: enc:// + plaintext + file:// keyFile := filepath.Join(dir, "api.key") - if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + if err = os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { t.Fatalf("setup: %v", err) } mixed, _ := json.Marshal(map[string]any{ @@ -1073,14 +1058,24 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) { {"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"}, }, }) - if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil { + if err = os.WriteFile(cfgPath, mixed, 0o600); err != nil { t.Fatalf("setup write: %v", err) } + secs, _ := yaml.Marshal(map[string]any{ + "model_list": map[string]map[string]any{ + "enc:0": {"api_keys": []string{encValue}}, + "plain:0": {"api_keys": []string{"sk-plain"}}, + "file:0": {"api_keys": []string{"file://api.key"}}, + }, + }) + if err = os.WriteFile(filepath.Join(dir, SecurityConfigFile), secs, 0o600); err != nil { + t.Fatalf("security write: %v", err) + } // Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted. t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") - _, err := LoadConfig(cfgPath) + _, err = LoadConfig(cfgPath) if err == nil { t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set") } @@ -1108,14 +1103,15 @@ func TestSaveConfig_UsesPassphraseProvider(t *testing.T) { t.Cleanup(func() { credential.PassphraseProvider = orig }) cfg := DefaultConfig() - cfg.ModelList = []ModelConfig{ - {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + cfg.ModelList = []*ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4"}, } + cfg.security.ModelList["test:0"] = ModelSecurityEntry{APIKeys: []string{"sk-plaintext"}} if err := SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("SaveConfig: %v", err) } - raw, _ := os.ReadFile(cfgPath) + raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile)) if !strings.Contains(string(raw), "enc://") { t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw) } @@ -1158,15 +1154,15 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) { if err != nil { t.Fatalf("LoadConfig: %v", err) } - if cfg.ModelList[0].APIKey != plainKey { - t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey) + if cfg.ModelList[0].APIKey() != plainKey { + t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey(), plainKey) } } func TestConfigParsesLogLevel(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") - data := `{"gateway":{"log_level":"debug"}}` + data := `{"version":1,"gateway":{"log_level":"debug"}}` if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { t.Fatalf("setup: %v", err) } @@ -1183,7 +1179,7 @@ func TestConfigParsesLogLevel(t *testing.T) { func TestConfigLogLevelEmpty(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") - data := `{}` + data := `{"version":1}` if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { t.Fatalf("setup: %v", err) } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7c47c8474..18e0bbfd4 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -8,6 +8,8 @@ package config import ( "os" "path/filepath" + + "github.com/sipeed/picoclaw/pkg" ) // DefaultConfig returns the default configuration for PicoClaw. @@ -19,17 +21,17 @@ func DefaultConfig() *Config { homePath = picoclawHome } else { userHome, _ := os.UserHomeDir() - homePath = filepath.Join(userHome, ".picoclaw") + homePath = filepath.Join(userHome, pkg.DefaultPicoClawHome) } - workspacePath := filepath.Join(homePath, "workspace") + workspacePath := filepath.Join(homePath, pkg.WorkspaceName) return &Config{ + Version: CurrentVersion, Agents: AgentsConfig{ Defaults: AgentDefaults{ Workspace: workspacePath, RestrictToWorkspace: true, Provider: "", - Model: "", MaxTokens: 32768, Temperature: nil, // nil means use provider default MaxToolIterations: 50, @@ -56,7 +58,6 @@ func DefaultConfig() *Config { }, Telegram: TelegramConfig{ Enabled: false, - Token: "", AllowFrom: FlexibleStringSlice{}, Typing: TypingConfig{Enabled: true}, Placeholder: PlaceholderConfig{ @@ -67,16 +68,12 @@ func DefaultConfig() *Config { UseMarkdownV2: false, }, Feishu: FeishuConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - EncryptKey: "", - VerificationToken: "", - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + AppID: "", + AllowFrom: FlexibleStringSlice{}, }, Discord: DiscordConfig{ Enabled: false, - Token: "", AllowFrom: FlexibleStringSlice{}, MentionOnly: false, }, @@ -89,28 +86,23 @@ func DefaultConfig() *Config { QQ: QQConfig{ Enabled: false, AppID: "", - AppSecret: "", AllowFrom: FlexibleStringSlice{}, MaxMessageLength: 2000, MaxBase64FileSizeMiB: 0, }, DingTalk: DingTalkConfig{ - Enabled: false, - ClientID: "", - ClientSecret: "", - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + ClientID: "", + AllowFrom: FlexibleStringSlice{}, }, Slack: SlackConfig{ Enabled: false, - BotToken: "", - AppToken: "", AllowFrom: FlexibleStringSlice{}, }, Matrix: MatrixConfig{ Enabled: false, Homeserver: "https://matrix.org", UserID: "", - AccessToken: "", DeviceID: "", JoinOnInvite: true, AllowFrom: FlexibleStringSlice{}, @@ -123,51 +115,40 @@ func DefaultConfig() *Config { }, }, LINE: LINEConfig{ - Enabled: false, - ChannelSecret: "", - ChannelAccessToken: "", - WebhookHost: "0.0.0.0", - WebhookPort: 18791, - WebhookPath: "/webhook/line", - AllowFrom: FlexibleStringSlice{}, - GroupTrigger: GroupTriggerConfig{MentionOnly: true}, + Enabled: false, + WebhookHost: "0.0.0.0", + WebhookPort: 18791, + WebhookPath: "/webhook/line", + AllowFrom: FlexibleStringSlice{}, + GroupTrigger: GroupTriggerConfig{MentionOnly: true}, }, OneBot: OneBotConfig{ - Enabled: false, - WSUrl: "ws://127.0.0.1:3001", - AccessToken: "", - ReconnectInterval: 5, - GroupTriggerPrefix: []string{}, - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + WSUrl: "ws://127.0.0.1:3001", + ReconnectInterval: 5, + AllowFrom: FlexibleStringSlice{}, }, WeCom: WeComConfig{ - Enabled: false, - Token: "", - EncodingAESKey: "", - WebhookURL: "", - WebhookHost: "0.0.0.0", - WebhookPort: 18793, - WebhookPath: "/webhook/wecom", - AllowFrom: FlexibleStringSlice{}, - ReplyTimeout: 5, + Enabled: false, + WebhookURL: "", + WebhookHost: "0.0.0.0", + WebhookPort: 18793, + WebhookPath: "/webhook/wecom", + AllowFrom: FlexibleStringSlice{}, + ReplyTimeout: 5, }, WeComApp: WeComAppConfig{ - Enabled: false, - CorpID: "", - CorpSecret: "", - AgentID: 0, - Token: "", - EncodingAESKey: "", - WebhookHost: "0.0.0.0", - WebhookPort: 18792, - WebhookPath: "/webhook/wecom-app", - AllowFrom: FlexibleStringSlice{}, - ReplyTimeout: 5, + Enabled: false, + CorpID: "", + AgentID: 0, + WebhookHost: "0.0.0.0", + WebhookPort: 18792, + WebhookPath: "/webhook/wecom-app", + AllowFrom: FlexibleStringSlice{}, + ReplyTimeout: 5, }, WeComAIBot: WeComAIBotConfig{ Enabled: false, - Token: "", - EncodingAESKey: "", WebhookPath: "/webhook/wecom-aibot", AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, @@ -177,7 +158,6 @@ func DefaultConfig() *Config { }, Weixin: WeixinConfig{ Enabled: false, - Token: "", BaseURL: "https://ilinkai.weixin.qq.com/", CDNBaseURL: "https://novac2c.cdn.weixin.qq.com/c2c", AllowFrom: FlexibleStringSlice{}, @@ -185,7 +165,6 @@ func DefaultConfig() *Config { }, Pico: PicoConfig{ Enabled: false, - Token: "", PingInterval: 30, ReadTimeout: 60, WriteTimeout: 10, @@ -201,10 +180,7 @@ func DefaultConfig() *Config { ApprovalTimeoutMS: 60000, }, }, - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{WebSearch: true}, - }, - ModelList: []ModelConfig{ + ModelList: []*ModelConfig{ // ============================================ // Add your API key to the model you want to use // ============================================ @@ -214,7 +190,6 @@ func DefaultConfig() *Config { ModelName: "glm-4.7", Model: "zhipu/glm-4.7", APIBase: "https://open.bigmodel.cn/api/paas/v4", - APIKey: "", }, // OpenAI - https://platform.openai.com/api-keys @@ -222,7 +197,6 @@ func DefaultConfig() *Config { ModelName: "gpt-5.4", Model: "openai/gpt-5.4", APIBase: "https://api.openai.com/v1", - APIKey: "", }, // Anthropic Claude - https://console.anthropic.com/settings/keys @@ -230,7 +204,6 @@ func DefaultConfig() *Config { ModelName: "claude-sonnet-4.6", Model: "anthropic/claude-sonnet-4.6", APIBase: "https://api.anthropic.com/v1", - APIKey: "", }, // DeepSeek - https://platform.deepseek.com/ @@ -238,7 +211,6 @@ func DefaultConfig() *Config { ModelName: "deepseek-chat", Model: "deepseek/deepseek-chat", APIBase: "https://api.deepseek.com/v1", - APIKey: "", }, // Google Gemini - https://ai.google.dev/ @@ -246,7 +218,6 @@ func DefaultConfig() *Config { ModelName: "gemini-2.0-flash", Model: "gemini/gemini-2.0-flash-exp", APIBase: "https://generativelanguage.googleapis.com/v1beta", - APIKey: "", }, // Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey @@ -254,7 +225,6 @@ func DefaultConfig() *Config { ModelName: "qwen-plus", Model: "qwen/qwen-plus", APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1", - APIKey: "", }, // Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys @@ -262,7 +232,6 @@ func DefaultConfig() *Config { ModelName: "moonshot-v1-8k", Model: "moonshot/moonshot-v1-8k", APIBase: "https://api.moonshot.cn/v1", - APIKey: "", }, // Groq - https://console.groq.com/keys @@ -270,7 +239,6 @@ func DefaultConfig() *Config { ModelName: "llama-3.3-70b", Model: "groq/llama-3.3-70b-versatile", APIBase: "https://api.groq.com/openai/v1", - APIKey: "", }, // OpenRouter (100+ models) - https://openrouter.ai/keys @@ -278,13 +246,11 @@ func DefaultConfig() *Config { ModelName: "openrouter-auto", Model: "openrouter/auto", APIBase: "https://openrouter.ai/api/v1", - APIKey: "", }, { ModelName: "openrouter-gpt-5.4", Model: "openrouter/openai/gpt-5.4", APIBase: "https://openrouter.ai/api/v1", - APIKey: "", }, // NVIDIA - https://build.nvidia.com/ @@ -292,7 +258,6 @@ func DefaultConfig() *Config { ModelName: "nemotron-4-340b", Model: "nvidia/nemotron-4-340b-instruct", APIBase: "https://integrate.api.nvidia.com/v1", - APIKey: "", }, // Cerebras - https://inference.cerebras.ai/ @@ -300,7 +265,6 @@ func DefaultConfig() *Config { ModelName: "cerebras-llama-3.3-70b", Model: "cerebras/llama-3.3-70b", APIBase: "https://api.cerebras.ai/v1", - APIKey: "", }, // Vivgrid - https://vivgrid.com @@ -308,7 +272,6 @@ func DefaultConfig() *Config { ModelName: "vivgrid-auto", Model: "vivgrid/auto", APIBase: "https://api.vivgrid.com/v1", - APIKey: "", }, // Volcengine (火山引擎) - https://console.volcengine.com/ark @@ -316,13 +279,11 @@ func DefaultConfig() *Config { ModelName: "ark-code-latest", Model: "volcengine/ark-code-latest", APIBase: "https://ark.cn-beijing.volces.com/api/v3", - APIKey: "", }, { ModelName: "doubao-pro", Model: "volcengine/doubao-pro-32k", APIBase: "https://ark.cn-beijing.volces.com/api/v3", - APIKey: "", }, // ShengsuanYun (神算云) @@ -330,7 +291,6 @@ func DefaultConfig() *Config { ModelName: "deepseek-v3", Model: "shengsuanyun/deepseek-v3", APIBase: "https://api.shengsuanyun.com/v1", - APIKey: "", }, // Antigravity (Google Cloud Code Assist) - OAuth only @@ -353,7 +313,6 @@ func DefaultConfig() *Config { ModelName: "llama3", Model: "ollama/llama3", APIBase: "http://localhost:11434/v1", - APIKey: "ollama", }, // Mistral AI - https://console.mistral.ai/api-keys @@ -361,7 +320,6 @@ func DefaultConfig() *Config { ModelName: "mistral-small", Model: "mistral/mistral-small-latest", APIBase: "https://api.mistral.ai/v1", - APIKey: "", }, // Avian - https://avian.io @@ -369,13 +327,11 @@ func DefaultConfig() *Config { ModelName: "deepseek-v3.2", Model: "avian/deepseek/deepseek-v3.2", APIBase: "https://api.avian.io/v1", - APIKey: "", }, { ModelName: "kimi-k2.5", Model: "avian/moonshotai/kimi-k2.5", APIBase: "https://api.avian.io/v1", - APIKey: "", }, // Minimax - https://api.minimaxi.com/ @@ -383,7 +339,6 @@ func DefaultConfig() *Config { ModelName: "MiniMax-M2.5", Model: "minimax/MiniMax-M2.5", APIBase: "https://api.minimaxi.com/v1", - APIKey: "", }, // LongCat - https://longcat.chat/platform @@ -391,7 +346,6 @@ func DefaultConfig() *Config { ModelName: "LongCat-Flash-Thinking", Model: "longcat/LongCat-Flash-Thinking", APIBase: "https://api.longcat.chat/openai", - APIKey: "", }, // ModelScope (魔搭社区) - https://modelscope.cn/my/tokens @@ -399,7 +353,6 @@ func DefaultConfig() *Config { ModelName: "modelscope-qwen", Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", APIBase: "https://api-inference.modelscope.cn/v1", - APIKey: "", }, // VLLM (local) - http://localhost:8000 @@ -407,7 +360,6 @@ func DefaultConfig() *Config { ModelName: "local-model", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1", - APIKey: "", }, // Azure OpenAI - https://portal.azure.com @@ -416,7 +368,6 @@ func DefaultConfig() *Config { ModelName: "azure-gpt5", Model: "azure/my-gpt5-deployment", APIBase: "https://your-resource.openai.azure.com", - APIKey: "", }, }, Gateway: GatewayConfig{ @@ -443,14 +394,10 @@ func DefaultConfig() *Config { Format: "plaintext", Brave: BraveConfig{ Enabled: false, - APIKey: "", - APIKeys: nil, MaxResults: 5, }, Tavily: TavilyConfig{ Enabled: false, - APIKey: "", - APIKeys: nil, MaxResults: 5, }, DuckDuckGo: DuckDuckGoConfig{ @@ -459,8 +406,6 @@ func DefaultConfig() *Config { }, Perplexity: PerplexityConfig{ Enabled: false, - APIKey: "", - APIKeys: nil, MaxResults: 5, }, SearXNG: SearXNGConfig{ @@ -470,14 +415,12 @@ func DefaultConfig() *Config { }, GLMSearch: GLMSearchConfig{ Enabled: false, - APIKey: "", BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search", SearchEngine: "search_std", MaxResults: 5, }, BaiduSearch: BaiduSearchConfig{ Enabled: false, - APIKey: "", BaseURL: "https://qianfan.baidubce.com/v2/ai_search/web_search", MaxResults: 10, }, @@ -591,5 +534,10 @@ func DefaultConfig() *Config { BuildTime: BuildTime, GoVersion: GoVersion, }, + security: &SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{}, + Channels: ChannelsSecurity{}, + Web: WebToolsSecurity{}, + }, } } diff --git a/pkg/config/example_security_usage.go b/pkg/config/example_security_usage.go new file mode 100644 index 000000000..cba76c6bc --- /dev/null +++ b/pkg/config/example_security_usage.go @@ -0,0 +1,423 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// This file demonstrates how to use the security configuration feature +// It's not meant to be compiled, just for documentation purposes + +/* +Package config + +# Example: Using Security Configuration + +## 1. Create security.yml + +File: ~/.picoclaw/security.yml + +```yaml +# Model API Keys +# Note: Use 'api_keys' array for multiple keys (load balancing/failover) +# Single key should be provided as an array with one element +model_list: + + gpt-5.4: + api_keys: + - "sk-proj-your-actual-openai-key-1" + - "sk-proj-your-actual-openai-key-2" # Failover key + claude-sonnet-4.6: + api_keys: + - "sk-ant-your-actual-anthropic-key" # Single key in array format + +# Channel Tokens +channels: + + telegram: + token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" + discord: + token: "your-discord-bot-token" + +# Web Tool Keys +# Note: Use 'api_keys' array for multiple keys (load balancing/failover) +# For GLMSearch, use 'api_key' (single string) +web: + + brave: + api_keys: + - "BSAyour-brave-api-key-1" + - "BSAyour-brave-api-key-2" # Failover key + tavily: + api_keys: + - "tvly-your-tavily-api-key" # Single key in array format + glm_search: + api_key: "your-glm-search-api-key" # Single key (not array) + +``` + +## 2. Update config.json to use references + +File: ~/.picoclaw/config.json + +```json + + { + "version": 1, + "agents": { + "defaults": { + "workspace": "~/picoclaw-workspace", + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1", + "api_key": "ref:model_list.gpt-5.4.api_key" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_base": "https://api.anthropic.com/v1", + "api_key": "ref:model_list.claude-sonnet-4.6.api_key" + } + ], + "channels": { + "telegram": { + "enabled": true, + "token": "ref:channels.telegram.token" + }, + "discord": { + "enabled": true, + "token": "ref:channels.discord.token" + } + }, + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "ref:web.brave.api_key" + }, + "tavily": { + "enabled": true, + "api_key": "ref:web.tavily.api_key" + } + } + } + } + +``` + +## 3. Set proper permissions + +```bash +chmod 600 ~/.picoclaw/security.yml +``` + +## 4. Add to .gitignore + +```gitignore +# Security configuration +.security.yml +``` + +## 5. Verify it works + +```bash +picoclaw --version +``` + +# Available Reference Paths + +## Model API Keys +- ref:model_list..api_key + +Examples: +- ref:model_list.gpt-5.4.api_key +- ref:model_list.claude-sonnet-4.6.api_key + +**Note:** In .security.yml, use `api_keys` (array) format for models. +Both single and multiple keys should use the array format. + +## Channel Tokens/Secrets +- ref:channels.telegram.token +- ref:channels.feishu.app_secret +- ref:channels.feishu.encrypt_key +- ref:channels.feishu.verification_token +- ref:channels.discord.token +- ref:channels.qq.app_secret +- ref:channels.dingtalk.client_secret +- ref:channels.slack.bot_token +- ref:channels.slack.app_token +- ref:channels.matrix.access_token +- ref:channels.line.channel_secret +- ref:channels.line.channel_access_token +- ref:channels.onebot.access_token +- ref:channels.wecom.token +- ref:channels.wecom.encoding_aes_key +- ref:channels.wecom_app.corp_secret +- ref:channels.wecom_app.token +- ref:channels.wecom_app.encoding_aes_key +- ref:channels.wecom_aibot.token +- ref:channels.wecom_aibot.encoding_aes_key +- ref:channels.pico.token +- ref:channels.irc.password +- ref:channels.irc.nickserv_password +- ref:channels.irc.sasl_password + +## Web Tool API Keys +- ref:web.brave.api_key +- ref:web.tavily.api_key +- ref:web.perplexity.api_key +- ref:web.glm_search.api_key + +**Note:** +- Brave, Tavily, Perplexity: Use `api_keys` (array) format in .security.yml +- GLMSearch: Use `api_key` (single string) format in .security.yml + +## Skills Registry Tokens +- ref:skills.github.token +- ref:skills.clawhub.auth_token + +# Backward Compatibility + +You can still use direct values in config.json if needed: + +```json + + { + "model_list": [ + { + "model_name": "local-model", + "model": "ollama/llama3", + "api_base": "http://localhost:11434/v1", + "api_key": "ollama" // Direct value (no reference) + } + ] + } + +``` + +You can also mix references and direct values: + +```json + + { + "model_list": [ + { + "model_name": "cloud-model", + "api_key": "ref:model_list.cloud-model.api_key" // From .security.yml + }, + { + "model_name": "local-model", + "api_key": "ollama" // Direct value + } + ] + } + +``` + +# Migration from Old Config + +## Step 1: Backup your config +```bash +cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup +``` + +## Step 2: Copy the example security file +```bash +cp security.example.yml ~/.picoclaw/.security.yml +``` + +## Step 3: Fill in your API keys +Edit ~/.picoclaw/.security.yml and replace placeholders with your actual keys. + +## Step 4: Update config.json references +Replace sensitive values in ~/.picoclaw/config.json with ref: references. + +## Step 5: Test +```bash +picoclaw --version +``` + +If everything works, you can delete the backup: +```bash +rm ~/.picoclaw/config.json.backup +``` + +# Advanced Features + +## Multiple API Keys (Load Balancing & Failover) + +You can configure multiple API keys for both models and web tools to enable: +- **Load balancing**: Requests are distributed across multiple keys +- **Failover**: If a key fails, the system automatically switches to another key + +### Example: Model with Multiple Keys + +**.security.yml:** +```yaml +model_list: + + gpt-5.4: + api_keys: + - "sk-proj-key-1" + - "sk-proj-key-2" + - "sk-proj-key-3" + +``` + +**config.json:** +```json + + { + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "ref:model_list.gpt-5.4.api_key" + } + ] + } + +``` + +### Example: Web Tool with Multiple Keys + +**.security.yml:** +```yaml +web: + + brave: + api_keys: + - "BSA-key-1" + - "BSA-key-2" + tavily: + api_keys: + - "tvly-your-key" # Single key in array format + glm_search: + api_key: "your-glm-key" # GLMSearch uses single key format + +``` + +**config.json:** +```json + + { + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "ref:web.brave.api_key" + } + } + } + } + +``` + +### Single Key + +Use array format with one element: +```yaml +model_list: + + gpt-5.4: + api_keys: + - "sk-proj-your-key" # Single key in array format + +``` + +### Multiple Keys (Load Balancing & Failover) + +Use array format with multiple elements: +```yaml +model_list: + + gpt-5.4: + api_keys: + - "sk-proj-key-1" + - "sk-proj-key-2" + - "sk-proj-key-3" + +``` + +**Important:** All model keys in .security.yml must use the `api_keys` (plural) array format. +The single `api_key` (singular) format is NOT supported for models. + +### Model Index Matching + +The system supports intelligent model name matching in .security.yml: + +**Example 1: Exact Match** +```yaml +# config.json + + { + "model_name": "gpt-5.4:0" + } + +# .security.yml (exact match with index) +model_list: + + gpt-5.4:0: + api_keys: ["key-1"] + +``` + +**Example 2: Base Name Match** +```yaml +# config.json + + { + "model_name": "gpt-5.4:0" + } + +# .security.yml (base name without index) +model_list: + + gpt-5.4: + api_keys: ["key-1"] + +``` + +Both methods work. The base name match allows you to use simpler keys in .security.yml +even when your config uses indexed model names for load balancing. + +### Security File Permissions + +The security file should have restricted permissions: + +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +This ensures only the owner can read and write the file. + +# Security Best Practices + +1. Never commit .security.yml to version control +2. Set file permissions: chmod 600 ~/.picoclaw/.security.yml +3. Use different keys for different environments +4. Rotate keys regularly and update .security.yml +5. Encrypt backups containing .security.yml + +# Troubleshooting + +## Error: "model security entry not found" +- Check that the model name in config.json matches exactly in .security.yml +- Verify the model_list section exists in .security.yml + +## Error: "failed to load security config" +- Ensure .security.yml exists in the same directory as config.json +- Check YAML syntax is valid +- Verify file permissions allow reading + +## Error: "unknown reference path" +- Verify the reference format is correct +- Check the path structure matches the examples above +- Ensure all required sections exist in .security.yml +*/ +package config + +// This file is documentation only diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 832d8bf17..fee800a76 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -6,10 +6,15 @@ package config import ( + "encoding/json" "slices" "strings" ) +type migratable interface { + Migrate() (*Config, error) +} + // buildModelWithProtocol constructs a model string with protocol prefix. // If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is. // Otherwise, the protocol prefix is added. @@ -21,31 +26,31 @@ func buildModelWithProtocol(protocol, model string) string { return protocol + "/" + model } -// providerMigrationConfig defines how to migrate a provider from old config to new format. -type providerMigrationConfig struct { - // providerNames are the possible names used in agents.defaults.provider - providerNames []string - // protocol is the protocol prefix for the model field - protocol string - // buildConfig creates the ModelConfig from ProviderConfig - buildConfig func(p ProvidersConfig) (ModelConfig, bool) -} - -// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig. +// v0ConvertProvidersToModelList converts the old providersConfigV0 to a slice of ModelConfig. // This enables backward compatibility with existing configurations. // It preserves the user's configured model from agents.defaults.model when possible. -func ConvertProvidersToModelList(cfg *Config) []ModelConfig { +func v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 { if cfg == nil { return nil } + // providerMigrationConfig defines how to migrate a provider from old config to new format. + type providerMigrationConfig struct { + // providerNames are the possible names used in agents.defaults.provider + providerNames []string + // protocol is the protocol prefix for the model field + protocol string + // buildConfig creates the ModelConfig from ProviderConfig + buildConfig func(p providersConfigV0) (modelConfigV0, bool) + } + // Get user's configured provider and model userProvider := strings.ToLower(cfg.Agents.Defaults.Provider) userModel := cfg.Agents.Defaults.GetModelName() p := cfg.Providers - var result []ModelConfig + var result []modelConfigV0 // Track if we've applied the legacy model name fix (only for first provider) legacyModelNameApplied := false @@ -55,11 +60,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"openai", "gpt"}, protocol: "openai", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "openai", Model: "openai/gpt-5.4", APIKey: p.OpenAI.APIKey, @@ -73,11 +78,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"anthropic", "claude"}, protocol: "anthropic", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "anthropic", Model: "anthropic/claude-sonnet-4.6", APIKey: p.Anthropic.APIKey, @@ -91,11 +96,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"litellm"}, protocol: "litellm", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "litellm", Model: "litellm/auto", APIKey: p.LiteLLM.APIKey, @@ -108,11 +113,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"openrouter"}, protocol: "openrouter", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "openrouter", Model: "openrouter/auto", APIKey: p.OpenRouter.APIKey, @@ -125,11 +130,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"groq"}, protocol: "groq", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Groq.APIKey == "" && p.Groq.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "groq", Model: "groq/llama-3.1-70b-versatile", APIKey: p.Groq.APIKey, @@ -142,11 +147,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"zhipu", "glm"}, protocol: "zhipu", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "zhipu", Model: "zhipu/glm-4", APIKey: p.Zhipu.APIKey, @@ -159,11 +164,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"vllm"}, protocol: "vllm", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "vllm", Model: "vllm/auto", APIKey: p.VLLM.APIKey, @@ -176,11 +181,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"gemini", "google"}, protocol: "gemini", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "gemini", Model: "gemini/gemini-pro", APIKey: p.Gemini.APIKey, @@ -193,11 +198,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"nvidia"}, protocol: "nvidia", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "nvidia", Model: "nvidia/meta/llama-3.1-8b-instruct", APIKey: p.Nvidia.APIKey, @@ -210,11 +215,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"ollama"}, protocol: "ollama", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "ollama", Model: "ollama/llama3", APIKey: p.Ollama.APIKey, @@ -227,11 +232,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"moonshot", "kimi"}, protocol: "moonshot", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "moonshot", Model: "moonshot/kimi", APIKey: p.Moonshot.APIKey, @@ -244,11 +249,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"shengsuanyun"}, protocol: "shengsuanyun", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "shengsuanyun", Model: "shengsuanyun/auto", APIKey: p.ShengSuanYun.APIKey, @@ -261,11 +266,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"deepseek"}, protocol: "deepseek", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "deepseek", Model: "deepseek/deepseek-chat", APIKey: p.DeepSeek.APIKey, @@ -278,11 +283,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"cerebras"}, protocol: "cerebras", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "cerebras", Model: "cerebras/llama-3.3-70b", APIKey: p.Cerebras.APIKey, @@ -295,11 +300,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"vivgrid"}, protocol: "vivgrid", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "vivgrid", Model: "vivgrid/auto", APIKey: p.Vivgrid.APIKey, @@ -312,11 +317,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"volcengine", "doubao"}, protocol: "volcengine", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "volcengine", Model: "volcengine/doubao-pro", APIKey: p.VolcEngine.APIKey, @@ -329,11 +334,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"github_copilot", "copilot"}, protocol: "github-copilot", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "github-copilot", Model: "github-copilot/gpt-5.4", APIBase: p.GitHubCopilot.APIBase, @@ -344,11 +349,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"antigravity"}, protocol: "antigravity", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "antigravity", Model: "antigravity/gemini-2.0-flash", APIKey: p.Antigravity.APIKey, @@ -359,11 +364,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"qwen", "tongyi"}, protocol: "qwen", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "qwen", Model: "qwen/qwen-max", APIKey: p.Qwen.APIKey, @@ -376,11 +381,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"mistral"}, protocol: "mistral", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "mistral", Model: "mistral/mistral-small-latest", APIKey: p.Mistral.APIKey, @@ -393,11 +398,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"avian"}, protocol: "avian", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.Avian.APIKey == "" && p.Avian.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "avian", Model: "avian/deepseek/deepseek-v3.2", APIKey: p.Avian.APIKey, @@ -410,11 +415,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"longcat"}, protocol: "longcat", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "longcat", Model: "longcat/LongCat-Flash-Thinking", APIKey: p.LongCat.APIKey, @@ -427,11 +432,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { { providerNames: []string{"modelscope"}, protocol: "modelscope", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" { - return ModelConfig{}, false + return modelConfigV0{}, false } - return ModelConfig{ + return modelConfigV0{ ModelName: "modelscope", Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", APIKey: p.ModelScope.APIKey, @@ -469,83 +474,63 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { return result } -// protocolProviderMapping maps a model protocol prefix (the part before "/" in -// the Model field) to a function that extracts the corresponding ProviderConfig -// from the legacy ProvidersConfig. Used by InheritProviderCredentials. -var protocolProviderMapping = map[string]func(p ProvidersConfig) ProviderConfig{ - "openai": func(p ProvidersConfig) ProviderConfig { return p.OpenAI.ProviderConfig }, - "anthropic": func(p ProvidersConfig) ProviderConfig { return p.Anthropic }, - "litellm": func(p ProvidersConfig) ProviderConfig { return p.LiteLLM }, - "openrouter": func(p ProvidersConfig) ProviderConfig { return p.OpenRouter }, - "groq": func(p ProvidersConfig) ProviderConfig { return p.Groq }, - "zhipu": func(p ProvidersConfig) ProviderConfig { return p.Zhipu }, - "vllm": func(p ProvidersConfig) ProviderConfig { return p.VLLM }, - "gemini": func(p ProvidersConfig) ProviderConfig { return p.Gemini }, - "nvidia": func(p ProvidersConfig) ProviderConfig { return p.Nvidia }, - "ollama": func(p ProvidersConfig) ProviderConfig { return p.Ollama }, - "moonshot": func(p ProvidersConfig) ProviderConfig { return p.Moonshot }, - "shengsuanyun": func(p ProvidersConfig) ProviderConfig { return p.ShengSuanYun }, - "deepseek": func(p ProvidersConfig) ProviderConfig { return p.DeepSeek }, - "cerebras": func(p ProvidersConfig) ProviderConfig { return p.Cerebras }, - "vivgrid": func(p ProvidersConfig) ProviderConfig { return p.Vivgrid }, - "volcengine": func(p ProvidersConfig) ProviderConfig { return p.VolcEngine }, - "github-copilot": func(p ProvidersConfig) ProviderConfig { return p.GitHubCopilot }, - "antigravity": func(p ProvidersConfig) ProviderConfig { return p.Antigravity }, - "qwen": func(p ProvidersConfig) ProviderConfig { return p.Qwen }, - "mistral": func(p ProvidersConfig) ProviderConfig { return p.Mistral }, - "avian": func(p ProvidersConfig) ProviderConfig { return p.Avian }, - "minimax": func(p ProvidersConfig) ProviderConfig { return p.Minimax }, - "longcat": func(p ProvidersConfig) ProviderConfig { return p.LongCat }, - "modelscope": func(p ProvidersConfig) ProviderConfig { return p.ModelScope }, - "novita": func(p ProvidersConfig) ProviderConfig { return p.Novita }, -} - -// InheritProviderCredentials fills in missing api_key, api_base, proxy, and -// request_timeout on model_list entries from the matching legacy providers -// configuration. The match is determined by the protocol prefix in the Model -// field (e.g. "deepseek/deepseek-chat" matches providers.deepseek). -// -// Only empty fields are filled — any value explicitly set on a model_list entry -// takes precedence. This function modifies the slice in place. -// -// This bridges the gap described in issue #1635: users who configure -// credentials once in the providers section expect model_list entries using -// the same protocol to "just work" without duplicating credentials. -func InheritProviderCredentials(models []ModelConfig, providers ProvidersConfig) { - if providers.IsEmpty() { - return +// loadConfigV0 loads a legacy config (no version field) +func loadConfigV0(data []byte) (migratable, error) { + var v0 configV0 + if err := json.Unmarshal(data, &v0); err != nil { + return nil, err } - for i := range models { - m := &models[i] + v0.migrateChannelConfigs() - // Extract protocol prefix from Model field - protocol := "" - if idx := strings.Index(m.Model, "/"); idx > 0 { - protocol = strings.ToLower(m.Model[:idx]) - } - if protocol == "" { - continue - } - - getProvider, ok := protocolProviderMapping[protocol] - if !ok { - continue - } - pc := getProvider(providers) - - // Only fill empty fields — explicit model_list values win - if m.APIKey == "" && pc.APIKey != "" { - m.APIKey = pc.APIKey - } - if m.APIBase == "" && pc.APIBase != "" { - m.APIBase = pc.APIBase - } - if m.Proxy == "" && pc.Proxy != "" { - m.Proxy = pc.Proxy - } - if m.RequestTimeout == 0 && pc.RequestTimeout != 0 { - m.RequestTimeout = pc.RequestTimeout + // Auto-migrate: if only legacy providers config exists, convert to model_list + if len(v0.ModelList) == 0 && !v0.Providers.IsEmpty() { + newModelList := v0ConvertProvidersToModelList(&v0) + // Convert []ModelConfig to []modelConfigV0 + v0.ModelList = make([]modelConfigV0, len(newModelList)) + for i, m := range newModelList { + v0.ModelList[i] = modelConfigV0{ + ModelName: m.ModelName, + Model: m.Model, + APIBase: m.APIBase, + Proxy: m.Proxy, + Fallbacks: m.Fallbacks, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + APIKey: m.APIKey, + APIKeys: m.APIKeys, + } } } + + return &v0, nil +} + +// loadConfigV1 loads a version 1 config (current schema) +func loadConfig(data []byte) (*Config, error) { + cfg := DefaultConfig() + + // Pre-scan the JSON to check how many model_list entries the user provided. + // Go's JSON decoder reuses existing slice backing-array elements rather than + // zero-initializing them, so fields absent from the user's JSON (e.g. api_base) + // would silently inherit values from the DefaultConfig template at the same + // index position. We only reset cfg.ModelList when the user actually provides + // entries; when count is 0 we keep DefaultConfig's built-in list as fallback. + var tmp Config + if err := json.Unmarshal(data, &tmp); err != nil { + return nil, err + } + if len(tmp.ModelList) > 0 { + cfg.ModelList = nil + } + + if err := json.Unmarshal(data, cfg); err != nil { + return nil, err + } + return cfg, nil } diff --git a/pkg/config/migration_integration_test.go b/pkg/config/migration_integration_test.go new file mode 100644 index 000000000..c884a6b5d --- /dev/null +++ b/pkg/config/migration_integration_test.go @@ -0,0 +1,568 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestMigration_Integration_LegacyConfigWithoutWorkspace tests the issue reported: +// User configured Model and Provider but no Workspace - settings should not be lost +func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) { + // Create a temporary directory for test config files + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Create a legacy config (version 0) with Model and Provider but NO Workspace + // This simulates the real-world scenario where user settings would be lost + legacyConfig := `{ + "agents": { + "defaults": { + "provider": "openai", + "model": "gpt-4o", + "max_tokens": 8192, + "temperature": 0.7 + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "test-token" + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": { + "enabled": true + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + // Load the config - this should trigger migration + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Verify version is updated + if cfg.Version != CurrentVersion { + t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion) + } + + // CRITICAL: Verify that user's settings are preserved + // This was the bug - these settings were lost when Workspace was empty + if cfg.Agents.Defaults.Provider != "openai" { + t.Errorf("Provider = %q, want %q (user's setting should be preserved)", cfg.Agents.Defaults.Provider, "openai") + } + // Old "model" field is migrated to "model_name" field + if cfg.Agents.Defaults.ModelName != "gpt-4o" { + t.Errorf( + "ModelName = %q, want %q (user's setting should be preserved)", + cfg.Agents.Defaults.ModelName, "gpt-4o", + ) + } + // GetModelName() should also return the migrated value + if cfg.Agents.Defaults.GetModelName() != "gpt-4o" { + t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "gpt-4o") + } + if cfg.Agents.Defaults.MaxTokens != 8192 { + t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 8192) + } + if cfg.Agents.Defaults.Temperature == nil { + t.Error("Temperature should not be nil") + } else if *cfg.Agents.Defaults.Temperature != 0.7 { + t.Errorf("Temperature = %v, want %v", *cfg.Agents.Defaults.Temperature, 0.7) + } + + // Verify Workspace has a default value (should not be empty) + if cfg.Agents.Defaults.Workspace == "" { + t.Error("Workspace should have a default value, not be empty") + } + + // Verify other config sections are preserved + if !cfg.Channels.Telegram.Enabled { + t.Error("Telegram.Enabled should be true") + } + if cfg.Channels.Telegram.Token() != "test-token" { + t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token(), "test-token") + } + if cfg.Gateway.Port != 18790 { + t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 18790) + } +} + +// TestMigration_Integration_LegacyConfigWithWorkspace tests migration with Workspace set +func TestMigration_Integration_LegacyConfigWithWorkspace(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + legacyConfig := `{ + "agents": { + "defaults": { + "workspace": "/custom/workspace", + "provider": "deepseek", + "model": "deepseek-chat", + "max_tokens": 16384 + } + }, + "channels": { + "telegram": { + "enabled": false + } + }, + "gateway": { + "host": "0.0.0.0", + "port": 8080 + }, + "tools": { + "web": { + "enabled": false + } + }, + "heartbeat": { + "enabled": false + }, + "devices": { + "enabled": true + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // All user settings should be preserved + if cfg.Agents.Defaults.Workspace != "/custom/workspace" { + t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "/custom/workspace") + } + if cfg.Agents.Defaults.Provider != "deepseek" { + t.Errorf("Provider = %q, want %q", cfg.Agents.Defaults.Provider, "deepseek") + } + if cfg.Agents.Defaults.ModelName != "deepseek-chat" { + t.Errorf("ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "deepseek-chat") + } + if cfg.Agents.Defaults.MaxTokens != 16384 { + t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 16384) + } + + // Verify other settings + if cfg.Gateway.Port != 8080 { + t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 8080) + } + if !cfg.Devices.Enabled { + t.Error("Devices.Enabled should be true") + } +} + +// TestMigration_Integration_PreservesAllAgentsFields tests that ALL Agents fields are preserved +func TestMigration_Integration_PreservesAllAgentsFields(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + legacyConfig := `{ + "agents": { + "defaults": { + "workspace": "", + "restrict_to_workspace": false, + "allow_read_outside_workspace": true, + "provider": "anthropic", + "model": "claude-opus-4", + "model_fallbacks": ["claude-sonnet-4", "claude-haiku-4"], + "image_model": "claude-opus-4-vision", + "image_model_fallbacks": ["claude-sonnet-4-vision"], + "max_tokens": 4096, + "temperature": 0.5, + "max_tool_iterations": 100, + "summarize_message_threshold": 30, + "summarize_token_percent": 80, + "max_media_size": 10485760 + }, + "list": [ + { + "id": "special-agent", + "default": false, + "name": "Special Agent", + "workspace": "/special/workspace" + } + ] + }, + "channels": { + "telegram": {"enabled": false} + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Verify ALL defaults fields are preserved + d := cfg.Agents.Defaults + + if d.RestrictToWorkspace != false { + t.Errorf("RestrictToWorkspace = %v, want false", d.RestrictToWorkspace) + } + if d.AllowReadOutsideWorkspace != true { + t.Errorf("AllowReadOutsideWorkspace = %v, want true", d.AllowReadOutsideWorkspace) + } + if d.Provider != "anthropic" { + t.Errorf("Provider = %q, want %q", d.Provider, "anthropic") + } + if d.ModelName != "claude-opus-4" { + t.Errorf("ModelName = %q, want %q", d.ModelName, "claude-opus-4") + } + if len(d.ModelFallbacks) != 2 { + t.Errorf("len(ModelFallbacks) = %d, want 2", len(d.ModelFallbacks)) + } else { + if d.ModelFallbacks[0] != "claude-sonnet-4" { + t.Errorf("ModelFallbacks[0] = %q, want %q", d.ModelFallbacks[0], "claude-sonnet-4") + } + if d.ModelFallbacks[1] != "claude-haiku-4" { + t.Errorf("ModelFallbacks[1] = %q, want %q", d.ModelFallbacks[1], "claude-haiku-4") + } + } + if d.ImageModel != "claude-opus-4-vision" { + t.Errorf("ImageModel = %q, want %q", d.ImageModel, "claude-opus-4-vision") + } + if len(d.ImageModelFallbacks) != 1 { + t.Errorf("len(ImageModelFallbacks) = %d, want 1", len(d.ImageModelFallbacks)) + } else if d.ImageModelFallbacks[0] != "claude-sonnet-4-vision" { + t.Errorf("ImageModelFallbacks[0] = %q, want %q", d.ImageModelFallbacks[0], "claude-sonnet-4-vision") + } + if d.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d, want %d", d.MaxTokens, 4096) + } + if d.Temperature == nil || *d.Temperature != 0.5 { + t.Errorf("Temperature = %v, want 0.5", d.Temperature) + } + if d.MaxToolIterations != 100 { + t.Errorf("MaxToolIterations = %d, want %d", d.MaxToolIterations, 100) + } + if d.SummarizeMessageThreshold != 30 { + t.Errorf("SummarizeMessageThreshold = %d, want %d", d.SummarizeMessageThreshold, 30) + } + if d.SummarizeTokenPercent != 80 { + t.Errorf("SummarizeTokenPercent = %d, want %d", d.SummarizeTokenPercent, 80) + } + if d.MaxMediaSize != 10485760 { + t.Errorf("MaxMediaSize = %d, want %d", d.MaxMediaSize, 10485760) + } + + // Verify agent list is preserved + if len(cfg.Agents.List) != 1 { + t.Fatalf("len(Agents.List) = %d, want 1", len(cfg.Agents.List)) + } + if cfg.Agents.List[0].ID != "special-agent" { + t.Errorf("Agent.ID = %q, want %q", cfg.Agents.List[0].ID, "special-agent") + } + if cfg.Agents.List[0].Workspace != "/special/workspace" { + t.Errorf("Agent.Workspace = %q, want %q", cfg.Agents.List[0].Workspace, "/special/workspace") + } + + // Workspace should have default since it was empty in legacy config + if d.Workspace == "" { + t.Error("Workspace should have a default value, not be empty") + } +} + +// TestMigration_Integration_ChannelsConfigMigrated tests channel config migration +func TestMigration_Integration_ChannelsConfigMigrated(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Legacy config with old channel field formats + legacyConfig := `{ + "agents": { + "defaults": {} + }, + "channels": { + "discord": { + "enabled": true, + "token": "discord-token", + "mention_only": true + }, + "onebot": { + "enabled": true, + "ws_url": "ws://127.0.0.1:3001", + "group_trigger_prefix": ["/", "!"] + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Discord: mention_only should be migrated to group_trigger.mention_only + if cfg.Channels.Discord.GroupTrigger.MentionOnly != true { + t.Error("Discord.GroupTrigger.MentionOnly should be true after migration") + } + + // OneBot: group_trigger_prefix should be migrated to group_trigger.prefixes + if len(cfg.Channels.OneBot.GroupTrigger.Prefixes) != 2 { + t.Errorf("len(OneBot.GroupTrigger.Prefixes) = %d, want 2", len(cfg.Channels.OneBot.GroupTrigger.Prefixes)) + } else { + if cfg.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" { + t.Errorf("Prefixes[0] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[0], "/") + } + if cfg.Channels.OneBot.GroupTrigger.Prefixes[1] != "!" { + t.Errorf("Prefixes[1] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[1], "!") + } + } +} + +// TestMigration_Integration_RoundTrip_SerializeAndLoad tests that migrated config can be saved and reloaded +func TestMigration_Integration_RoundTrip_SerializeAndLoad(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + legacyConfig := `{ + "agents": { + "defaults": { + "provider": "openai", + "model": "gpt-4o", + "max_tokens": 8192 + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "test-token" + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + // First load - triggers migration and saves + cfg1, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("First LoadConfig failed: %v", err) + } + + // Read the migrated config from disk + migratedData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read migrated config: %v", err) + } + + // Verify it has the current version + var versionCheck struct { + Version int `json:"version"` + } + if err = json.Unmarshal(migratedData, &versionCheck); err != nil { + t.Fatalf("Failed to parse migrated config version: %v", err) + } + if versionCheck.Version != CurrentVersion { + t.Errorf("Migrated config version = %d, want %d", versionCheck.Version, CurrentVersion) + } + + // Second load - should load the migrated config without changes + cfg2, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("Second LoadConfig failed: %v", err) + } + + // Verify configs are identical + if cfg2.Agents.Defaults.Provider != cfg1.Agents.Defaults.Provider { + t.Errorf("Provider changed from %q to %q", cfg1.Agents.Defaults.Provider, cfg2.Agents.Defaults.Provider) + } + if cfg2.Agents.Defaults.ModelName != cfg1.Agents.Defaults.ModelName { + t.Errorf("ModelName changed from %q to %q", cfg1.Agents.Defaults.ModelName, cfg2.Agents.Defaults.ModelName) + } + if cfg2.Agents.Defaults.MaxTokens != cfg1.Agents.Defaults.MaxTokens { + t.Errorf("MaxTokens changed from %d to %d", cfg1.Agents.Defaults.MaxTokens, cfg2.Agents.Defaults.MaxTokens) + } +} + +// TestMigration_Integration_EmptyAgentsDefaults tests migration with completely empty agents config +func TestMigration_Integration_EmptyAgentsDefaults(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Legacy config with empty agents defaults + legacyConfig := `{ + "agents": { + "defaults": {} + }, + "channels": { + "telegram": {"enabled": false} + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Workspace should have default value + if cfg.Agents.Defaults.Workspace == "" { + t.Error("Workspace should have a default value") + } + + // Note: When fields are explicitly set in config (even to zero values), + // they override defaults. This is correct JSON unmarshaling behavior. + // Users should set values they want; defaults are for unspecified fields. + if cfg.Agents.Defaults.MaxTokens == 0 { + // This is expected when users don't set max_tokens in their config + // The zero value (0) from the legacy config is preserved + } + if cfg.Agents.Defaults.MaxToolIterations == 0 { + // Same as above - zero value is preserved if it was in the config + } +} + +// TestMigration_Integration_ModelNameField tests migration using new model_name field +func TestMigration_Integration_ModelNameField(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Legacy config using the new model_name field + legacyConfig := `{ + "agents": { + "defaults": { + "provider": "deepseek", + "model_name": "deepseek-reasoner", + "model_fallbacks": ["deepseek-chat"] + } + }, + "channels": { + "telegram": {"enabled": false} + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // model_name field should be preserved + if cfg.Agents.Defaults.ModelName != "deepseek-reasoner" { + t.Errorf("ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "deepseek-reasoner") + } + + // GetModelName() should return model_name, not model (deprecated) + if cfg.Agents.Defaults.GetModelName() != "deepseek-reasoner" { + t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "deepseek-reasoner") + } + + if len(cfg.Agents.Defaults.ModelFallbacks) != 1 { + t.Errorf("len(ModelFallbacks) = %d, want 1", len(cfg.Agents.Defaults.ModelFallbacks)) + } else if cfg.Agents.Defaults.ModelFallbacks[0] != "deepseek-chat" { + t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat") + } +} diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index bea5b9034..aeabe9730 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -11,10 +11,10 @@ import ( ) func TestConvertProvidersToModelList_OpenAI(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ - ProviderConfig: ProviderConfig{ + cfg := &configV0{ + Providers: providersConfigV0{ + OpenAI: openAIProviderConfigV0{ + providerConfigV0: providerConfigV0{ APIKey: "sk-test-key", APIBase: "https://custom.api.com/v1", }, @@ -22,7 +22,7 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) { }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -40,16 +40,15 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) { } func TestConvertProvidersToModelList_Anthropic(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - Anthropic: ProviderConfig{ - APIKey: "ant-key", + cfg := &configV0{ + Providers: providersConfigV0{ + Anthropic: providerConfigV0{ APIBase: "https://custom.anthropic.com", }, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -64,16 +63,15 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) { } func TestConvertProvidersToModelList_LiteLLM(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - LiteLLM: ProviderConfig{ - APIKey: "litellm-key", + cfg := &configV0{ + Providers: providersConfigV0{ + LiteLLM: providerConfigV0{ APIBase: "http://localhost:4000/v1", }, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -91,15 +89,15 @@ func TestConvertProvidersToModelList_LiteLLM(t *testing.T) { } func TestConvertProvidersToModelList_Multiple(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}}, - Groq: ProviderConfig{APIKey: "groq-key"}, - Zhipu: ProviderConfig{APIKey: "zhipu-key"}, + cfg := &configV0{ + Providers: providersConfigV0{ + OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, + Groq: providerConfigV0{APIKey: "groq-key"}, + Zhipu: providerConfigV0{APIKey: "zhipu-key"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 3 { t.Fatalf("len(result) = %d, want 3", len(result)) @@ -119,11 +117,11 @@ func TestConvertProvidersToModelList_Multiple(t *testing.T) { } func TestConvertProvidersToModelList_Empty(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{}, + cfg := &configV0{ + Providers: providersConfigV0{}, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 0 { t.Errorf("len(result) = %d, want 0", len(result)) @@ -131,7 +129,7 @@ func TestConvertProvidersToModelList_Empty(t *testing.T) { } func TestConvertProvidersToModelList_Nil(t *testing.T) { - result := ConvertProvidersToModelList(nil) + result := v0ConvertProvidersToModelList(nil) if result != nil { t.Errorf("result = %v, want nil", result) @@ -139,35 +137,38 @@ func TestConvertProvidersToModelList_Nil(t *testing.T) { } func TestConvertProvidersToModelList_AllProviders(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}}, - LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, - Anthropic: ProviderConfig{APIKey: "key2"}, - OpenRouter: ProviderConfig{APIKey: "key3"}, - Groq: ProviderConfig{APIKey: "key4"}, - Zhipu: ProviderConfig{APIKey: "key5"}, - VLLM: ProviderConfig{APIKey: "key6"}, - Gemini: ProviderConfig{APIKey: "key7"}, - Nvidia: ProviderConfig{APIKey: "key8"}, - Ollama: ProviderConfig{APIKey: "key9"}, - Moonshot: ProviderConfig{APIKey: "key10"}, - ShengSuanYun: ProviderConfig{APIKey: "key11"}, - DeepSeek: ProviderConfig{APIKey: "key12"}, - Cerebras: ProviderConfig{APIKey: "key13"}, - Vivgrid: ProviderConfig{APIKey: "key14"}, - VolcEngine: ProviderConfig{APIKey: "key15"}, - GitHubCopilot: ProviderConfig{ConnectMode: "grpc"}, - Antigravity: ProviderConfig{AuthMethod: "oauth"}, - Qwen: ProviderConfig{APIKey: "key17"}, - Mistral: ProviderConfig{APIKey: "key18"}, - Avian: ProviderConfig{APIKey: "key19"}, - LongCat: ProviderConfig{APIKey: "key-longcat"}, - ModelScope: ProviderConfig{APIKey: "key-modelscope"}, + // This test verifies that when providers have at least one configured field, + // they are converted. GitHubCopilot has ConnectMode set, Antigravity has AuthMethod. + // Other providers have no configuration, so they won't be converted. + cfg := &configV0{ + Providers: providersConfigV0{ + OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "key1"}}, + LiteLLM: providerConfigV0{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, + Anthropic: providerConfigV0{APIKey: "key2"}, + OpenRouter: providerConfigV0{APIKey: "key3"}, + Groq: providerConfigV0{APIKey: "key4"}, + Zhipu: providerConfigV0{APIKey: "key5"}, + VLLM: providerConfigV0{APIKey: "key6"}, + Gemini: providerConfigV0{APIKey: "key7"}, + Nvidia: providerConfigV0{APIKey: "key8"}, + Ollama: providerConfigV0{APIKey: "key9"}, + Moonshot: providerConfigV0{APIKey: "key10"}, + ShengSuanYun: providerConfigV0{APIKey: "key11"}, + DeepSeek: providerConfigV0{APIKey: "key12"}, + Cerebras: providerConfigV0{APIKey: "key13"}, + Vivgrid: providerConfigV0{APIKey: "key14"}, + VolcEngine: providerConfigV0{APIKey: "key15"}, + GitHubCopilot: providerConfigV0{ConnectMode: "grpc"}, + Antigravity: providerConfigV0{AuthMethod: "oauth"}, + Qwen: providerConfigV0{APIKey: "key17"}, + Mistral: providerConfigV0{APIKey: "key18"}, + Avian: providerConfigV0{APIKey: "key19"}, + LongCat: providerConfigV0{APIKey: "key-longcat"}, + ModelScope: providerConfigV0{APIKey: "key-modelscope"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) // All 23 providers should be converted if len(result) != 23 { @@ -176,10 +177,10 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) { } func TestConvertProvidersToModelList_Proxy(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ - ProviderConfig: ProviderConfig{ + cfg := &configV0{ + Providers: providersConfigV0{ + OpenAI: openAIProviderConfigV0{ + providerConfigV0: providerConfigV0{ APIKey: "key", Proxy: "http://proxy:8080", }, @@ -187,7 +188,7 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) { }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -199,16 +200,16 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) { } func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - Ollama: ProviderConfig{ - APIKey: "ollama-key", + cfg := &configV0{ + Providers: providersConfigV0{ + Ollama: providerConfigV0{ + APIBase: "http://localhost:11434", RequestTimeout: 300, }, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -220,17 +221,17 @@ func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) { } func TestConvertProvidersToModelList_AuthMethod(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ - ProviderConfig: ProviderConfig{ + cfg := &configV0{ + Providers: providersConfigV0{ + OpenAI: openAIProviderConfigV0{ + providerConfigV0: providerConfigV0{ AuthMethod: "oauth", }, }, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 0 { t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result)) @@ -240,19 +241,19 @@ func TestConvertProvidersToModelList_AuthMethod(t *testing.T) { // Tests for preserving user's configured model during migration func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "deepseek", Model: "deepseek-reasoner", }, }, - Providers: ProvidersConfig{ - DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, + Providers: providersConfigV0{ + DeepSeek: providerConfigV0{APIKey: "sk-deepseek"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -265,19 +266,19 @@ func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) { } func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "openai", Model: "gpt-4-turbo", }, }, - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}}, + Providers: providersConfigV0{ + OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -289,19 +290,19 @@ func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) { } func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "claude", // alternative name Model: "claude-opus-4-20250514", }, }, - Providers: ProvidersConfig{ - Anthropic: ProviderConfig{APIKey: "sk-ant"}, + Providers: providersConfigV0{ + Anthropic: providerConfigV0{APIKey: "sk-ant"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -313,19 +314,19 @@ func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) } func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "qwen", Model: "qwen-plus", }, }, - Providers: ProvidersConfig{ - Qwen: ProviderConfig{APIKey: "sk-qwen"}, + Providers: providersConfigV0{ + Qwen: providerConfigV0{APIKey: "sk-qwen"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -337,19 +338,19 @@ func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) { } func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "deepseek", Model: "", // no model specified }, }, - Providers: ProvidersConfig{ - DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, + Providers: providersConfigV0{ + DeepSeek: providerConfigV0{APIKey: "sk-deepseek"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -362,20 +363,20 @@ func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) { } func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "deepseek", Model: "deepseek-reasoner", }, }, - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}}, - DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, + Providers: providersConfigV0{ + OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}}, + DeepSeek: providerConfigV0{APIKey: "sk-deepseek"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 2 { t.Fatalf("len(result) = %d, want 2", len(result)) @@ -400,20 +401,20 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) { tests := []struct { providerAlias string expectedModel string - provider ProviderConfig + provider providerConfigV0 }{ - {"gpt", "openai/gpt-4-custom", ProviderConfig{APIKey: "key"}}, - {"claude", "anthropic/claude-custom", ProviderConfig{APIKey: "key"}}, - {"doubao", "volcengine/doubao-custom", ProviderConfig{APIKey: "key"}}, - {"tongyi", "qwen/qwen-custom", ProviderConfig{APIKey: "key"}}, - {"kimi", "moonshot/kimi-custom", ProviderConfig{APIKey: "key"}}, + {"gpt", "openai/gpt-4-custom", providerConfigV0{APIKey: "key"}}, + {"claude", "anthropic/claude-custom", providerConfigV0{APIKey: "key"}}, + {"doubao", "volcengine/doubao-custom", providerConfigV0{APIKey: "key"}}, + {"tongyi", "qwen/qwen-custom", providerConfigV0{APIKey: "key"}}, + {"kimi", "moonshot/kimi-custom", providerConfigV0{APIKey: "key"}}, } for _, tt := range tests { t.Run(tt.providerAlias, func(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: tt.providerAlias, Model: strings.TrimPrefix( tt.expectedModel, @@ -421,13 +422,13 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) { ), }, }, - Providers: ProvidersConfig{}, + Providers: providersConfigV0{}, } // Set the appropriate provider config switch tt.providerAlias { case "gpt": - cfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider} + cfg.Providers.OpenAI = openAIProviderConfigV0{providerConfigV0: tt.provider} case "claude": cfg.Providers.Anthropic = tt.provider case "doubao": @@ -444,7 +445,7 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) { tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1], ) - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } @@ -466,19 +467,21 @@ func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T // - No provider field set // - model = "glm-4.7" // - Only zhipu has API key configured - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "", // Not set Model: "glm-4.7", }, }, - Providers: ProvidersConfig{ - Zhipu: ProviderConfig{APIKey: "test-zhipu-key"}, + Providers: providersConfigV0{ + Zhipu: providerConfigV0{ + APIKey: "test-zhipu-key", + }, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -499,20 +502,20 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin // When multiple providers are configured but no provider field is set, // the FIRST provider (in migration order) will use userModel as ModelName // for backward compatibility with legacy implicit provider selection - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "", // Not set Model: "some-model", }, }, - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}}, - Zhipu: ProviderConfig{APIKey: "zhipu-key"}, + Providers: providersConfigV0{ + OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, + Zhipu: providerConfigV0{APIKey: "zhipu-key"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 2 { t.Fatalf("len(result) = %d, want 2", len(result)) @@ -532,19 +535,19 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) { // Edge case: no provider, no model - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "", Model: "", }, }, - Providers: ProvidersConfig{ - Zhipu: ProviderConfig{APIKey: "zhipu-key"}, + Providers: providersConfigV0{ + Zhipu: providerConfigV0{APIKey: "zhipu-key"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) @@ -585,19 +588,19 @@ func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) { // Test for legacy config with protocol prefix in model name func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ + cfg := &configV0{ + Agents: agentsConfigV0{ + Defaults: agentDefaultsV0{ Provider: "", // No explicit provider Model: "openrouter/auto", // Model already has protocol prefix }, }, - Providers: ProvidersConfig{ - OpenRouter: ProviderConfig{APIKey: "sk-or-test"}, + Providers: providersConfigV0{ + OpenRouter: providerConfigV0{APIKey: "sk-or-test"}, }, } - result := ConvertProvidersToModelList(cfg) + result := v0ConvertProvidersToModelList(cfg) if len(result) < 1 { t.Fatalf("len(result) = %d, want at least 1", len(result)) @@ -613,143 +616,3 @@ func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto") } } - -// ---------- InheritProviderCredentials tests ---------- - -func TestInheritProviderCredentials_FillsMissingAPIKey(t *testing.T) { - models := []ModelConfig{ - {ModelName: "my-deepseek", Model: "deepseek/deepseek-chat"}, - } - providers := ProvidersConfig{ - DeepSeek: ProviderConfig{ - APIKey: "sk-deepseek-from-providers", - APIBase: "https://api.deepseek.com/v1", - }, - } - - InheritProviderCredentials(models, providers) - - if models[0].APIKey != "sk-deepseek-from-providers" { - t.Errorf("APIKey = %q, want %q", models[0].APIKey, "sk-deepseek-from-providers") - } - if models[0].APIBase != "https://api.deepseek.com/v1" { - t.Errorf("APIBase = %q, want %q", models[0].APIBase, "https://api.deepseek.com/v1") - } -} - -func TestInheritProviderCredentials_ExplicitValuesTakePrecedence(t *testing.T) { - models := []ModelConfig{ - { - ModelName: "my-openai", - Model: "openai/gpt-5.4", - APIKey: "sk-explicit-model-key", - APIBase: "https://my-custom-endpoint.com/v1", - }, - } - providers := ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ - ProviderConfig: ProviderConfig{ - APIKey: "sk-provider-key", - APIBase: "https://api.openai.com/v1", - }, - }, - } - - InheritProviderCredentials(models, providers) - - if models[0].APIKey != "sk-explicit-model-key" { - t.Errorf("APIKey = %q, want %q (explicit should win)", models[0].APIKey, "sk-explicit-model-key") - } - if models[0].APIBase != "https://my-custom-endpoint.com/v1" { - t.Errorf("APIBase = %q, want %q (explicit should win)", models[0].APIBase, "https://my-custom-endpoint.com/v1") - } -} - -func TestInheritProviderCredentials_MultipleModels(t *testing.T) { - models := []ModelConfig{ - {ModelName: "groq-llama", Model: "groq/llama-3.1-70b"}, - {ModelName: "zhipu-glm", Model: "zhipu/glm-4"}, - {ModelName: "custom-openai", Model: "openai/gpt-5.4", APIKey: "sk-already-set"}, - } - providers := ProvidersConfig{ - Groq: ProviderConfig{APIKey: "gsk-groq-key", Proxy: "http://proxy:8080"}, - Zhipu: ProviderConfig{APIKey: "zhipu-key-123", APIBase: "https://zhipu.example.com"}, - OpenAI: OpenAIProviderConfig{ - ProviderConfig: ProviderConfig{APIKey: "sk-should-not-override"}, - }, - } - - InheritProviderCredentials(models, providers) - - // groq model should inherit - if models[0].APIKey != "gsk-groq-key" { - t.Errorf("groq APIKey = %q, want %q", models[0].APIKey, "gsk-groq-key") - } - if models[0].Proxy != "http://proxy:8080" { - t.Errorf("groq Proxy = %q, want %q", models[0].Proxy, "http://proxy:8080") - } - - // zhipu model should inherit - if models[1].APIKey != "zhipu-key-123" { - t.Errorf("zhipu APIKey = %q, want %q", models[1].APIKey, "zhipu-key-123") - } - if models[1].APIBase != "https://zhipu.example.com" { - t.Errorf("zhipu APIBase = %q, want %q", models[1].APIBase, "https://zhipu.example.com") - } - - // openai model already has key — should NOT be overridden - if models[2].APIKey != "sk-already-set" { - t.Errorf("openai APIKey = %q, want %q (should not be overridden)", models[2].APIKey, "sk-already-set") - } -} - -func TestInheritProviderCredentials_NoMatchingProvider(t *testing.T) { - models := []ModelConfig{ - {ModelName: "my-model", Model: "novelai/some-model"}, - } - providers := ProvidersConfig{ - DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, - } - - InheritProviderCredentials(models, providers) - - // No matching provider for "novelai" protocol — should stay empty - if models[0].APIKey != "" { - t.Errorf("APIKey = %q, want empty (no matching provider)", models[0].APIKey) - } -} - -func TestInheritProviderCredentials_EmptyProviders(t *testing.T) { - models := []ModelConfig{ - {ModelName: "my-model", Model: "openai/gpt-5.4"}, - } - providers := ProvidersConfig{} // all empty - - InheritProviderCredentials(models, providers) - - // Empty providers — nothing to inherit - if models[0].APIKey != "" { - t.Errorf("APIKey = %q, want empty", models[0].APIKey) - } -} - -func TestInheritProviderCredentials_InheritsRequestTimeout(t *testing.T) { - models := []ModelConfig{ - {ModelName: "my-ollama", Model: "ollama/llama3.2:3b"}, - } - providers := ProvidersConfig{ - Ollama: ProviderConfig{ - APIBase: "http://localhost:11434", - RequestTimeout: 120, - }, - } - - InheritProviderCredentials(models, providers) - - if models[0].APIBase != "http://localhost:11434" { - t.Errorf("APIBase = %q, want %q", models[0].APIBase, "http://localhost:11434") - } - if models[0].RequestTimeout != 120 { - t.Errorf("RequestTimeout = %d, want 120", models[0].RequestTimeout) - } -} diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go index 9bc600ed9..3252d2f26 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -13,12 +13,20 @@ import ( ) func TestGetModelConfig_Found(t *testing.T) { - cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"}, - {ModelName: "other-model", Model: "anthropic/claude", APIKey: "key2"}, + cfg := (&Config{ + Version: CurrentVersion, + ModelList: []*ModelConfig{ + {ModelName: "test-model", Model: "openai/gpt-4o"}, + {ModelName: "other-model", Model: "anthropic/claude"}, }, - } + }).WithSecurity(&SecurityConfig{ModelList: map[string]ModelSecurityEntry{ + "test-model:0": { + APIKeys: []string{"key1"}, + }, + "other-model:0": { + APIKeys: []string{"key2"}, + }, + }}) result, err := cfg.GetModelConfig("test-model") if err != nil { @@ -30,11 +38,17 @@ func TestGetModelConfig_Found(t *testing.T) { } func TestGetModelConfig_NotFound(t *testing.T) { - cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"}, + cfg := (&Config{ + ModelList: []*ModelConfig{ + {ModelName: "test-model", Model: "openai/gpt-4o"}, }, - } + }).WithSecurity(&SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{ + "test-model:0": { + APIKeys: []string{"key1"}, + }, + }, + }) _, err := cfg.GetModelConfig("nonexistent") if err == nil { @@ -44,7 +58,7 @@ func TestGetModelConfig_NotFound(t *testing.T) { func TestGetModelConfig_EmptyList(t *testing.T) { cfg := &Config{ - ModelList: []ModelConfig{}, + ModelList: []*ModelConfig{}, } _, err := cfg.GetModelConfig("any-model") @@ -54,13 +68,25 @@ func TestGetModelConfig_EmptyList(t *testing.T) { } func TestGetModelConfig_RoundRobin(t *testing.T) { - cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"}, - {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"}, - {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"}, + cfg := (&Config{ + ModelList: []*ModelConfig{ + {ModelName: "lb-model", Model: "openai/gpt-4o-1"}, + {ModelName: "lb-model", Model: "openai/gpt-4o-2"}, + {ModelName: "lb-model", Model: "openai/gpt-4o-3"}, }, - } + }).WithSecurity(&SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{ + "lb-model:0": { + APIKeys: []string{"key1"}, + }, + "lb-model:1": { + APIKeys: []string{"key2"}, + }, + "lb-model:2": { + APIKeys: []string{"key3"}, + }, + }, + }) // Test round-robin distribution results := make(map[string]int) @@ -84,10 +110,10 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) { rrCounter.Store(0) cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"}, - {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"}, - {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"}, + ModelList: []*ModelConfig{ + {ModelName: "lb-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}}, + {ModelName: "lb-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}}, + {ModelName: "lb-model", Model: "openai/gpt-4o-3", apiKeys: []string{"key3"}}, }, } @@ -112,9 +138,9 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) { func TestGetModelConfig_Concurrent(t *testing.T) { cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKey: "key1"}, - {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKey: "key2"}, + ModelList: []*ModelConfig{ + {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}}, + {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}}, }, } @@ -143,39 +169,7 @@ func TestGetModelConfig_Concurrent(t *testing.T) { } } -func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) { - tests := []struct { - name string - defaults AgentDefaults - wantName string - }{ - { - name: "new model_name field only", - defaults: AgentDefaults{ModelName: "new-model"}, - wantName: "new-model", - }, - { - name: "old model field only", - defaults: AgentDefaults{Model: "legacy-model"}, - wantName: "legacy-model", - }, - { - name: "both fields - model_name takes precedence", - defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"}, - wantName: "new-model", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.defaults.GetModelName(); got != tt.wantName { - t.Errorf("GetModelName() = %q, want %q", got, tt.wantName) - } - }) - } -} - -func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) { +func TestAgentDefaultsV0_JSON_BackwardCompat(t *testing.T) { tests := []struct { name string json string @@ -200,7 +194,7 @@ func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var defaults AgentDefaults + var defaults agentDefaultsV0 if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil { t.Fatalf("Unmarshal error: %v", err) } @@ -211,69 +205,6 @@ func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) { } } -func TestFullConfig_JSON_BackwardCompat(t *testing.T) { - // Test complete config with both old and new formats - oldFormat := `{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "gpt4", - "max_tokens": 4096 - } - }, - "model_list": [ - { - "model_name": "gpt4", - "model": "openai/gpt-4o", - "api_key": "test-key" - } - ] - }` - - newFormat := `{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model_name": "gpt4", - "max_tokens": 4096 - } - }, - "model_list": [ - { - "model_name": "gpt4", - "model": "openai/gpt-4o", - "api_key": "test-key" - } - ] - }` - - for name, jsonStr := range map[string]string{ - "old format (model)": oldFormat, - "new format (model_name)": newFormat, - } { - t.Run(name, func(t *testing.T) { - cfg := &Config{} - if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil { - t.Fatalf("Unmarshal error: %v", err) - } - - // Check that GetModelName returns correct value - if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" { - t.Errorf("GetModelName() = %q, want %q", got, "gpt4") - } - - // Check that GetModelConfig works - modelCfg, err := cfg.GetModelConfig("gpt4") - if err != nil { - t.Fatalf("GetModelConfig error: %v", err) - } - if modelCfg.Model != "openai/gpt-4o" { - t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o") - } - }) - } -} - func TestModelConfig_Validate(t *testing.T) { tests := []struct { name string @@ -329,7 +260,7 @@ func TestConfig_ValidateModelList(t *testing.T) { { name: "valid list", config: &Config{ - ModelList: []ModelConfig{ + ModelList: []*ModelConfig{ {ModelName: "test1", Model: "openai/gpt-4o"}, {ModelName: "test2", Model: "anthropic/claude"}, }, @@ -339,7 +270,7 @@ func TestConfig_ValidateModelList(t *testing.T) { { name: "invalid entry", config: &Config{ - ModelList: []ModelConfig{ + ModelList: []*ModelConfig{ {ModelName: "test1", Model: "openai/gpt-4o"}, {ModelName: "", Model: "anthropic/claude"}, // missing model_name }, @@ -350,7 +281,7 @@ func TestConfig_ValidateModelList(t *testing.T) { { name: "empty list", config: &Config{ - ModelList: []ModelConfig{}, + ModelList: []*ModelConfig{}, }, wantErr: false, }, @@ -358,10 +289,7 @@ func TestConfig_ValidateModelList(t *testing.T) { // Load balancing: multiple entries with same model_name are allowed name: "duplicate model_name for load balancing", config: &Config{ - ModelList: []ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1"}, - {ModelName: "gpt-4", Model: "openai/gpt-4-turbo", APIKey: "key2"}, - }, + ModelList: []*ModelConfig{}, }, wantErr: false, // Changed: duplicates are allowed for load balancing }, @@ -369,7 +297,7 @@ func TestConfig_ValidateModelList(t *testing.T) { // Load balancing: non-adjacent entries with same model_name are also allowed name: "duplicate model_name non-adjacent for load balancing", config: &Config{ - ModelList: []ModelConfig{ + ModelList: []*ModelConfig{ {ModelName: "model-a", Model: "openai/gpt-4o"}, {ModelName: "model-b", Model: "anthropic/claude"}, {ModelName: "model-a", Model: "openai/gpt-4-turbo"}, diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go index b899b991c..cc529905c 100644 --- a/pkg/config/multikey_test.go +++ b/pkg/config/multikey_test.go @@ -5,15 +5,15 @@ import ( ) func TestExpandMultiKeyModels_SingleKey(t *testing.T) { - models := []ModelConfig{ + models := []*ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", - APIKey: "single-key", + apiKeys: []string{"single-key"}, }, } - result := ExpandMultiKeyModels(models) + result := expandMultiKeyModels(models) if len(result) != 1 { t.Fatalf("expected 1 model, got %d", len(result)) @@ -23,8 +23,8 @@ func TestExpandMultiKeyModels_SingleKey(t *testing.T) { t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName) } - if result[0].APIKey != "single-key" { - t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey) + if result[0].APIKey() != "single-key" { + t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey()) } if len(result[0].Fallbacks) != 0 { @@ -33,16 +33,16 @@ func TestExpandMultiKeyModels_SingleKey(t *testing.T) { } func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) { - models := []ModelConfig{ + models := []*ModelConfig{ { ModelName: "glm-4.7", Model: "zhipu/glm-4.7", APIBase: "https://api.example.com", - APIKeys: []string{"key1", "key2", "key3"}, + apiKeys: []string{"key1", "key2", "key3"}, }, } - result := ExpandMultiKeyModels(models) + result := expandMultiKeyModels(models) // Should expand to 3 models if len(result) != 3 { @@ -54,8 +54,8 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) { if primary.ModelName != "glm-4.7" { t.Errorf("expected primary model_name 'glm-4.7', got %q", primary.ModelName) } - if primary.APIKey != "key1" { - t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey) + if primary.APIKey() != "key1" { + t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey()) } if len(primary.Fallbacks) != 2 { t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks)) @@ -72,8 +72,8 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) { if second.ModelName != "glm-4.7__key_1" { t.Errorf("expected second model_name 'glm-4.7__key_1', got %q", second.ModelName) } - if second.APIKey != "key2" { - t.Errorf("expected second api_key 'key2', got %q", second.APIKey) + if second.APIKey() != "key2" { + t.Errorf("expected second api_key 'key2', got %q", second.APIKey()) } // Third entry should be key3 @@ -81,22 +81,21 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) { if third.ModelName != "glm-4.7__key_2" { t.Errorf("expected third model_name 'glm-4.7__key_2', got %q", third.ModelName) } - if third.APIKey != "key3" { - t.Errorf("expected third api_key 'key3', got %q", third.APIKey) + if third.APIKey() != "key3" { + t.Errorf("expected third api_key 'key3', got %q", third.APIKey()) } } func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) { - models := []ModelConfig{ + models := []*ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", - APIKey: "key0", - APIKeys: []string{"key1", "key2"}, + apiKeys: []string{"key0", "key1", "key2"}, }, } - result := ExpandMultiKeyModels(models) + result := expandMultiKeyModels(models) // Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys) if len(result) != 3 { @@ -105,8 +104,8 @@ func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) { // Primary should use key0 primary := result[2] - if primary.APIKey != "key0" { - t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey) + if primary.APIKey() != "key0" { + t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey()) } if len(primary.Fallbacks) != 2 { t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks)) @@ -114,16 +113,15 @@ func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) { } func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) { - models := []ModelConfig{ - { - ModelName: "gpt-4", - Model: "openai/gpt-4o", - APIKeys: []string{"key1", "key2"}, - Fallbacks: []string{"claude-3"}, - }, + modelCfg := &ModelConfig{ + ModelName: "gpt-4", + Model: "openai/gpt-4o", } + modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing + modelCfg.Fallbacks = []string{"claude-3"} + models := []*ModelConfig{modelCfg} - result := ExpandMultiKeyModels(models) + result := expandMultiKeyModels(models) primary := result[1] // With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total @@ -141,16 +139,15 @@ func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) { } func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) { - models := []ModelConfig{ + models := []*ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", - APIKey: "", - APIKeys: []string{}, + apiKeys: []string{}, }, } - result := ExpandMultiKeyModels(models) + result := expandMultiKeyModels(models) // Should keep as-is with no changes if len(result) != 1 { @@ -163,25 +160,25 @@ func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) { } func TestExpandMultiKeyModels_Deduplication(t *testing.T) { - models := []ModelConfig{ + models := []*ModelConfig{ { ModelName: "gpt-4", Model: "openai/gpt-4o", - APIKey: "key1", - APIKeys: []string{"key1", "key2", "key1"}, // Duplicate key1 + apiKeys: []string{"key1", "key2", "key1"}, // Duplicate key1 }, } - result := ExpandMultiKeyModels(models) + result := expandMultiKeyModels(models) + t.Logf("result: %#v", result) // Should only create 2 models (deduplicated keys) if len(result) != 2 { t.Fatalf("expected 2 models (deduplicated), got %d", len(result)) } primary := result[1] - if primary.APIKey != "key1" { - t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey) + if primary.APIKey() != "key1" { + t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey()) } if len(primary.Fallbacks) != 1 { t.Errorf("expected 1 fallback, got %d", len(primary.Fallbacks)) @@ -189,21 +186,20 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) { } func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { - models := []ModelConfig{ - { - ModelName: "gpt-4", - Model: "openai/gpt-4o", - APIBase: "https://api.example.com", - APIKeys: []string{"key1", "key2"}, - Proxy: "http://proxy:8080", - RPM: 60, - MaxTokensField: "max_completion_tokens", - RequestTimeout: 30, - ThinkingLevel: "high", - }, + modelCfg := &ModelConfig{ + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIBase: "https://api.example.com", + Proxy: "http://proxy:8080", + RPM: 60, + MaxTokensField: "max_completion_tokens", + RequestTimeout: 30, + ThinkingLevel: "high", } + modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing + models := []*ModelConfig{modelCfg} - result := ExpandMultiKeyModels(models) + result := expandMultiKeyModels(models) // Check primary entry preserves all fields primary := result[1] @@ -250,13 +246,13 @@ func TestMergeAPIKeys(t *testing.T) { expected: nil, }, { - name: "only apiKey", + name: "only ApiKey", apiKey: "key1", apiKeys: nil, expected: []string{"key1"}, }, { - name: "only apiKeys", + name: "only ApiKeys", apiKey: "", apiKeys: []string{"key1", "key2"}, expected: []string{"key1", "key2"}, diff --git a/pkg/config/security.go b/pkg/config/security.go new file mode 100644 index 000000000..fe2111280 --- /dev/null +++ b/pkg/config/security.go @@ -0,0 +1,220 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "github.com/caarlos0/env/v11" + "github.com/tencent-connect/botgo/log" + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +const ( + SecurityConfigFile = ".security.yml" +) + +// SecurityConfig stores all sensitive data (API keys, tokens, secrets, passwords) +// This data is loaded from security.yml and kept separate from the main config +type SecurityConfig struct { + // Model API keys. Map key is model_name, can include suffix like "abc:0", "abc:1" + // for load balancing with same model_name. The suffix ":N" is used to distinguish + // multiple configs that share the same base model_name. + ModelList map[string]ModelSecurityEntry `yaml:"model_list,omitempty"` + + // Channel tokens/secrets + Channels ChannelsSecurity `yaml:"channels,omitempty"` + + Web WebToolsSecurity `yaml:"web,omitempty"` + Skills SkillsSecurity `yaml:"skills,omitempty"` +} + +// ModelSecurityEntry stores security data for a model +type ModelSecurityEntry struct { + APIKeys []string `yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) +} + +// ChannelsSecurity stores channel-related security data +type ChannelsSecurity struct { + Telegram *TelegramSecurity `yaml:"telegram,omitempty"` + Feishu *FeishuSecurity `yaml:"feishu,omitempty"` + Discord *DiscordSecurity `yaml:"discord,omitempty"` + Weixin *WeixinSecurity `yaml:"weixin,omitempty"` + QQ *QQSecurity `yaml:"qq,omitempty"` + DingTalk *DingTalkSecurity `yaml:"dingtalk,omitempty"` + Slack *SlackSecurity `yaml:"slack,omitempty"` + Matrix *MatrixSecurity `yaml:"matrix,omitempty"` + LINE *LINESecurity `yaml:"line,omitempty"` + OneBot *OneBotSecurity `yaml:"onebot,omitempty"` + WeCom *WeComSecurity `yaml:"wecom,omitempty"` + WeComApp *WeComAppSecurity `yaml:"wecom_app,omitempty"` + WeComAIBot *WeComAIBotSecurity `yaml:"wecom_aibot,omitempty"` + Pico *PicoSecurity `yaml:"pico,omitempty"` + IRC *IRCSecurity `yaml:"irc,omitempty"` +} + +type TelegramSecurity struct { + Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` +} + +type FeishuSecurity struct { + AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + VerificationToken string `yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` +} + +type DiscordSecurity struct { + Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` +} + +type WeixinSecurity struct { + Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"` +} + +type QQSecurity struct { + AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` +} + +type DingTalkSecurity struct { + ClientSecret string `yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` +} + +type SlackSecurity struct { + BotToken string `yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` +} + +type MatrixSecurity struct { + AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` +} + +type LINESecurity struct { + ChannelSecret string `yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + ChannelAccessToken string `yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` +} + +type OneBotSecurity struct { + AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` +} + +type WeComSecurity struct { + Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` + EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` +} + +type WeComAppSecurity struct { + CorpSecret string `yaml:"corp_secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` + Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` + EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` +} + +type WeComAIBotSecurity struct { + Secret string `yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"` + Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` + EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` +} + +type PicoSecurity struct { + Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` +} + +type IRCSecurity struct { + Password string `yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` + NickServPassword string `yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` + SASLPassword string `yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` +} + +type WebToolsSecurity struct { + Brave *BraveSecurity `yaml:"brave,omitempty"` + Tavily *TavilySecurity `yaml:"tavily,omitempty"` + Perplexity *PerplexitySecurity `yaml:"perplexity,omitempty"` + GLMSearch *GLMSearchSecurity `yaml:"glm_search,omitempty"` + BaiduSearch *BaiduSearchSecurity `yaml:"baidu_search,omitempty"` +} + +type BraveSecurity struct { + APIKeys []string `yaml:"api_keys,omitempty"` +} + +type TavilySecurity struct { + APIKeys []string `yaml:"api_keys,omitempty"` +} + +type PerplexitySecurity struct { + APIKeys []string `yaml:"api_keys,omitempty"` +} + +type GLMSearchSecurity struct { + APIKey string `yaml:"api_key,omitempty"` +} + +type BaiduSearchSecurity struct { + APIKey string `yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"` +} + +type SkillsSecurity struct { + Github *GithubSecurity `yaml:"github,omitempty"` + ClawHub *ClawHubSecurity `yaml:"clawhub,omitempty"` +} + +type GithubSecurity struct { + Token string `yaml:"token,omitempty"` +} + +type ClawHubSecurity struct { + AuthToken string `yaml:"auth_token,omitempty"` +} + +// securityPath returns the path to security.yml relative to the config file +func securityPath(configPath string) string { + configDir := filepath.Dir(configPath) + return filepath.Join(configDir, SecurityConfigFile) +} + +// loadSecurityConfig loads the security configuration from security.yml +// Returns an empty SecurityConfig if the file doesn't exist +func loadSecurityConfig(securityPath string) (*SecurityConfig, error) { + data, err := os.ReadFile(securityPath) + if err != nil { + if os.IsNotExist(err) { + return &SecurityConfig{}, nil + } + return nil, fmt.Errorf("failed to read security config: %w", err) + } + + var sec SecurityConfig + if err := yaml.Unmarshal(data, &sec); err != nil { + return nil, fmt.Errorf("failed to parse security config: %w", err) + } + + // No need to validate model_name format here - both formats are supported: + // - "model-name:0" (with index for multiple entries) + // - "model-name" (without index for single entry or default to index 0) + + if err := env.Parse(&sec); err != nil { + log.Errorf("failed to parse environment variables: %v", err) + return nil, err + } + + return &sec, nil +} + +// saveSecurityConfig saves the security configuration to security.yml +func saveSecurityConfig(securityPath string, sec *SecurityConfig) error { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + err := enc.Encode(sec) + if err != nil { + return fmt.Errorf("failed to marshal security config: %w", err) + } + return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600) +} diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go new file mode 100644 index 000000000..c1e1a2340 --- /dev/null +++ b/pkg/config/security_integration_test.go @@ -0,0 +1,472 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test JSON unmarshal of private fields +func TestJSONUnmarshalPrivateFields(t *testing.T) { + //nolint: govet + type testStruct struct { + PublicField string `json:"public"` + privateField string `json:"private"` + } + + data := `{"public": "pub", "private": "priv"}` + var s testStruct + if err := json.Unmarshal([]byte(data), &s); err != nil { + t.Fatalf("JSON unmarshal failed: %v", err) + } + + t.Logf("PublicField: %s", s.PublicField) + t.Logf("privateField: %s", s.privateField) + + if s.PublicField != "pub" { + t.Errorf("PublicField = %q, want 'pub'", s.PublicField) + } + // This should fail because privateField is unexported + if s.privateField != "priv" { + t.Logf("privateField = %q, want 'priv' - THIS IS EXPECTED TO FAIL", s.privateField) + } +} + +func TestSecurityConfigIntegration(t *testing.T) { + t.Run("Full workflow with security references", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create config.json with references + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 1, + "model_list": [ + { + "model_name": "test-model", + "model": "openai/test-model", + "api_base": "https://api.openai.com/v1", + "api_key": "ref:model_list.test-model.api_key" + } + ], + "channels": { + "telegram": { + "enabled": true, + "token": "ref:channels.telegram.token" + } + }, + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "ref:web.brave.api_key" + } + }, + "skills": { + "github": { + "token": "ref:skills.github.token" + } + } + } +}` + err := os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + // Create .security.yml with actual values + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `model_list: + test-model: + api_keys: + - "sk-test-api-key-12345" + +channels: + telegram: + token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" + +web: + brave: + api_keys: + - "BSAbrave-api-key-67890" + +skills: + github: + token: "ghp_github-token-abc123"` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + // Load config and verify references are resolved + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Verify model API key is resolved + assert.Equal(t, 1, len(cfg.ModelList)) + assert.Equal(t, "test-model", cfg.ModelList[0].ModelName) + assert.Equal(t, "sk-test-api-key-12345", cfg.ModelList[0].apiKeys[0]) + + // Verify channel token is resolved + assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.token) + + // Verify web tool API key is resolved + assert.Equal(t, "BSAbrave-api-key-67890", cfg.Tools.Web.Brave.APIKey()) + + // Verify skills token is resolved + assert.Equal(t, "ghp_github-token-abc123", cfg.Tools.Skills.Github.token) + }) +} + +func TestSecurityConfigWithAPIKeysArray(t *testing.T) { + t.Run("Multiple API keys via security", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create config with APIKeys array + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 1, + "model_list": [ + { + "model_name": "multi-key-model", + "model": "openai/multi-key-model" + } + ] +}` + err := os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + // Create .security.yml + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `model_list: + multi-key-model:0: + api_key: "sk-key-1" + api_keys: + - "sk-key-1" + - "sk-key-2" + - "sk-key-3" +` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + // Load config + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + + t.Logf("Config: %+v", cfg.ModelList) + for _, m := range cfg.ModelList { + t.Logf("Model: %+v", m) + } + // Verify multi-key expansion works + assert.Equal(t, 3, len(cfg.ModelList)) + assert.Equal(t, "multi-key-model", cfg.ModelList[2].ModelName) + }) +} + +func TestAllSecurityKeysAccessible(t *testing.T) { + t.Run("All security keys accessible via Key() methods including file://", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files for file:// references + modelAPIKeyFile := filepath.Join(tmpDir, "model_api_key.txt") + err := os.WriteFile(modelAPIKeyFile, []byte("sk-model-from-file-12345"), 0o600) + require.NoError(t, err) + + braveAPIKeyFile := filepath.Join(tmpDir, "brave_api_key.txt") + err = os.WriteFile(braveAPIKeyFile, []byte("BSA-brave-from-file-67890"), 0o600) + require.NoError(t, err) + + tavilyAPIKeyFile := filepath.Join(tmpDir, "tavily_api_key.txt") + err = os.WriteFile(tavilyAPIKeyFile, []byte("tvly-tavily-from-file-11111"), 0o600) + require.NoError(t, err) + + perplexityAPIKeyFile := filepath.Join(tmpDir, "perplexity_api_key.txt") + err = os.WriteFile(perplexityAPIKeyFile, []byte("pplx-perplexity-from-file-22222"), 0o600) + require.NoError(t, err) + + githubTokenFile := filepath.Join(tmpDir, "github_token.txt") + err = os.WriteFile(githubTokenFile, []byte("ghp-github-from-file-abc123"), 0o600) + require.NoError(t, err) + + clawhubAuthTokenFile := filepath.Join(tmpDir, "clawhub_auth_token.txt") + err = os.WriteFile(clawhubAuthTokenFile, []byte("clawhub-auth-token-from-file"), 0o600) + require.NoError(t, err) + + // Create config.json without sensitive values (they'll be in .security.yml) + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 1, + "model_list": [ + { + "model_name": "test-model-1", + "model": "openai/test-model-1" + } + ], + "channels": { + "telegram": { + "enabled": true + }, + "feishu": { + "enabled": true, + "app_id": "test_app_id" + }, + "discord": { + "enabled": true + }, + "dingtalk": { + "enabled": true, + "client_id": "test_client_id" + }, + "slack": { + "enabled": true + }, + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "user_id": "@test:matrix.org" + }, + "line": { + "enabled": true, + "webhook_host": "localhost", + "webhook_port": 8080, + "webhook_path": "/webhook" + }, + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080" + }, + "wecom": { + "enabled": true, + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook" + }, + "wecom_app": { + "enabled": true, + "corp_id": "test_corp_id", + "agent_id": 123456 + }, + "wecom_aibot": { + "enabled": true + }, + "pico": { + "enabled": true + }, + "irc": { + "enabled": true, + "server": "irc.example.com", + "nick": "testbot" + }, + "qq": { + "enabled": true, + "app_id": "test_qq_app_id" + } + }, + "tools": { + "web": { + "brave": { + "enabled": true + }, + "tavily": { + "enabled": true + }, + "perplexity": { + "enabled": true + }, + "glm_search": { + "enabled": true + } + }, + "skills": { + "github": {} + } + } +}` + err = os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + // Create .security.yml with file:// references and plaintext values + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `model_list: + test-model-1: + api_keys: + - "file://model_api_key.txt" + +channels: + telegram: + token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" + feishu: + app_secret: "feishu_test_app_secret" + encrypt_key: "feishu_test_encrypt_key" + verification_token: "feishu_test_verification_token" + discord: + token: "discord_test_bot_token_xyz" + dingtalk: + client_secret: "dingtalk_test_client_secret" + slack: + bot_token: "xoxb-slack-bot-token-123" + app_token: "xapp-slack-app-token-456" + matrix: + access_token: "matrix_test_access_token" + line: + channel_secret: "line_test_channel_secret" + channel_access_token: "line_test_channel_access_token" + onebot: + access_token: "onebot_test_access_token" + wecom: + token: "wecom_test_webhook_token" + encoding_aes_key: "wecom_test_aes_key" + wecom_app: + corp_secret: "wecom_app_test_corp_secret" + token: "wecom_app_test_token" + encoding_aes_key: "wecom_app_test_aes_key" + wecom_aibot: + token: "wecom_aibot_test_token" + encoding_aes_key: "wecom_aibot_test_aes_key" + pico: + token: "pico_test_token" + irc: + password: "irc_test_password" + nickserv_password: "irc_test_nickserv_password" + sasl_password: "irc_test_sasl_password" + qq: + app_secret: "qq_test_app_secret" + +web: + brave: + api_keys: + - "file://brave_api_key.txt" + tavily: + api_keys: + - "file://tavily_api_key.txt" + perplexity: + api_keys: + - "file://perplexity_api_key.txt" + glm_search: + api_key: "glm-test-glm-search-key" + +skills: + github: + token: "file://github_token.txt" + clawhub: + auth_token: "file://clawhub_auth_token.txt" +` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + // Load config and verify all security keys are accessible + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Verify Model API keys + assert.Equal(t, 1, len(cfg.ModelList)) + assert.Equal(t, "test-model-1", cfg.ModelList[0].ModelName) + // file:// reference should be resolved + assert.Equal(t, "sk-model-from-file-12345", cfg.ModelList[0].APIKey()) + t.Logf("Model APIKey(): %s", cfg.ModelList[0].APIKey()) + + // Verify Channel tokens via Key() methods + // Telegram + assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token()) + t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token()) + + // Feishu + assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret()) + assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey()) + assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken()) + t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret()) + t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey()) + t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken()) + + // Discord + assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token()) + t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token()) + + // DingTalk + assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret()) + t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret()) + + // Slack + assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken()) + assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken()) + t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken()) + t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken()) + + // Matrix + assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken()) + t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken()) + + // LINE + assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret()) + assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken()) + t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret()) + t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken()) + + // OneBot + assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken()) + t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken()) + + // WeCom + assert.Equal(t, "wecom_test_webhook_token", cfg.Channels.WeCom.Token()) + assert.Equal(t, "wecom_test_aes_key", cfg.Channels.WeCom.EncodingAESKey()) + t.Logf("WeCom Token(): %s", cfg.Channels.WeCom.Token()) + t.Logf("WeCom EncodingAESKey(): %s", cfg.Channels.WeCom.EncodingAESKey()) + + // WeCom App + assert.Equal(t, "wecom_app_test_corp_secret", cfg.Channels.WeComApp.CorpSecret()) + assert.Equal(t, "wecom_app_test_token", cfg.Channels.WeComApp.Token()) + assert.Equal(t, "wecom_app_test_aes_key", cfg.Channels.WeComApp.EncodingAESKey()) + t.Logf("WeComApp CorpSecret(): %s", cfg.Channels.WeComApp.CorpSecret()) + t.Logf("WeComApp Token(): %s", cfg.Channels.WeComApp.Token()) + t.Logf("WeComApp EncodingAESKey(): %s", cfg.Channels.WeComApp.EncodingAESKey()) + + // WeCom AI Bot + assert.Equal(t, "wecom_aibot_test_token", cfg.Channels.WeComAIBot.Token()) + assert.Equal(t, "wecom_aibot_test_aes_key", cfg.Channels.WeComAIBot.EncodingAESKey()) + t.Logf("WeComAIBot Token(): %s", cfg.Channels.WeComAIBot.Token()) + t.Logf("WeComAIBot EncodingAESKey(): %s", cfg.Channels.WeComAIBot.EncodingAESKey()) + + // Pico + assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token()) + t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token()) + + // IRC + assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password()) + assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword()) + assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword()) + t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password()) + t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword()) + t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword()) + + // QQ + assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret()) + t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret()) + + // Verify Web tool API keys + assert.Equal(t, "BSA-brave-from-file-67890", cfg.Tools.Web.Brave.APIKey()) + t.Logf("Brave APIKey(): %s", cfg.Tools.Web.Brave.APIKey()) + + assert.Equal(t, "tvly-tavily-from-file-11111", cfg.Tools.Web.Tavily.APIKey()) + t.Logf("Tavily APIKey(): %s", cfg.Tools.Web.Tavily.APIKey()) + + assert.Equal(t, "pplx-perplexity-from-file-22222", cfg.Tools.Web.Perplexity.APIKey()) + t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey()) + + // GLM Search - Note: GLM uses SetAPIKey (lowercase) internally + t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey()) + assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey()) + + // Verify Skills tokens + assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token()) + t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token()) + + assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken()) + t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken()) + + t.Log("All security keys are successfully accessible via their respective Key() methods") + }) +} diff --git a/pkg/config/security_test.go b/pkg/config/security_test.go new file mode 100644 index 000000000..74e765f6b --- /dev/null +++ b/pkg/config/security_test.go @@ -0,0 +1,90 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecurityConfig(t *testing.T) { + t.Run("LoadNonExistent", func(t *testing.T) { + sec, err := loadSecurityConfig("/nonexistent/.security.yml") + require.NoError(t, err) + assert.NotNil(t, sec) + assert.Empty(t, sec.ModelList) + }) +} + +func TestSecurityPath(t *testing.T) { + tests := []struct { + name string + configDir string + want string + }{ + { + name: "standard path", + configDir: "/home/user/.picoclaw/config.json", + want: "/home/user/.picoclaw/.security.yml", + }, + { + name: "nested path", + configDir: "/path/to/config/myconfig.json", + want: "/path/to/config/.security.yml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := securityPath(tt.configDir) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSaveAndLoadSecurityConfig(t *testing.T) { + tmpDir := t.TempDir() + secPath := filepath.Join(tmpDir, SecurityConfigFile) + + original := &SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{ + "model1:0": { + APIKeys: []string{"key1", "key2"}, + }, + }, + Channels: ChannelsSecurity{ + Telegram: &TelegramSecurity{ + Token: "telegram-token", + }, + }, + Web: WebToolsSecurity{ + Brave: &BraveSecurity{ + APIKeys: []string{"brave-api-key"}, + }, + }, + } + + // Save + err := saveSecurityConfig(secPath, original) + require.NoError(t, err) + + // Verify file was created with correct permissions + info, err := os.Stat(secPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode()) + + // Load + loaded, err := loadSecurityConfig(secPath) + require.NoError(t, err) + + assert.Equal(t, original.ModelList, loaded.ModelList) + assert.Equal(t, original.Channels.Telegram.Token, loaded.Channels.Telegram.Token) + assert.EqualValues(t, original.Web.Brave.APIKeys, loaded.Web.Brave.APIKeys) +} diff --git a/pkg/env.go b/pkg/env.go new file mode 100644 index 000000000..b9a77dab2 --- /dev/null +++ b/pkg/env.go @@ -0,0 +1,12 @@ +// all environment variables including default values put here + +package pkg + +const ( + Logo = "🦞" + // AppName is the name of the app + AppName = "PicoClaw" + + DefaultPicoClawHome = ".picoclaw" + WorkspaceName = "workspace" +) diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 7f920a6f1..454ee2c48 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -381,9 +381,6 @@ func handleConfigReload( logger.Info("🔄 Config file changed, reloading...") newModel := newCfg.Agents.Defaults.ModelName - if newModel == "" { - newModel = newCfg.Agents.Defaults.Model - } logger.Infof(" New model is '%s', recreating provider...", newModel) diff --git a/pkg/migrate/internal/common.go b/pkg/migrate/internal/common.go index 75aef5dc2..65a87adc4 100644 --- a/pkg/migrate/internal/common.go +++ b/pkg/migrate/internal/common.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" ) @@ -20,7 +21,7 @@ func ResolveTargetHome(override string) (string, error) { if err != nil { return "", fmt.Errorf("resolving home directory: %w", err) } - return filepath.Join(home, ".picoclaw"), nil + return filepath.Join(home, pkg.DefaultPicoClawHome), nil } func ExpandHome(path string) string { diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index 317bd3e84..b56194b3d 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -981,13 +981,16 @@ func (c *PicoClawConfig) ToStandardConfig() *config.Config { cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks for _, m := range c.ModelList { - cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + mc := &config.ModelConfig{ ModelName: m.ModelName, Model: m.Model, APIBase: m.APIBase, - APIKey: m.APIKey, Proxy: m.Proxy, - }) + } + if m.APIKey != "" { + mc.SetAPIKey(m.APIKey) + } + cfg.ModelList = append(cfg.ModelList, mc) } cfg.Channels = c.Channels.ToStandardChannels() @@ -1020,59 +1023,107 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { Enabled: c.WhatsApp.Enabled, BridgeURL: c.WhatsApp.BridgeURL, }, - Telegram: config.TelegramConfig{ - Enabled: c.Telegram.Enabled, - Token: c.Telegram.Token, - Proxy: c.Telegram.Proxy, - }, - Feishu: config.FeishuConfig{ - Enabled: c.Feishu.Enabled, - AppID: c.Feishu.AppID, - AppSecret: c.Feishu.AppSecret, - EncryptKey: c.Feishu.EncryptKey, - VerificationToken: c.Feishu.VerificationToken, - }, - Discord: config.DiscordConfig{ - Enabled: c.Discord.Enabled, - Token: c.Discord.Token, - MentionOnly: c.Discord.MentionOnly, - }, + Telegram: func() config.TelegramConfig { + tc := config.TelegramConfig{ + Enabled: c.Telegram.Enabled, + Proxy: c.Telegram.Proxy, + } + if c.Telegram.Token != "" { + tc.SetToken(c.Telegram.Token) + } + return tc + }(), + Feishu: func() config.FeishuConfig { + fc := config.FeishuConfig{ + Enabled: c.Feishu.Enabled, + AppID: c.Feishu.AppID, + } + if c.Feishu.AppSecret != "" { + fc.SetAppSecret(c.Feishu.AppSecret) + } + if c.Feishu.EncryptKey != "" { + fc.SetEncryptKey(c.Feishu.EncryptKey) + } + if c.Feishu.VerificationToken != "" { + fc.SetVerificationToken(c.Feishu.VerificationToken) + } + return fc + }(), + Discord: func() config.DiscordConfig { + dc := config.DiscordConfig{ + Enabled: c.Discord.Enabled, + MentionOnly: c.Discord.MentionOnly, + } + if c.Discord.Token != "" { + dc.SetToken(c.Discord.Token) + } + return dc + }(), MaixCam: config.MaixCamConfig{ Enabled: c.MaixCam.Enabled, Host: c.MaixCam.Host, Port: c.MaixCam.Port, }, - QQ: config.QQConfig{ - Enabled: c.QQ.Enabled, - AppID: c.QQ.AppID, - AppSecret: c.QQ.AppSecret, - }, - DingTalk: config.DingTalkConfig{ - Enabled: c.DingTalk.Enabled, - ClientID: c.DingTalk.ClientID, - ClientSecret: c.DingTalk.ClientSecret, - }, - Slack: config.SlackConfig{ - Enabled: c.Slack.Enabled, - BotToken: c.Slack.BotToken, - AppToken: c.Slack.AppToken, - }, - Matrix: config.MatrixConfig{ - Enabled: c.Matrix.Enabled, - Homeserver: c.Matrix.Homeserver, - UserID: c.Matrix.UserID, - AccessToken: c.Matrix.AccessToken, - AllowFrom: c.Matrix.AllowFrom, - JoinOnInvite: true, - }, - LINE: config.LINEConfig{ - Enabled: c.LINE.Enabled, - ChannelSecret: c.LINE.ChannelSecret, - ChannelAccessToken: c.LINE.ChannelAccessToken, - WebhookHost: c.LINE.WebhookHost, - WebhookPort: c.LINE.WebhookPort, - WebhookPath: c.LINE.WebhookPath, - }, + QQ: func() config.QQConfig { + qc := config.QQConfig{ + Enabled: c.QQ.Enabled, + AppID: c.QQ.AppID, + } + if c.QQ.AppSecret != "" { + qc.SetAppSecret(c.QQ.AppSecret) + } + return qc + }(), + DingTalk: func() config.DingTalkConfig { + dt := config.DingTalkConfig{ + Enabled: c.DingTalk.Enabled, + ClientID: c.DingTalk.ClientID, + } + if c.DingTalk.ClientSecret != "" { + dt.SetClientSecret(c.DingTalk.ClientSecret) + } + return dt + }(), + Slack: func() config.SlackConfig { + sc := config.SlackConfig{ + Enabled: c.Slack.Enabled, + } + if c.Slack.BotToken != "" { + sc.SetBotToken(c.Slack.BotToken) + } + if c.Slack.AppToken != "" { + sc.SetAppToken(c.Slack.AppToken) + } + return sc + }(), + Matrix: func() config.MatrixConfig { + mc := config.MatrixConfig{ + Enabled: c.Matrix.Enabled, + Homeserver: c.Matrix.Homeserver, + UserID: c.Matrix.UserID, + AllowFrom: c.Matrix.AllowFrom, + JoinOnInvite: true, + } + if c.Matrix.AccessToken != "" { + mc.SetAccessToken(c.Matrix.AccessToken) + } + return mc + }(), + LINE: func() config.LINEConfig { + lc := config.LINEConfig{ + Enabled: c.LINE.Enabled, + WebhookHost: c.LINE.WebhookHost, + WebhookPort: c.LINE.WebhookPort, + WebhookPath: c.LINE.WebhookPath, + } + if c.LINE.ChannelSecret != "" { + lc.SetChannelSecret(c.LINE.ChannelSecret) + } + if c.LINE.ChannelAccessToken != "" { + lc.SetChannelAccessToken(c.LINE.ChannelAccessToken) + } + return lc + }(), } } @@ -1084,30 +1135,44 @@ func (c GatewayConfig) ToStandardGateway() config.GatewayConfig { } func (c ToolsConfig) ToStandardTools() config.ToolsConfig { + brave := config.BraveConfig{ + Enabled: c.Web.Brave.Enabled, + MaxResults: c.Web.Brave.MaxResults, + } + if c.Web.Brave.APIKey != "" { + brave.SetAPIKey(c.Web.Brave.APIKey) + } + if len(c.Web.Brave.APIKeys) > 0 { + brave.SetAPIKeys(c.Web.Brave.APIKeys) + } + + tavily := config.TavilyConfig{ + Enabled: c.Web.Tavily.Enabled, + BaseURL: c.Web.Tavily.BaseURL, + MaxResults: c.Web.Tavily.MaxResults, + } + if c.Web.Tavily.APIKey != "" { + tavily.SetAPIKey(c.Web.Tavily.APIKey) + } + + perplexity := config.PerplexityConfig{ + Enabled: c.Web.Perplexity.Enabled, + MaxResults: c.Web.Perplexity.MaxResults, + } + if c.Web.Perplexity.APIKey != "" { + perplexity.SetAPIKey(c.Web.Perplexity.APIKey) + } + return config.ToolsConfig{ Web: config.WebToolsConfig{ - Brave: config.BraveConfig{ - Enabled: c.Web.Brave.Enabled, - APIKey: c.Web.Brave.APIKey, - APIKeys: c.Web.Brave.APIKeys, - MaxResults: c.Web.Brave.MaxResults, - }, - Tavily: config.TavilyConfig{ - Enabled: c.Web.Tavily.Enabled, - APIKey: c.Web.Tavily.APIKey, - BaseURL: c.Web.Tavily.BaseURL, - MaxResults: c.Web.Tavily.MaxResults, - }, + Brave: brave, + Tavily: tavily, DuckDuckGo: config.DuckDuckGoConfig{ Enabled: c.Web.DuckDuckGo.Enabled, MaxResults: c.Web.DuckDuckGo.MaxResults, }, - Perplexity: config.PerplexityConfig{ - Enabled: c.Web.Perplexity.Enabled, - APIKey: c.Web.Perplexity.APIKey, - MaxResults: c.Web.Perplexity.MaxResults, - }, - Proxy: c.Web.Proxy, + Perplexity: perplexity, + Proxy: c.Web.Proxy, }, Cron: config.CronToolsConfig{ ExecTimeoutMinutes: c.Cron.ExecTimeoutMinutes, diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go index 802693825..350b29776 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config_test.go +++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go @@ -697,7 +697,7 @@ func TestToStandardConfig(t *testing.T) { for _, m := range stdCfg.ModelList { if m.ModelName == "claude-sonnet-4-20250514" { foundModel = true - foundAPIKey = m.APIKey + foundAPIKey = m.APIKey() break } } @@ -711,8 +711,8 @@ func TestToStandardConfig(t *testing.T) { if !stdCfg.Channels.Telegram.Enabled { t.Error("telegram should be enabled") } - if stdCfg.Channels.Telegram.Token != "test-token" { - t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token) + if stdCfg.Channels.Telegram.Token() != "test-token" { + t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token()) } if stdCfg.Gateway.Port != 8080 { diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index d4d648f5a..bc9960f0c 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -413,10 +413,10 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { func TestCreateProvider_ClaudeCli(t *testing.T) { cfg := config.DefaultConfig() - cfg.ModelList = []config.ModelConfig{ + cfg.ModelList = []*config.ModelConfig{ {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"}, } - cfg.Agents.Defaults.Model = "claude-sonnet-4.6" + cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" provider, _, err := CreateProvider(cfg) if err != nil { @@ -434,10 +434,10 @@ func TestCreateProvider_ClaudeCli(t *testing.T) { func TestCreateProvider_ClaudeCode(t *testing.T) { cfg := config.DefaultConfig() - cfg.ModelList = []config.ModelConfig{ + cfg.ModelList = []*config.ModelConfig{ {ModelName: "claude-code", Model: "claude-cli/claude-code"}, } - cfg.Agents.Defaults.Model = "claude-code" + cfg.Agents.Defaults.ModelName = "claude-code" provider, _, err := CreateProvider(cfg) if err != nil { @@ -450,10 +450,10 @@ func TestCreateProvider_ClaudeCode(t *testing.T) { func TestCreateProvider_ClaudeCodec(t *testing.T) { cfg := config.DefaultConfig() - cfg.ModelList = []config.ModelConfig{ + cfg.ModelList = []*config.ModelConfig{ {ModelName: "claudecode", Model: "claude-cli/claudecode"}, } - cfg.Agents.Defaults.Model = "claudecode" + cfg.Agents.Defaults.ModelName = "claudecode" provider, _, err := CreateProvider(cfg) if err != nil { @@ -466,10 +466,10 @@ func TestCreateProvider_ClaudeCodec(t *testing.T) { func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { cfg := config.DefaultConfig() - cfg.ModelList = []config.ModelConfig{ + cfg.ModelList = []*config.ModelConfig{ {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"}, } - cfg.Agents.Defaults.Model = "claude-cli" + cfg.Agents.Defaults.ModelName = "claude-cli" cfg.Agents.Defaults.Workspace = "" provider, _, err := CreateProvider(cfg) diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index d2afe2943..354acafcb 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -1,400 +1,7 @@ package providers import ( - "fmt" - "strings" - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/config" ) -const defaultAnthropicAPIBase = "https://api.anthropic.com/v1" - var getCredential = auth.GetCredential - -type providerType int - -const ( - providerTypeHTTPCompat providerType = iota - providerTypeClaudeAuth - providerTypeCodexAuth - providerTypeCodexCLIToken - providerTypeClaudeCLI - providerTypeCodexCLI - providerTypeGitHubCopilot -) - -type providerSelection struct { - providerType providerType - apiKey string - apiBase string - proxy string - model string - workspace string - connectMode string - enableWebSearch bool -} - -func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { - model := cfg.Agents.Defaults.GetModelName() - providerName := strings.ToLower(cfg.Agents.Defaults.Provider) - lowerModel := strings.ToLower(model) - - if providerName == "" && model == "" { - return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty") - } - - sel := providerSelection{ - providerType: providerTypeHTTPCompat, - model: model, - } - - // First, prefer explicit provider configuration. - if providerName != "" { - switch providerName { - case "groq": - if cfg.Providers.Groq.APIKey != "" { - sel.apiKey = cfg.Providers.Groq.APIKey - sel.apiBase = cfg.Providers.Groq.APIBase - sel.proxy = cfg.Providers.Groq.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.groq.com/openai/v1" - } - } - case "openai", "gpt": - if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { - sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch - if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { - sel.providerType = providerTypeCodexCLIToken - return sel, nil - } - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - sel.providerType = providerTypeCodexAuth - return sel, nil - } - sel.apiKey = cfg.Providers.OpenAI.APIKey - sel.apiBase = cfg.Providers.OpenAI.APIBase - sel.proxy = cfg.Providers.OpenAI.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.openai.com/v1" - } - } - case "anthropic", "claude": - if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - sel.apiBase = cfg.Providers.Anthropic.APIBase - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - sel.providerType = providerTypeClaudeAuth - return sel, nil - } - sel.apiKey = cfg.Providers.Anthropic.APIKey - sel.apiBase = cfg.Providers.Anthropic.APIBase - sel.proxy = cfg.Providers.Anthropic.Proxy - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - } - case "openrouter": - if cfg.Providers.OpenRouter.APIKey != "" { - sel.apiKey = cfg.Providers.OpenRouter.APIKey - sel.proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - sel.apiBase = cfg.Providers.OpenRouter.APIBase - } else { - sel.apiBase = "https://openrouter.ai/api/v1" - } - } - case "litellm": - if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" { - sel.apiKey = cfg.Providers.LiteLLM.APIKey - sel.apiBase = cfg.Providers.LiteLLM.APIBase - sel.proxy = cfg.Providers.LiteLLM.Proxy - if sel.apiBase == "" { - sel.apiBase = "http://localhost:4000/v1" - } - } - case "zhipu", "glm": - if cfg.Providers.Zhipu.APIKey != "" { - sel.apiKey = cfg.Providers.Zhipu.APIKey - sel.apiBase = cfg.Providers.Zhipu.APIBase - sel.proxy = cfg.Providers.Zhipu.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - } - case "gemini", "google": - if cfg.Providers.Gemini.APIKey != "" { - sel.apiKey = cfg.Providers.Gemini.APIKey - sel.apiBase = cfg.Providers.Gemini.APIBase - sel.proxy = cfg.Providers.Gemini.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - } - case "vllm": - if cfg.Providers.VLLM.APIBase != "" { - sel.apiKey = cfg.Providers.VLLM.APIKey - sel.apiBase = cfg.Providers.VLLM.APIBase - sel.proxy = cfg.Providers.VLLM.Proxy - } - case "shengsuanyun": - if cfg.Providers.ShengSuanYun.APIKey != "" { - sel.apiKey = cfg.Providers.ShengSuanYun.APIKey - sel.apiBase = cfg.Providers.ShengSuanYun.APIBase - sel.proxy = cfg.Providers.ShengSuanYun.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://router.shengsuanyun.com/api/v1" - } - } - case "nvidia": - if cfg.Providers.Nvidia.APIKey != "" { - sel.apiKey = cfg.Providers.Nvidia.APIKey - sel.apiBase = cfg.Providers.Nvidia.APIBase - sel.proxy = cfg.Providers.Nvidia.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://integrate.api.nvidia.com/v1" - } - } - case "vivgrid": - if cfg.Providers.Vivgrid.APIKey != "" { - sel.apiKey = cfg.Providers.Vivgrid.APIKey - sel.apiBase = cfg.Providers.Vivgrid.APIBase - sel.proxy = cfg.Providers.Vivgrid.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.vivgrid.com/v1" - } - } - case "claude-cli", "claude-code", "claudecode": - workspace := cfg.WorkspacePath() - if workspace == "" { - workspace = "." - } - sel.providerType = providerTypeClaudeCLI - sel.workspace = workspace - return sel, nil - case "codex-cli", "codex-code": - workspace := cfg.WorkspacePath() - if workspace == "" { - workspace = "." - } - sel.providerType = providerTypeCodexCLI - sel.workspace = workspace - return sel, nil - case "deepseek": - if cfg.Providers.DeepSeek.APIKey != "" { - sel.apiKey = cfg.Providers.DeepSeek.APIKey - sel.apiBase = cfg.Providers.DeepSeek.APIBase - sel.proxy = cfg.Providers.DeepSeek.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.deepseek.com/v1" - } - if model != "deepseek-chat" && model != "deepseek-reasoner" { - sel.model = "deepseek-chat" - } - } - case "avian": - if cfg.Providers.Avian.APIKey != "" { - sel.apiKey = cfg.Providers.Avian.APIKey - sel.apiBase = cfg.Providers.Avian.APIBase - sel.proxy = cfg.Providers.Avian.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.avian.io/v1" - } - } - case "mistral": - if cfg.Providers.Mistral.APIKey != "" { - sel.apiKey = cfg.Providers.Mistral.APIKey - sel.apiBase = cfg.Providers.Mistral.APIBase - sel.proxy = cfg.Providers.Mistral.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.mistral.ai/v1" - } - } - case "minimax": - if cfg.Providers.Minimax.APIKey != "" { - sel.apiKey = cfg.Providers.Minimax.APIKey - sel.apiBase = cfg.Providers.Minimax.APIBase - sel.proxy = cfg.Providers.Minimax.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.minimaxi.com/v1" - } - } - case "longcat": - if cfg.Providers.LongCat.APIKey != "" { - sel.apiKey = cfg.Providers.LongCat.APIKey - sel.apiBase = cfg.Providers.LongCat.APIBase - sel.proxy = cfg.Providers.LongCat.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.longcat.chat/openai" - } - } - case "github_copilot", "copilot": - sel.providerType = providerTypeGitHubCopilot - if cfg.Providers.GitHubCopilot.APIBase != "" { - sel.apiBase = cfg.Providers.GitHubCopilot.APIBase - } else { - sel.apiBase = "localhost:4321" - } - sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode - return sel, nil - } - } - - // Fallback: infer provider from model and configured keys. - if sel.apiKey == "" && sel.apiBase == "" { - switch { - case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": - sel.apiKey = cfg.Providers.Moonshot.APIKey - sel.apiBase = cfg.Providers.Moonshot.APIBase - sel.proxy = cfg.Providers.Moonshot.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.moonshot.cn/v1" - } - case strings.HasPrefix(model, "openrouter/") || - strings.HasPrefix(model, "anthropic/") || - strings.HasPrefix(model, "openai/") || - strings.HasPrefix(model, "meta-llama/") || - strings.HasPrefix(model, "deepseek/") || - strings.HasPrefix(model, "google/"): - sel.apiKey = cfg.Providers.OpenRouter.APIKey - sel.proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - sel.apiBase = cfg.Providers.OpenRouter.APIBase - } else { - sel.apiBase = "https://openrouter.ai/api/v1" - } - case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && - (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - sel.apiBase = cfg.Providers.Anthropic.APIBase - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - sel.providerType = providerTypeClaudeAuth - return sel, nil - } - sel.apiKey = cfg.Providers.Anthropic.APIKey - sel.apiBase = cfg.Providers.Anthropic.APIBase - sel.proxy = cfg.Providers.Anthropic.Proxy - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && - (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): - sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch - if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { - sel.providerType = providerTypeCodexCLIToken - return sel, nil - } - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - sel.providerType = providerTypeCodexAuth - return sel, nil - } - sel.apiKey = cfg.Providers.OpenAI.APIKey - sel.apiBase = cfg.Providers.OpenAI.APIBase - sel.proxy = cfg.Providers.OpenAI.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.openai.com/v1" - } - case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": - sel.apiKey = cfg.Providers.Gemini.APIKey - sel.apiBase = cfg.Providers.Gemini.APIBase - sel.proxy = cfg.Providers.Gemini.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": - sel.apiKey = cfg.Providers.Zhipu.APIKey - sel.apiBase = cfg.Providers.Zhipu.APIBase - sel.proxy = cfg.Providers.Zhipu.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": - sel.apiKey = cfg.Providers.Groq.APIKey - sel.apiBase = cfg.Providers.Groq.APIBase - sel.proxy = cfg.Providers.Groq.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.groq.com/openai/v1" - } - case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "": - sel.apiKey = cfg.Providers.Nvidia.APIKey - sel.apiBase = cfg.Providers.Nvidia.APIBase - sel.proxy = cfg.Providers.Nvidia.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://integrate.api.nvidia.com/v1" - } - case strings.HasPrefix(model, "vivgrid/") && cfg.Providers.Vivgrid.APIKey != "": - sel.apiKey = cfg.Providers.Vivgrid.APIKey - sel.apiBase = cfg.Providers.Vivgrid.APIBase - sel.proxy = cfg.Providers.Vivgrid.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.vivgrid.com/v1" - } - case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "": - sel.apiKey = cfg.Providers.Ollama.APIKey - sel.apiBase = cfg.Providers.Ollama.APIBase - sel.proxy = cfg.Providers.Ollama.Proxy - if sel.apiBase == "" { - sel.apiBase = "http://localhost:11434/v1" - } - case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "": - sel.apiKey = cfg.Providers.Mistral.APIKey - sel.apiBase = cfg.Providers.Mistral.APIBase - sel.proxy = cfg.Providers.Mistral.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.mistral.ai/v1" - } - case (strings.Contains(lowerModel, "minimax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.Minimax.APIKey != "": - sel.apiKey = cfg.Providers.Minimax.APIKey - sel.apiBase = cfg.Providers.Minimax.APIBase - sel.proxy = cfg.Providers.Minimax.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.minimaxi.com/v1" - } - case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "": - sel.apiKey = cfg.Providers.Avian.APIKey - sel.apiBase = cfg.Providers.Avian.APIBase - sel.proxy = cfg.Providers.Avian.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.avian.io/v1" - } - case (strings.Contains(lowerModel, "longcat") || strings.HasPrefix(model, "longcat/")) && cfg.Providers.LongCat.APIKey != "": - sel.apiKey = cfg.Providers.LongCat.APIKey - sel.apiBase = cfg.Providers.LongCat.APIBase - sel.proxy = cfg.Providers.LongCat.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.longcat.chat/openai" - } - case cfg.Providers.VLLM.APIBase != "": - sel.apiKey = cfg.Providers.VLLM.APIKey - sel.apiBase = cfg.Providers.VLLM.APIBase - sel.proxy = cfg.Providers.VLLM.Proxy - default: - if cfg.Providers.OpenRouter.APIKey != "" { - sel.apiKey = cfg.Providers.OpenRouter.APIKey - sel.proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - sel.apiBase = cfg.Providers.OpenRouter.APIBase - } else { - sel.apiBase = "https://openrouter.ai/api/v1" - } - } else { - return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model) - } - } - } - - if sel.providerType == providerTypeHTTPCompat { - if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") { - return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model) - } - if sel.apiBase == "" { - return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model) - } - } - - return sel, nil -} diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a7fef8f5b..8a18f8fe7 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -80,7 +80,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return provider, modelID, nil } // OpenAI with API key - if cfg.APIKey == "" && cfg.APIBase == "" { + if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase @@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase = getDefaultAPIBase(protocol) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, + cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, @@ -98,7 +98,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "azure", "azure-openai": // Azure OpenAI uses deployment-based URLs, api-key header auth, // and always sends max_completion_tokens. - if cfg.APIKey == "" { + if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for azure protocol") } if cfg.APIBase == "" { @@ -107,7 +107,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err ) } return azure.NewProviderWithTimeout( - cfg.APIKey, + cfg.APIKey(), cfg.APIBase, cfg.Proxy, cfg.RequestTimeout, @@ -119,7 +119,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding": // All other OpenAI-compatible HTTP providers - if cfg.APIKey == "" && cfg.APIBase == "" { + if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase @@ -127,7 +127,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase = getDefaultAPIBase(protocol) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, + cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, @@ -148,11 +148,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = "https://api.anthropic.com/v1" } - if cfg.APIKey == "" { + if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, + cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, @@ -165,11 +165,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = "https://api.anthropic.com/v1" } - if cfg.APIKey == "" { + if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model) } return anthropicmessages.NewProviderWithTimeout( - cfg.APIKey, + cfg.APIKey(), apiBase, cfg.RequestTimeout, ), modelID, nil @@ -180,11 +180,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - if cfg.APIKey == "" { + if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) } return anthropicmessages.NewProviderWithTimeout( - cfg.APIKey, + cfg.APIKey(), apiBase, cfg.RequestTimeout, ), modelID, nil diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 8b9ddeecd..fb980f32f 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -89,9 +89,9 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-openai", Model: "openai/gpt-4o", - APIKey: "test-key", APIBase: "https://api.example.com/v1", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -129,8 +129,8 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-" + tt.protocol, Model: tt.protocol + "/test-model", - APIKey: "test-key", } + cfg.SetAPIKey("test-key") provider, _, err := CreateProviderFromConfig(cfg) if err != nil { @@ -155,9 +155,9 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", Model: "litellm/my-proxy-alias", - APIKey: "test-key", APIBase: "http://localhost:4000/v1", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -175,9 +175,9 @@ func TestCreateProviderFromConfig_LongCat(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-longcat", Model: "longcat/LongCat-Flash-Thinking", - APIKey: "test-key", APIBase: "https://api.longcat.chat/openai", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -198,9 +198,9 @@ func TestCreateProviderFromConfig_ModelScope(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-modelscope", Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", - APIKey: "test-key", APIBase: "https://api-inference.modelscope.cn/v1", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -227,8 +227,8 @@ func TestCreateProviderFromConfig_Novita(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-novita", Model: "novita/deepseek/deepseek-v3.2", - APIKey: "test-key", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -255,8 +255,8 @@ func TestCreateProviderFromConfig_Anthropic(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-anthropic", Model: "anthropic/claude-sonnet-4.6", - APIKey: "test-key", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -340,8 +340,8 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-unknown", Model: "unknown-protocol/model", - APIKey: "test-key", } + cfg.SetAPIKey("test-key") _, _, err := CreateProviderFromConfig(cfg) if err == nil { @@ -382,6 +382,7 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) { APIBase: server.URL, RequestTimeout: 1, } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -411,9 +412,9 @@ func TestCreateProviderFromConfig_Azure(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "azure-gpt5", Model: "azure/my-gpt5-deployment", - APIKey: "test-azure-key", APIBase: "https://my-resource.openai.azure.com", } + cfg.SetAPIKey("test-azure-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -431,9 +432,9 @@ func TestCreateProviderFromConfig_AzureOpenAIAlias(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "azure-gpt4", Model: "azure-openai/my-deployment", - APIKey: "test-azure-key", APIBase: "https://my-resource.openai.azure.com", } + cfg.SetAPIKey("test-azure-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -464,8 +465,8 @@ func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "azure-gpt5", Model: "azure/my-gpt5-deployment", - APIKey: "test-azure-key", } + cfg.SetAPIKey("test-azure-key") _, _, err := CreateProviderFromConfig(cfg) if err == nil { @@ -488,8 +489,8 @@ func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-" + tt.protocol, Model: tt.protocol + "/qwen-max", - APIKey: "test-key", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -522,8 +523,8 @@ func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-" + tt.protocol, Model: tt.protocol + "/qwen-max", - APIKey: "test-key", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -556,8 +557,8 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-" + tt.protocol, Model: tt.protocol + "/claude-sonnet-4-20250514", - APIKey: "test-key", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go index 91469f25b..b99f5baf9 100644 --- a/pkg/providers/factory_test.go +++ b/pkg/providers/factory_test.go @@ -1,262 +1,22 @@ package providers import ( - "strings" "testing" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) -func TestResolveProviderSelection(t *testing.T) { - tests := []struct { - name string - setup func(*config.Config) - wantType providerType - wantAPIBase string - wantProxy string - wantErrSubstr string - }{ - { - name: "explicit litellm provider uses configured base", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "litellm" - cfg.Providers.LiteLLM.APIKey = "litellm-key" - cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1" - cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "http://localhost:4000/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "explicit litellm provider defaults base when only key is configured", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "litellm" - cfg.Providers.LiteLLM.APIKey = "litellm-key" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "http://localhost:4000/v1", - }, - { - name: "explicit claude-cli provider routes to cli provider type", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "claude-cli" - cfg.Agents.Defaults.Workspace = "/tmp/ws" - }, - wantType: providerTypeClaudeCLI, - }, - { - name: "explicit copilot provider routes to github copilot type", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "copilot" - }, - wantType: providerTypeGitHubCopilot, - wantAPIBase: "localhost:4321", - }, - { - name: "explicit deepseek provider uses deepseek defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "deepseek" - cfg.Agents.Defaults.Model = "deepseek/deepseek-chat" - cfg.Providers.DeepSeek.APIKey = "deepseek-key" - cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.deepseek.com/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "explicit shengsuanyun provider uses defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "shengsuanyun" - cfg.Providers.ShengSuanYun.APIKey = "ssy-key" - cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://router.shengsuanyun.com/api/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "explicit nvidia provider uses defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "nvidia" - cfg.Providers.Nvidia.APIKey = "nvapi-test" - cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://integrate.api.nvidia.com/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "explicit vivgrid provider uses defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "vivgrid" - cfg.Providers.Vivgrid.APIKey = "vivgrid-key" - cfg.Providers.Vivgrid.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.vivgrid.com/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "openrouter model uses openrouter defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "openrouter/auto" - cfg.Providers.OpenRouter.APIKey = "sk-or-test" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://openrouter.ai/api/v1", - }, - { - name: "anthropic oauth routes to claude auth provider", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "claude-sonnet-4.6" - cfg.Providers.Anthropic.AuthMethod = "oauth" - }, - wantType: providerTypeClaudeAuth, - }, - { - name: "openai oauth routes to codex auth provider", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "gpt-4o" - cfg.Providers.OpenAI.AuthMethod = "oauth" - }, - wantType: providerTypeCodexAuth, - }, - { - name: "openai codex-cli auth routes to codex cli token provider", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "gpt-4o" - cfg.Providers.OpenAI.AuthMethod = "codex-cli" - }, - wantType: providerTypeCodexCLIToken, - }, - { - name: "explicit codex-code provider routes to codex cli provider type", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "codex-code" - cfg.Agents.Defaults.Workspace = "/tmp/ws" - }, - wantType: providerTypeCodexCLI, - }, - { - name: "zhipu model uses zhipu base default", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "glm-4.7" - cfg.Providers.Zhipu.APIKey = "zhipu-key" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://open.bigmodel.cn/api/paas/v4", - }, - { - name: "groq model uses groq base default", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "groq/llama-3.3-70b" - cfg.Providers.Groq.APIKey = "gsk-key" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.groq.com/openai/v1", - }, - { - name: "ollama model uses ollama base default", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "ollama/qwen2.5:14b" - cfg.Providers.Ollama.APIKey = "ollama-key" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "http://localhost:11434/v1", - }, - { - name: "moonshot model keeps proxy and default base", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5" - cfg.Providers.Moonshot.APIKey = "moonshot-key" - cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.moonshot.cn/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "explicit longcat provider uses defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "longcat" - cfg.Providers.LongCat.APIKey = "longcat-key" - cfg.Providers.LongCat.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.longcat.chat/openai", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "longcat model fallback uses longcat base default", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "longcat/LongCat-Flash-Thinking" - cfg.Providers.LongCat.APIKey = "longcat-key" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.longcat.chat/openai", - }, - { - name: "missing keys returns model config error", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "custom-model" - }, - wantErrSubstr: "no API key configured for model", - }, - { - name: "openrouter prefix without key returns provider key error", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "openrouter/auto" - }, - wantErrSubstr: "no API key configured for provider", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.DefaultConfig() - tt.setup(cfg) - - got, err := resolveProviderSelection(cfg) - if tt.wantErrSubstr != "" { - if err == nil { - t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr) - } - if !strings.Contains(err.Error(), tt.wantErrSubstr) { - t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr) - } - return - } - - if err != nil { - t.Fatalf("resolveProviderSelection() error = %v", err) - } - if got.providerType != tt.wantType { - t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType) - } - if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase { - t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase) - } - if tt.wantProxy != "" && got.proxy != tt.wantProxy { - t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy) - } - }) - } -} - func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) { cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "test-openrouter" - cfg.ModelList = []config.ModelConfig{ - { - ModelName: "test-openrouter", - Model: "openrouter/auto", - APIKey: "sk-or-test", - APIBase: "https://openrouter.ai/api/v1", - }, + cfg.Agents.Defaults.ModelName = "test-openrouter" + modelCfg := &config.ModelConfig{ + ModelName: "test-openrouter", + Model: "openrouter/auto", + APIBase: "https://openrouter.ai/api/v1", } + modelCfg.SetAPIKey("sk-or-test") + cfg.ModelList = []*config.ModelConfig{modelCfg} provider, _, err := CreateProvider(cfg) if err != nil { @@ -270,8 +30,8 @@ func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) { func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) { cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "test-codex" - cfg.ModelList = []config.ModelConfig{ + cfg.Agents.Defaults.ModelName = "test-codex" + cfg.ModelList = []*config.ModelConfig{ { ModelName: "test-codex", Model: "codex-cli/codex-model", @@ -291,8 +51,8 @@ func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) { func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) { cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "test-claude-cli" - cfg.ModelList = []config.ModelConfig{ + cfg.Agents.Defaults.ModelName = "test-claude-cli" + cfg.ModelList = []*config.ModelConfig{ { ModelName: "test-claude-cli", Model: "claude-cli/claude-sonnet", @@ -324,8 +84,8 @@ func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) { } cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "test-claude-oauth" - cfg.ModelList = []config.ModelConfig{ + cfg.Agents.Defaults.ModelName = "test-claude-oauth" + cfg.ModelList = []*config.ModelConfig{ { ModelName: "test-claude-oauth", Model: "anthropic/claude-sonnet-4.6", diff --git a/pkg/providers/legacy_provider.go b/pkg/providers/legacy_provider.go index 26905159f..4b0815dd4 100644 --- a/pkg/providers/legacy_provider.go +++ b/pkg/providers/legacy_provider.go @@ -18,23 +18,6 @@ import ( func CreateProvider(cfg *config.Config) (LLMProvider, string, error) { model := cfg.Agents.Defaults.GetModelName() - // Ensure model_list is populated from providers config if needed - // This handles two cases: - // 1. ModelList is empty - convert all providers - // 2. ModelList has some entries but not all providers - merge missing ones - if cfg.HasProvidersConfig() { - providerModels := config.ConvertProvidersToModelList(cfg) - existingModelNames := make(map[string]bool) - for _, m := range cfg.ModelList { - existingModelNames[m.ModelName] = true - } - for _, pm := range providerModels { - if !existingModelNames[pm.ModelName] { - cfg.ModelList = append(cfg.ModelList, pm) - } - } - } - // Must have model_list at this point if len(cfg.ModelList) == 0 { return nil, "", fmt.Errorf("no providers configured. Please add entries to model_list in your config") diff --git a/pkg/routing/route_test.go b/pkg/routing/route_test.go index 8255db5f9..fdfc899f9 100644 --- a/pkg/routing/route_test.go +++ b/pkg/routing/route_test.go @@ -11,7 +11,7 @@ func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *co Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: "/tmp/picoclaw-test", - Model: "gpt-4", + ModelName: "gpt-4", }, List: agents, }, diff --git a/pkg/voice/audio_model_transcriber.go b/pkg/voice/audio_model_transcriber.go index 94486b5e4..f3ca81961 100644 --- a/pkg/voice/audio_model_transcriber.go +++ b/pkg/voice/audio_model_transcriber.go @@ -29,7 +29,7 @@ func NewAudioModelTranscriber(modelCfg *config.ModelConfig) *AudioModelTranscrib } logger.DebugCF("voice", "Creating audio model transcriber", map[string]any{ - "has_api_key": modelCfg.APIKey != "", + "has_api_key": modelCfg.APIKey() != "", "api_base": modelCfg.APIBase, "model": modelCfg.Model, }) diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index f3e6af71e..a50fba8f8 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -54,14 +54,10 @@ func DetectTranscriber(cfg *config.Config) Transcriber { } } - // Direct Groq provider config takes priority. - if key := cfg.Providers.Groq.APIKey; key != "" { - return NewGroqTranscriber(key) - } // Fall back to any model-list entry that uses the groq/ protocol. for _, mc := range cfg.ModelList { - if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { - return NewGroqTranscriber(mc.APIKey) + if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" { + return NewGroqTranscriber(mc.APIKey()) } } return nil diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go index 1b20bf9f2..20ba5388b 100644 --- a/pkg/voice/transcriber_test.go +++ b/pkg/voice/transcriber_test.go @@ -18,99 +18,131 @@ func TestDetectTranscriber(t *testing.T) { cfg: &config.Config{}, wantNil: true, }, - { - name: "groq provider key", - cfg: &config.Config{ - Providers: config.ProvidersConfig{ - Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, - }, - }, - wantName: "groq", - }, { name: "voice model name selects audio model transcriber", - cfg: &config.Config{ + cfg: (&config.Config{ Voice: config.VoiceConfig{ModelName: "voice-gemini"}, - ModelList: []config.ModelConfig{ - {ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash", APIKey: "sk-gemini-model"}, + ModelList: []*config.ModelConfig{ + {ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"}, }, - }, + }).WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "voice-gemini": { + APIKeys: []string{"sk-gemini-model"}, + }, + }, + }), wantName: "audio-model", }, { name: "groq via model list", - cfg: &config.Config{ - ModelList: []config.ModelConfig{ - {Model: "openai/gpt-4o", APIKey: "sk-openai"}, - {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, + cfg: (&config.Config{ + ModelList: []*config.ModelConfig{ + {ModelName: "openai", Model: "openai/gpt-4o"}, + {ModelName: "groq", Model: "groq/llama-3.3-70b"}, }, - }, + }).WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "openai": { + APIKeys: []string{"sk-openai"}, + }, + "groq": { + APIKeys: []string{"sk-groq-model"}, + }, + }, + }), wantName: "groq", }, { name: "voice model name selects non-gemini audio model transcriber", - cfg: &config.Config{ + cfg: (&config.Config{ Voice: config.VoiceConfig{ModelName: "voice-openai-audio"}, - ModelList: []config.ModelConfig{ - {ModelName: "voice-openai-audio", Model: "openai/gpt-4o-audio-preview", APIKey: "sk-openai"}, + ModelList: []*config.ModelConfig{ + {ModelName: "voice-openai-audio", Model: "openai/gpt-4o-audio-preview"}, }, - }, + }).WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "voice-openai-audio": { + APIKeys: []string{"sk-openai"}, + }, + }, + }), wantName: "audio-model", }, { name: "voice model name selects azure audio model transcriber", - cfg: &config.Config{ + cfg: (&config.Config{ Voice: config.VoiceConfig{ModelName: "voice-azure-audio"}, - ModelList: []config.ModelConfig{ + ModelList: []*config.ModelConfig{ { ModelName: "voice-azure-audio", Model: "azure/my-audio-deployment", - APIKey: "sk-azure", APIBase: "https://example.openai.azure.com", }, }, - }, + }).WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "voice-azure-audio": { + APIKeys: []string{"sk-azure"}, + }, + }, + }), wantName: "audio-model", }, { name: "voice model name with non openai compatible protocol does not select audio model transcriber", - cfg: &config.Config{ + cfg: (&config.Config{ Voice: config.VoiceConfig{ModelName: "voice-anthropic"}, - ModelList: []config.ModelConfig{ - {ModelName: "voice-anthropic", Model: "anthropic/claude-sonnet-4.6", APIKey: "sk-anthropic"}, + ModelList: []*config.ModelConfig{ + {ModelName: "voice-anthropic", Model: "anthropic/claude-sonnet-4.6"}, }, - }, + }).WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "voice-anthropic": { + APIKeys: []string{"sk-anthropic"}, + }, + }, + }), wantNil: true, }, { name: "groq model list entry without key is skipped", cfg: &config.Config{ - ModelList: []config.ModelConfig{ - {Model: "groq/llama-3.3-70b", APIKey: ""}, + ModelList: []*config.ModelConfig{ + {Model: "groq/llama-3.3-70b"}, }, }, wantNil: true, }, { name: "provider key takes priority over model list", - cfg: &config.Config{ - Providers: config.ProvidersConfig{ - Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, + cfg: (&config.Config{ + ModelList: []*config.ModelConfig{ + {ModelName: "groq", Model: "groq/llama-3.3-70b"}, }, - ModelList: []config.ModelConfig{ - {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, + }).WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "groq": { + APIKeys: []string{"sk-groq-model"}, + }, }, - }, + }), wantName: "groq", }, { name: "missing voice model name config returns nil", - cfg: &config.Config{ + cfg: (&config.Config{ Voice: config.VoiceConfig{ModelName: "missing"}, - ModelList: []config.ModelConfig{ - {ModelName: "other", Model: "gemini/gemini-2.5-flash", APIKey: "sk-gemini-model"}, + ModelList: []*config.ModelConfig{ + {ModelName: "other", Model: "gemini/gemini-2.5-flash"}, }, - }, + }).WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "other": { + APIKeys: []string{"sk-other-model"}, + }, + }, + }), wantNil: true, }, } diff --git a/web/backend/api/config.go b/web/backend/api/config.go index a7d5b3c5d..7cdfde174 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -8,6 +8,7 @@ import ( "regexp" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) // registerConfigRoutes binds configuration management endpoints to the ServeMux. @@ -45,7 +46,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var cfg config.Config - if err := json.Unmarshal(body, &cfg); err != nil { + if err = json.Unmarshal(body, &cfg); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } @@ -63,6 +64,14 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { return } + logger.Infof("new config: %+v", cfg) + oldCfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + cfg.SecurityCopyFrom(oldCfg) + if err := config.SaveConfig(h.configPath, &cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return @@ -150,6 +159,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { return } + newCfg.SecurityCopyFrom(cfg) + if err := config.SaveConfig(h.configPath, &newCfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return @@ -175,17 +186,17 @@ func validateConfig(cfg *config.Config) []string { } // Pico channel: token required when enabled - if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" { + if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token() == "" { errs = append(errs, "channels.pico.token is required when pico channel is enabled") } // Telegram: token required when enabled - if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" { + if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token() == "" { errs = append(errs, "channels.telegram.token is required when telegram channel is enabled") } // Discord: token required when enabled - if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" { + if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token() == "" { errs = append(errs, "channels.discord.token is required when discord channel is enabled") } diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 54ec8e857..bbf285e14 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -18,6 +18,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ +"version": 1, "agents": { "defaults": { "workspace": "~/.picoclaw/workspace" @@ -27,7 +28,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin { "model_name": "custom-default", "model": "openai/gpt-4o", - "api_key": "sk-default" + "api_keys": ["sk-default"] } ] }`)) diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index d5ccd6e29..7f72f12b8 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -159,10 +159,10 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { return false, fmt.Sprintf("default model %q is invalid", modelName), nil } - if !hasModelConfiguration(*modelCfg) { + if !hasModelConfiguration(modelCfg) { return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil } - if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) { + if requiresRuntimeProbe(modelCfg) && !probeLocalModelAvailability(modelCfg) { return false, fmt.Sprintf("default model %q is not reachable", modelName), nil } diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 387c5ac53..a5ba2bad2 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -101,7 +101,7 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) { func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "missing-model" + cfg.Agents.Defaults.ModelName = "missing-model" err := config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) @@ -124,7 +124,7 @@ func TestGatewayStartReady_ValidDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName - cfg.ModelList[0].APIKey = "test-key" + cfg.ModelList[0].SetAPIKey("test-key") err := config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) @@ -144,7 +144,7 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName - cfg.ModelList[0].APIKey = "" + cfg.ModelList[0].SetAPIKey("") cfg.ModelList[0].AuthMethod = "" err := config.SaveConfig(configPath, cfg) if err != nil { @@ -177,7 +177,7 @@ func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - cfg.ModelList = []config.ModelConfig{{ + cfg.ModelList = []*config.ModelConfig{{ ModelName: "local-vllm", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1", @@ -214,7 +214,7 @@ func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - cfg.ModelList = []config.ModelConfig{{ + cfg.ModelList = []*config.ModelConfig{{ ModelName: "local-vllm", Model: "vllm/custom-model", APIBase: "http://127.0.0.1:8000/v1", @@ -249,12 +249,12 @@ func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - cfg.ModelList = []config.ModelConfig{{ + cfg.ModelList = []*config.ModelConfig{{ ModelName: "remote-vllm", Model: "vllm/custom-model", APIBase: "https://models.example.com/v1", - APIKey: "remote-key", }} + cfg.ModelList[0o0].SetAPIKey("remote-key") cfg.Agents.Defaults.ModelName = "remote-vllm" err = config.SaveConfig(configPath, cfg) if err != nil { @@ -284,7 +284,7 @@ func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - cfg.ModelList = []config.ModelConfig{{ + cfg.ModelList = []*config.ModelConfig{{ ModelName: "local-ollama", Model: "ollama/llama3", }} @@ -312,7 +312,7 @@ func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - cfg.ModelList = []config.ModelConfig{{ + cfg.ModelList = []*config.ModelConfig{{ ModelName: "openai-oauth", Model: "openai/gpt-5.4", AuthMethod: "oauth", @@ -483,12 +483,12 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName - cfg.ModelList[0].APIKey = "test-key" - cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + cfg.ModelList[0].SetAPIKey("test-key") + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ ModelName: "second-model", Model: "openai/gpt-4.1", - APIKey: "second-key", }) + cfg.ModelList[len(cfg.ModelList)-1].SetAPIKey("second-key") if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -632,7 +632,7 @@ func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName - cfg.ModelList[0].APIKey = "" + cfg.ModelList[0].SetAPIKey("") cfg.ModelList[0].AuthMethod = "" if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) @@ -685,7 +685,7 @@ func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName - cfg.ModelList[0].APIKey = "test-key" + cfg.ModelList[0].SetAPIKey("test-key") if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -751,7 +751,7 @@ func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing. configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName - cfg.ModelList[0].APIKey = "test-key" + cfg.ModelList[0].SetAPIKey("test-key") if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index b56fe5f39..aeef85119 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -20,9 +20,9 @@ var ( probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel ) -func hasModelConfiguration(m config.ModelConfig) bool { +func hasModelConfiguration(m *config.ModelConfig) bool { authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) - apiKey := strings.TrimSpace(m.APIKey) + apiKey := strings.TrimSpace(m.APIKey()) if authMethod == "oauth" || authMethod == "token" { if provider, ok := oauthProviderForModel(m.Model); ok { @@ -44,7 +44,7 @@ func hasModelConfiguration(m config.ModelConfig) bool { // isModelConfigured reports whether a model is currently available to use. // Local models must be reachable; remote/API-key models only need saved config. -func isModelConfigured(m config.ModelConfig) bool { +func isModelConfigured(m *config.ModelConfig) bool { if !hasModelConfiguration(m) { return false } @@ -54,7 +54,7 @@ func isModelConfigured(m config.ModelConfig) bool { return true } -func requiresRuntimeProbe(m config.ModelConfig) bool { +func requiresRuntimeProbe(m *config.ModelConfig) bool { authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) if authMethod == "local" { return true @@ -75,27 +75,27 @@ func requiresRuntimeProbe(m config.ModelConfig) bool { return false } -func probeLocalModelAvailability(m config.ModelConfig) bool { +func probeLocalModelAvailability(m *config.ModelConfig) bool { apiBase := modelProbeAPIBase(m) protocol, modelID := splitModel(m.Model) switch protocol { case "ollama": return probeOllamaModelFunc(apiBase, modelID) case "vllm": - return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey) + return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot", "copilot": return probeTCPServiceFunc(apiBase) case "claude-cli", "claudecli", "codex-cli", "codexcli": return true default: if hasLocalAPIBase(apiBase) { - return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey) + return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) } return false } } -func modelProbeAPIBase(m config.ModelConfig) string { +func modelProbeAPIBase(m *config.ModelConfig) string { if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" { return normalizeModelProbeAPIBase(apiBase) } diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go index 047af7a4d..df942a9e9 100644 --- a/web/backend/api/model_status_test.go +++ b/web/backend/api/model_status_test.go @@ -25,11 +25,11 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T })) defer srv.Close() - model := config.ModelConfig{ + model := &config.ModelConfig{ Model: "openai/custom-model", APIBase: srv.URL + "/v1", - APIKey: apiKey, } + model.SetAPIKey(apiKey) if !probeLocalModelAvailability(model) { t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured") diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 7f3d29c77..dd71ad25a 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -58,7 +58,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { var wg sync.WaitGroup wg.Add(len(cfg.ModelList)) for i, m := range cfg.ModelList { - go func(i int, m config.ModelConfig) { + go func(i int, m *config.ModelConfig) { defer wg.Done() configured[i] = isModelConfigured(m) }(i, m) @@ -72,7 +72,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { ModelName: m.ModelName, Model: m.Model, APIBase: m.APIBase, - APIKey: maskAPIKey(m.APIKey), + APIKey: maskAPIKey(m.APIKey()), Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, @@ -122,7 +122,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { return } - cfg.ModelList = append(cfg.ModelList, mc) + cfg.ModelList = append(cfg.ModelList, &mc) if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) @@ -180,11 +180,11 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { // Preserve the existing API key when the caller omits it (empty string). // This lets the UI update api_base / proxy without clearing the stored secret. - if mc.APIKey == "" { - mc.APIKey = cfg.ModelList[idx].APIKey + if mc.APIKey() == "" { + mc.SetAPIKey(cfg.ModelList[idx].APIKey()) } - cfg.ModelList[idx] = mc + cfg.ModelList[idx] = &mc if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) @@ -224,9 +224,6 @@ func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) { if cfg.Agents.Defaults.ModelName == deletedModelName { cfg.Agents.Defaults.ModelName = "" } - if cfg.Agents.Defaults.Model == deletedModelName { - cfg.Agents.Defaults.Model = "" - } if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 1ec5fb8c9..44d10154e 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -59,7 +59,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - cfg.ModelList = []config.ModelConfig{ + cfg.ModelList = []*config.ModelConfig{ { ModelName: "openai-oauth", Model: "openai/gpt-5.4", @@ -78,7 +78,6 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes ModelName: "vllm-remote", Model: "vllm/custom-model", APIBase: "https://models.example.com/v1", - APIKey: "remote-key", }, { ModelName: "copilot-gpt-5.4", @@ -87,6 +86,11 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes AuthMethod: "oauth", }, } + cfg.WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ + "vllm-remote": { + APIKeys: []string{"remote-key"}, + }, + }}) cfg.Agents.Defaults.ModelName = "openai-oauth" if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) @@ -152,7 +156,7 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - cfg.ModelList = []config.ModelConfig{{ + cfg.ModelList = []*config.ModelConfig{{ ModelName: "claude-oauth", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "oauth", @@ -215,7 +219,7 @@ func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - cfg.ModelList = []config.ModelConfig{ + cfg.ModelList = []*config.ModelConfig{ { ModelName: "local-vllm-a", Model: "vllm/custom-a", @@ -274,7 +278,7 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - cfg.ModelList = []config.ModelConfig{{ + cfg.ModelList = []*config.ModelConfig{{ ModelName: "vllm-local", Model: "vllm/custom-model", APIBase: "http://0.0.0.0:8000/v1", diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go index 4edabb9ab..213b53836 100644 --- a/web/backend/api/oauth.go +++ b/web/backend/api/oauth.go @@ -744,17 +744,6 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { return err } - switch provider { - case oauthProviderOpenAI: - cfg.Providers.OpenAI.AuthMethod = authMethod - case oauthProviderAnthropic: - cfg.Providers.Anthropic.AuthMethod = authMethod - case oauthProviderGoogleAntigravity: - cfg.Providers.Antigravity.AuthMethod = authMethod - default: - return fmt.Errorf("unsupported provider %q", provider) - } - found := false for i := range cfg.ModelList { if modelBelongsToProvider(provider, cfg.ModelList[i].Model) { @@ -787,28 +776,28 @@ func modelBelongsToProvider(provider, model string) bool { } } -func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig { +func defaultModelConfigForProvider(provider, authMethod string) *config.ModelConfig { switch provider { case oauthProviderOpenAI: - return config.ModelConfig{ + return &config.ModelConfig{ ModelName: "gpt-5.4", Model: "openai/gpt-5.4", AuthMethod: authMethod, } case oauthProviderAnthropic: - return config.ModelConfig{ + return &config.ModelConfig{ ModelName: "claude-sonnet-4.6", Model: "anthropic/claude-sonnet-4.6", AuthMethod: authMethod, } case oauthProviderGoogleAntigravity: - return config.ModelConfig{ + return &config.ModelConfig{ ModelName: "gemini-flash", Model: "antigravity/gemini-3-flash", AuthMethod: authMethod, } default: - return config.ModelConfig{} + return &config.ModelConfig{} } } diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go index 7d63abbd4..7cab79b52 100644 --- a/web/backend/api/oauth_test.go +++ b/web/backend/api/oauth_test.go @@ -166,8 +166,7 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) { if err != nil { t.Fatalf("LoadConfig error: %v", err) } - cfg.Providers.OpenAI.AuthMethod = "oauth" - cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ ModelName: "gpt-5.4", Model: "openai/gpt-5.4", AuthMethod: "oauth", @@ -208,9 +207,6 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) { if err != nil { t.Fatalf("LoadConfig error: %v", err) } - if updated.Providers.OpenAI.AuthMethod != "" { - t.Fatalf("providers.openai.auth_method = %q, want empty", updated.Providers.OpenAI.AuthMethod) - } for _, m := range updated.ModelList { if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" { t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod) @@ -233,12 +229,18 @@ func setupOAuthTestEnv(t *testing.T) (string, func()) { } cfg := config.DefaultConfig() - cfg.ModelList = []config.ModelConfig{{ + cfg.ModelList = []*config.ModelConfig{{ ModelName: "custom-default", Model: "openai/gpt-4o", - APIKey: "sk-default", }} cfg.Agents.Defaults.ModelName = "custom-default" + cfg.WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "custom-default": { + APIKeys: []string{"sk-default"}, + }, + }, + }) configPath := filepath.Join(tmp, "config.json") if err := config.SaveConfig(configPath, cfg); err != nil { diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index a880f2f0c..8fbb8737f 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -57,7 +57,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "token": cfg.Channels.Pico.Token, + "token": cfg.Channels.Pico.Token(), "ws_url": wsURL, "enabled": cfg.Channels.Pico.Enabled, }) @@ -74,7 +74,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { } token := generateSecureToken() - cfg.Channels.Pico.Token = token + cfg.Channels.Pico.SetToken(token) if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) @@ -110,8 +110,8 @@ func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) { changed = true } - if cfg.Channels.Pico.Token == "" { - cfg.Channels.Pico.Token = generateSecureToken() + if cfg.Channels.Pico.Token() == "" { + cfg.Channels.Pico.SetToken(generateSecureToken()) changed = true } @@ -150,7 +150,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "token": cfg.Channels.Pico.Token, + "token": cfg.Channels.Pico.Token(), "ws_url": wsURL, "enabled": true, "changed": changed, diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index 075da4ddc..263253cb2 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -33,7 +33,7 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) { if !cfg.Channels.Pico.Enabled { t.Error("expected Pico to be enabled after setup") } - if cfg.Channels.Pico.Token == "" { + if cfg.Channels.Pico.Token() == "" { t.Error("expected a non-empty token after setup") } } @@ -121,7 +121,7 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { // Pre-configure with custom user settings cfg := config.DefaultConfig() cfg.Channels.Pico.Enabled = true - cfg.Channels.Pico.Token = "user-custom-token" + cfg.Channels.Pico.SetToken("user-custom-token") cfg.Channels.Pico.AllowTokenQuery = true cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"} if err := config.SaveConfig(configPath, cfg); err != nil { @@ -143,8 +143,8 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - if cfg.Channels.Pico.Token != "user-custom-token" { - t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token, "user-custom-token") + if cfg.Channels.Pico.Token() != "user-custom-token" { + t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token(), "user-custom-token") } if !cfg.Channels.Pico.AllowTokenQuery { t.Error("user's allow_token_query=true must be preserved") @@ -166,7 +166,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { } cfg1, _ := config.LoadConfig(configPath) - token1 := cfg1.Channels.Pico.Token + token1 := cfg1.Channels.Pico.Token() // Second call should be a no-op changed, err := h.ensurePicoChannel(origin) @@ -178,7 +178,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { } cfg2, _ := config.LoadConfig(configPath) - if cfg2.Channels.Pico.Token != token1 { + if cfg2.Channels.Pico.Token() != token1 { t.Error("token should not change on subsequent calls") } }