diff --git a/README.md b/README.md index e25366ef8..72d38103c 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,9 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use | [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment | | [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login | | [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | +| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS | + +> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
Local deployment (Ollama, vLLM, etc.) 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/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 4812f1bee..7fa588c5c 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -34,7 +34,7 @@ func NewGatewayCommand() *cobra.Command { return nil }, RunE: func(_ *cobra.Command, _ []string) error { - return gateway.Run(debug, internal.GetConfigPath(), allowEmpty) + return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty) }, } 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/channels/telegram/README.md b/docs/channels/telegram/README.md index 5b4d6c76a..86c016a5d 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -33,3 +33,23 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co 3. Obtain the HTTP API Token 4. Fill in the Token in the configuration file 5. (Optional) Configure `allow_from` to restrict which user IDs can interact (you can get IDs via `@userinfobot`) + +## Built-in Commands + +Telegram auto-registers PicoClaw's top-level bot commands at startup, including `/start`, `/help`, `/show`, `/list`, and `/use`. + +Skill-related commands: + +- `/list skills` lists the installed skills visible to the current agent. +- `/use ` forces a skill for a single request. +- `/use ` arms the skill for your next message in the same chat. +- `/use clear` clears a pending skill override. + +Examples: + +```text +/list skills +/use git explain how to squash the last 3 commits +/use git +explain how to squash the last 3 commits +``` diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md index 6a7533582..1d9dcc46e 100644 --- a/docs/channels/telegram/README.zh.md +++ b/docs/channels/telegram/README.zh.md @@ -33,3 +33,23 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器 3. 获取 HTTP API Token 4. 将 Token 填入配置文件中 5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID) + +## 内置命令 + +Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/start`、`/help`、`/show`、`/list` 和 `/use`。 + +与技能相关的命令: + +- `/list skills`:列出当前 Agent 可见的已安装技能。 +- `/use `:只在本次请求中强制使用指定技能。 +- `/use `:为同一聊天中的下一条消息预先启用该技能。 +- `/use clear`:清除待应用的技能覆盖。 + +示例: + +```text +/list skills +/use git explain how to squash the last 3 commits +/use italiapersonalfinance +dammi le ultime news +``` diff --git a/docs/chat-apps.md b/docs/chat-apps.md index b0ebc7c54..d300f5544 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -61,11 +61,18 @@ picoclaw gateway **4. Telegram command menu (auto-registered at startup)** -PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync. +PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`) so command menu and runtime behavior stay in sync. Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor. If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. +You can also manage installed skills directly from Telegram: + +- `/list skills` +- `/use ` +- `/use ` and then send the actual request in the next message +- `/use clear` + **4. Advanced Formatting** You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks. 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/docs/configuration.md b/docs/configuration.md index 56c3e2dc7..4e77300cf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,6 +65,24 @@ For advanced/test setups, you can override the builtin skills root with: export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### Using Skills From Chat Channels + +Once skills are installed, you can inspect and force them directly from a chat channel: + +- `/list skills` shows the installed skill names available to the current agent. +- `/use ` forces a specific skill for a single request. +- `/use ` arms that skill for your next message in the same chat session. +- `/use clear` cancels a pending skill override created by `/use `. + +Examples: + +```text +/list skills +/use git explain how to squash the last 3 commits +/use italiapersonalfinance +dammi le ultime news +``` + ### Unified Command Execution Policy - Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. @@ -736,6 +754,7 @@ Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace | Topic | Description | | ----- | ----------- | +| [Sensitive Data Filtering](sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM | | [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks | | [Steering](steering.md) | Inject messages into a running agent loop between tool calls | | [SubTurn](subturn.md) | Subagent coordination, concurrency control, lifecycle | diff --git a/docs/sensitive_data_filtering.md b/docs/sensitive_data_filtering.md new file mode 100644 index 000000000..0c10ff01d --- /dev/null +++ b/docs/sensitive_data_filtering.md @@ -0,0 +1,107 @@ +# Sensitive Data Filtering + +PicoClaw can filter sensitive values (API keys, tokens, secrets, passwords) from tool call results before they are sent to the LLM. This prevents the LLM from seeing its own credentials, which could otherwise leak through tool output or cause confusing behavior. + +--- + +## Overview + +When the LLM uses a tool that returns its own credentials (e.g., a tool that echoes the API key being used), those values are automatically replaced with `[FILTERED]` in the message sent to the LLM. + +Sensitive values are collected from [`.security.yml`](./credential_encryption.md) — the centralized storage for all sensitive configuration (API keys, tokens, secrets stored alongside `config.json`). This includes: + +- Model API keys +- Channel tokens (Telegram, Discord, Slack, Matrix, etc.) +- Web tool API keys (Brave, Tavily, Perplexity, etc.) +- Skills tokens (GitHub, ClawHub) + +--- + +## Configuration + +Sensitive data filtering is configured in the `tools` section of `config.json`: + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `filter_sensitive_data` | bool | `true` | Enable/disable filtering. When `false`, no filtering is performed. | +| `filter_min_length` | int | `8` | Minimum content length to trigger filtering. Short content is skipped for performance. | + +```json +{ + "tools": { + "filter_sensitive_data": true, + "filter_min_length": 8 + } +} +``` + +### Environment Variable + +| Variable | Description | +|----------|-------------| +| `PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA` | Set to `true` or `false` to override the config value | + +--- + +## How It Works + +1. **On startup**: All sensitive values are collected from `.security.yml` using reflection and compiled into a `strings.Replacer` (O(n+m) performance, computed once). + +2. **Per tool result**: Before sending any tool result content to the LLM: + - If `filter_sensitive_data` is `false`, content is passed through unchanged + - If content length < `filter_min_length`, content is passed through unchanged (fast path) + - Otherwise, all sensitive values are replaced with `[FILTERED]` + +3. **Replacement**: Uses `strings.Replacer` for efficient O(n+m) string substitution, where n = content length and m = total sensitive value length. + +--- + +## Example + +Given the following `.security.yml`: + +```yaml +model_list: + my-model: + api_keys: + - sk-secret-key-12345 + +channels: + telegram: + token: "123456:ABC-DEF" +``` + +And a tool result containing: + +``` +The model is using API key sk-secret-key-12345 and Telegram bot 123456:ABC-DEF +``` + +The LLM will receive: + +``` +The model is using API key [FILTERED] and Telegram bot [FILTERED] +``` + +--- + +## Performance + +- **Fast path**: Content shorter than `filter_min_length` (default 8) is returned unchanged without any string scanning +- **Efficient replacement**: Uses `strings.Replacer` with O(n+m) complexity instead of regex +- **Lazy initialization**: The replacement map is built once on first access via `sync.Once` + +--- + +## Security Considerations + +- **Credential exposure prevention**: Without filtering, tools that echo credentials could cause the LLM to see its own API keys, potentially leading to confusion or credential leakage in logs +- **Defense in depth**: Filtering complements (but does not replace) credential encryption — both features should be used together +- **No false positives**: Only values explicitly stored in `.security.yml` are filtered; the LLM's general knowledge is unaffected + +--- + +## Related + +- [Credential Encryption](./credential_encryption.md) — encrypting API keys in config +- [Tools Configuration](./tools_configuration.md) diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index 0528fe714..b5907b991 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -26,6 +26,17 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`. } ``` +## Sensitive Data Filtering + +Before tool results are sent to the LLM, PicoClaw can filter sensitive values (API keys, tokens, secrets) from the output. This prevents the LLM from seeing its own credentials. + +See [Sensitive Data Filtering](../sensitive_data_filtering.md) for full documentation. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `filter_sensitive_data` | bool | `true` | Enable/disable filtering | +| `filter_min_length` | int | `8` | Minimum content length to trigger filtering | + ## Web Tools Web tools are used for web search and fetching. diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md index 026acf404..aeba7d460 100644 --- a/docs/zh/chat-apps.md +++ b/docs/zh/chat-apps.md @@ -64,11 +64,18 @@ picoclaw gateway **4. Telegram 命令菜单(启动时自动注册)** -PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 +PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 +你也可以直接在 Telegram 中管理已安装技能: + +- `/list skills` +- `/use ` +- `/use `,然后在下一条消息里发送真正的请求 +- `/use clear` +
diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 11aa4f176..335566d36 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -65,6 +65,24 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### 在聊天频道中使用技能 + +技能安装完成后,可以直接在聊天频道里查看并显式启用它们: + +- `/list skills`:显示当前 Agent 可用的已安装技能名称。 +- `/use `:只对当前这一条请求强制使用指定技能。 +- `/use `:为同一会话中的下一条消息预先启用该技能。 +- `/use clear`:取消通过 `/use ` 设置的待应用技能。 + +示例: + +```text +/list skills +/use git explain how to squash the last 3 commits +/use italiapersonalfinance +dammi le ultime news +``` + ### 统一命令执行策略 - 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 @@ -605,6 +623,7 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设 | 主题 | 说明 | | ---- | ---- | +| [敏感数据过滤](../sensitive_data_filtering.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 | | [Hook 系统](../hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | | [Steering](../steering.md) | 在工具调用间向运行中的 Agent 注入消息 | | [SubTurn](../subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | diff --git a/docs/zh/sensitive_data_filtering.md b/docs/zh/sensitive_data_filtering.md new file mode 100644 index 000000000..4382706ed --- /dev/null +++ b/docs/zh/sensitive_data_filtering.md @@ -0,0 +1,107 @@ +# 敏感数据过滤 + +PicoClaw 可以从工具调用结果中过滤敏感值(API 密钥、令牌、密码等),然后再发送给 LLM。这可以防止 LLM 看到自己的凭据,避免通过工具输出泄露或产生混淆行为。 + +--- + +## 概述 + +当 LLM 使用的工具返回其自身的凭据时(例如,一个回显正在使用的 API 密钥的工具),这些值会自动替换为 `[FILTERED]` 再发送给 LLM。 + +敏感值从 `.security.yml` 中收集 —— 这是所有敏感配置的集中存储,包括: + +- 模型 API 密钥 +- 频道令牌(Telegram、Discord、Slack、Matrix 等) +- Web 工具 API 密钥(Brave、Tavily、Perplexity 等) +- 技能令牌(GitHub、ClawHub) + +--- + +## 配置 + +敏感数据过滤在 `config.json` 的 `tools` 部分配置: + +| 配置 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `filter_sensitive_data` | bool | `true` | 启用/禁用过滤。为 `false` 时,不进行任何过滤。 | +| `filter_min_length` | int | `8` | 触发过滤的最小内容长度。短内容会被跳过以提高性能。 | + +```json +{ + "tools": { + "filter_sensitive_data": true, + "filter_min_length": 8 + } +} +``` + +### 环境变量 + +| 变量 | 说明 | +|------|------| +| `PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA` | 设置为 `true` 或 `false` 以覆盖配置值 | + +--- + +## 工作原理 + +1. **启动时**:使用反射从 `.security.yml` 中收集所有敏感值,并编译成 `strings.Replacer`(O(n+m) 性能,仅计算一次)。 + +2. **每个工具结果**:在将任何工具结果发送给 LLM 之前: + - 如果 `filter_sensitive_data` 为 `false`,内容原样传递 + - 如果内容长度 < `filter_min_length`,内容原样传递(快速路径) + - 否则,所有敏感值都会被替换为 `[FILTERED]` + +3. **替换**:使用 `strings.Replacer` 进行高效的 O(n+m) 字符串替换,其中 n = 内容长度,m = 敏感值总长度。 + +--- + +## 示例 + +给定以下 `.security.yml`: + +```yaml +model_list: + my-model: + api_keys: + - sk-secret-key-12345 + +channels: + telegram: + token: "123456:ABC-DEF" +``` + +以及包含以下内容的工具结果: + +``` +The model is using API key sk-secret-key-12345 and Telegram bot 123456:ABC-DEF +``` + +LLM 将收到: + +``` +The model is using API key [FILTERED] and Telegram bot [FILTERED] +``` + +--- + +## 性能 + +- **快速路径**:短于 `filter_min_length`(默认 8)的内容会直接返回,不进行任何字符串扫描 +- **高效替换**:使用 `strings.Replacer`,复杂度为 O(n+m),而非正则表达式 +- **延迟初始化**:替换映射通过 `sync.Once` 在首次访问时构建一次 + +--- + +## 安全注意事项 + +- **凭据泄露防护**:如果没有过滤,返回凭据的工具可能导致 LLM 看到自己的 API 密钥,可能导致日志中泄露凭据或产生混淆 +- **纵深防御**:过滤是对凭据加密的补充(而非替代)—— 应同时使用这两个功能 +- **无误报**:只有明确存储在 `.security.yml` 中的值才会被过滤;LLM 的通用知识不受影响 + +--- + +## 相关文档 + +- [凭据加密](../credential_encryption.md) — 配置中 API 密钥的加密 +- [工具配置](../tools_configuration.md) diff --git a/docs/zh/tools_configuration.md b/docs/zh/tools_configuration.md index a3816a35a..63ac5000b 100644 --- a/docs/zh/tools_configuration.md +++ b/docs/zh/tools_configuration.md @@ -28,6 +28,17 @@ PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 } ``` +## 敏感数据过滤 + +在将工具结果发送给 LLM 之前,PicoClaw 可以从输出中过滤敏感值(API 密钥、令牌、密码)。这可以防止 LLM 看到自己的凭据。 + +详细说明请参阅[敏感数据过滤](../sensitive_data_filtering.md)。 + +| 配置项 | 类型 | 默认值 | 描述 | +|--------|------|--------|------| +| `filter_sensitive_data` | bool | `true` | 启用/禁用过滤 | +| `filter_min_length` | int | `8` | 触发过滤的最小内容长度 | + ## Web 工具 Web 工具用于网页搜索和抓取。 diff --git a/go.mod b/go.mod index cfc930d37..bce41d0d3 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ 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/aws/aws-sdk-go-v2 v1.41.4 + github.com/aws/aws-sdk-go-v2/config v1.32.12 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 github.com/ergochat/irc-go v0.6.0 @@ -40,6 +43,19 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/beeper/argo-go v1.1.2 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -96,5 +112,5 @@ require ( golang.org/x/crypto v0.49.0 golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.42.0 ) diff --git a/go.sum b/go.sum index f24b997d4..87117bc98 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,38 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= +github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 h1:x0eGAWpd1B5I/vMtrB4Q4Zuc3CXWI8wjHfPPqBSrKmM= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2/go.mod h1:V9oTWSDC2MtS1DR71hbNET/bZ8psQp022amEBe1grJc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= diff --git a/pkg/agent/context.go b/pkg/agent/context.go index cc818630f..bde34561a 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 { @@ -510,6 +511,7 @@ func (cb *ContextBuilder) BuildMessages( currentMessage string, media []string, channel, chatID, senderID, senderDisplayName string, + activeSkills ...string, ) []providers.Message { messages := []providers.Message{} @@ -543,6 +545,11 @@ func (cb *ContextBuilder) BuildMessages( {Type: "text", Text: dynamicCtx}, } + if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { + stringParts = append(stringParts, skillsText) + contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText}) + } + if summary != "" { summaryText := fmt.Sprintf( "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ @@ -673,8 +680,21 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message // like DeepSeek that enforce: "An assistant message with 'tool_calls' must // be followed by tool messages responding to each 'tool_call_id'." final := make([]providers.Message, 0, len(sanitized)) + seenToolCallID := make(map[string]bool) for i := 0; i < len(sanitized); i++ { msg := sanitized[i] + + // Deduplicate tool results by ToolCallID + if msg.Role == "tool" && msg.ToolCallID != "" { + if seenToolCallID[msg.ToolCallID] { + logger.DebugCF("agent", "Dropping duplicate tool result", map[string]any{ + "tool_call_id": msg.ToolCallID, + }) + continue + } + seenToolCallID[msg.ToolCallID] = true + } + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { // Collect expected tool_call IDs expected := make(map[string]bool, len(msg.ToolCalls)) @@ -750,6 +770,68 @@ func (cb *ContextBuilder) AddAssistantMessage( return messages } +func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string { + if cb.skillsLoader == nil || len(skillNames) == 0 { + return "" + } + + var ordered []string + seen := make(map[string]struct{}, len(skillNames)) + for _, name := range skillNames { + canonical, ok := cb.ResolveSkillName(name) + if !ok { + continue + } + if _, exists := seen[canonical]; exists { + continue + } + seen[canonical] = struct{}{} + ordered = append(ordered, canonical) + } + if len(ordered) == 0 { + return "" + } + + content := cb.skillsLoader.LoadSkillsForContext(ordered) + if strings.TrimSpace(content) == "" { + return "" + } + + return fmt.Sprintf(`# Active Skills + +The following skills are active for this request. Follow them when relevant. + +%s`, content) +} + +func (cb *ContextBuilder) ListSkillNames() []string { + if cb.skillsLoader == nil { + return nil + } + + allSkills := cb.skillsLoader.ListSkills() + names := make([]string, 0, len(allSkills)) + for _, skill := range allSkills { + names = append(names, skill.Name) + } + return names +} + +func (cb *ContextBuilder) ResolveSkillName(name string) (string, bool) { + name = strings.TrimSpace(name) + if name == "" || cb.skillsLoader == nil { + return "", false + } + + for _, skill := range cb.skillsLoader.ListSkills() { + if strings.EqualFold(skill.Name, name) { + return skill.Name, true + } + } + + return "", false +} + // GetSkillsInfo returns information about loaded skills. func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 5756ed911..0d7948eef 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -188,6 +188,31 @@ func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { assertRoles(t, result, "user", "assistant", "user", "assistant") } +func TestSanitizeHistoryForProvider_DuplicateToolResults(t *testing.T) { + history := []providers.Message{ + msg("user", "do something"), + assistantWithTools("A", "B"), + toolResult("A"), + toolResult("B"), + toolResult("A"), // duplicate + toolResult("B"), // duplicate + msg("assistant", "done"), + } + + result := sanitizeHistoryForProvider(history) + if len(result) != 5 { + t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant") + // Verify the kept tool results have the correct IDs + if result[2].ToolCallID != "A" { + t.Errorf("expected tool result A, got %q", result[2].ToolCallID) + } + if result[3].ToolCallID != "B" { + t.Errorf("expected tool result B, got %q", result[3].ToolCallID) + } +} + func roles(msgs []providers.Message) []string { r := make([]string, len(msgs)) for i, m := range msgs { 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 522997f6c..37c04acf9 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -56,6 +56,7 @@ type AgentLoop struct { mcp mcpRuntime hookRuntime hookRuntime steering *steeringQueue + pendingSkills sync.Map mu sync.RWMutex // Concurrent turn management (from HEAD) @@ -77,6 +78,7 @@ type processOptions struct { SenderID string // Current sender ID for dynamic context SenderDisplayName string // Current sender display name for dynamic context UserMessage string // User message content (may include prefix) + ForcedSkills []string // Skills explicitly requested for this message SystemPromptOverride string // Override the default system prompt (Used by SubTurns) Media []string // media:// refs from inbound message InitialSteeringMessages []providers.Message // Steering messages from refactor/agent @@ -161,30 +163,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 +255,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 { @@ -1334,6 +1350,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return response, nil } + if pending := al.takePendingSkills(opts.SessionKey); len(pending) > 0 { + opts.ForcedSkills = append(opts.ForcedSkills, pending...) + logger.InfoCF("agent", "Applying pending skill override", + map[string]any{ + "session_key": opts.SessionKey, + "skills": strings.Join(pending, ","), + }) + } + return al.runAgentLoop(ctx, agent, opts) } @@ -1627,6 +1652,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName, + activeSkillNames(ts.agent, ts.opts)..., ) cfg := al.GetConfig() @@ -1656,6 +1682,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er newHistory, newSummary, ts.userMessage, ts.media, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName, + activeSkillNames(ts.agent, ts.opts)..., ) messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) } @@ -1726,7 +1753,8 @@ turnLoop: select { case result, ok := <-ts.pendingResults: if ok && result != nil && result.ForLLM != "" { - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", result.ForLLM)} + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} pendingMessages = append(pendingMessages, msg) } default: @@ -2020,8 +2048,8 @@ turnLoop: newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey) messages = ts.agent.ContextBuilder.BuildMessages( newHistory, newSummary, "", - nil, ts.channel, ts.chatID, - "", "", // Empty SenderID and SenderDisplayName for retry + nil, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName, + activeSkillNames(ts.agent, ts.opts)..., ) callMessages = messages if gracefulTerminal { @@ -2084,9 +2112,13 @@ turnLoop: } } + reasoningContent := response.Reasoning + if reasoningContent == "" { + reasoningContent = response.ReasoningContent + } go al.handleReasoning( turnCtx, - response.Reasoning, + reasoningContent, ts.channel, al.targetReasoningChannelID(ts.channel), ) @@ -2329,6 +2361,9 @@ turnLoop: return } + // Filter sensitive data before publishing + content = al.cfg.FilterSensitiveData(content) + logger.InfoCF("agent", "Async tool completed, publishing result", map[string]any{ "tool": asyncToolName, @@ -2444,6 +2479,11 @@ turnLoop: contentForLLM = toolResult.Err.Error() } + // Filter sensitive data (API keys, tokens, secrets) before sending to LLM + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + toolResultMsg := providers.Message{ Role: "tool", Content: contentForLLM, @@ -2521,7 +2561,8 @@ turnLoop: select { case result, ok := <-ts.pendingResults: if ok && result != nil && result.ForLLM != "" { - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", result.ForLLM)} + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} messages = append(messages, msg) ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) } @@ -3102,6 +3143,10 @@ func (al *AgentLoop) handleCommand( return "", false } + if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched { + return reply, handled + } + if al.cmdRegistry == nil { return "", false } @@ -3165,6 +3210,9 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt return nil }, } + if agent != nil && agent.ContextBuilder != nil { + rt.ListSkillNames = agent.ContextBuilder.ListSkillNames + } rt.ReloadConfig = func() error { if al.reloadFunc == nil { return fmt.Errorf("reload not configured") @@ -3224,6 +3272,146 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt return rt } +func activeSkillNames(agent *AgentInstance, opts processOptions) []string { + var out []string + seen := make(map[string]struct{}) + + appendNames := func(names []string) { + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + } + + if agent != nil { + appendNames(agent.SkillsFilter) + } + appendNames(opts.ForcedSkills) + + return out +} + +func (al *AgentLoop) applyExplicitSkillCommand( + raw string, + agent *AgentInstance, + opts *processOptions, +) (matched bool, handled bool, reply string) { + commandName, ok := commands.CommandName(raw) + if !ok || commandName != "use" { + return false, false, "" + } + + if agent == nil || agent.ContextBuilder == nil { + return true, true, commandsUnavailableSkillMessage() + } + + fields := strings.Fields(strings.TrimSpace(raw)) + if len(fields) < 2 { + return true, true, buildUseCommandHelp(agent) + } + + if strings.EqualFold(fields[1], "clear") || strings.EqualFold(fields[1], "off") { + al.clearPendingSkills(opts.SessionKey) + return true, true, "Cleared pending skill override." + } + + canonicalSkill, ok := agent.ContextBuilder.ResolveSkillName(fields[1]) + if !ok { + return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", fields[1]) + } + + if len(fields) == 2 { + al.setPendingSkills(opts.SessionKey, []string{canonicalSkill}) + return true, true, fmt.Sprintf( + "Skill %q is armed for your next message.\nSend your next request normally, or use /use clear to cancel.", + canonicalSkill, + ) + } + + message := strings.TrimSpace(strings.Join(fields[2:], " ")) + if message == "" { + return true, true, buildUseCommandHelp(agent) + } + + opts.UserMessage = message + opts.ForcedSkills = append(opts.ForcedSkills, canonicalSkill) + return true, false, "" +} + +func commandsUnavailableSkillMessage() string { + return "Skill selection is unavailable in the current context." +} + +func buildUseCommandHelp(agent *AgentInstance) string { + if agent == nil || agent.ContextBuilder == nil { + return "Usage: /use [message]" + } + + names := agent.ContextBuilder.ListSkillNames() + if len(names) == 0 { + return "Usage: /use [message]\nNo installed skills found." + } + + return fmt.Sprintf( + "Usage: /use [message]\n\nInstalled Skills:\n- %s\n\nUse /use to apply a skill to your next message, or /use to force it immediately.", + strings.Join(names, "\n- "), + ) +} + +func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" || len(skillNames) == 0 { + return + } + + filtered := make([]string, 0, len(skillNames)) + for _, name := range skillNames { + name = strings.TrimSpace(name) + if name != "" { + filtered = append(filtered, name) + } + } + if len(filtered) == 0 { + return + } + + al.pendingSkills.Store(sessionKey, filtered) +} + +func (al *AgentLoop) takePendingSkills(sessionKey string) []string { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return nil + } + + value, ok := al.pendingSkills.LoadAndDelete(sessionKey) + if !ok { + return nil + } + + skills, ok := value.([]string) + if !ok { + return nil + } + + return append([]string(nil), skills...) +} + +func (al *AgentLoop) clearPendingSkills(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.pendingSkills.Delete(sessionKey) +} + func mapCommandError(result commands.ExecuteResult) string { if result.Command == "" { return fmt.Sprintf("Failed to execute command: %v", result.Err) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 71f2d15e4..976d25c4b 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, }, @@ -132,6 +132,163 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { } } +func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { + tmpDir := t.TempDir() + skillDir := filepath.Join(tmpDir, "skills", "shell") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("mkdir skill dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("# shell\n\nPrefer concise shell commands and explain them briefly."), + 0o644, + ); err != nil { + t.Fatalf("write skill file: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/use shell explain how to list files", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "# Active Skills") { + t.Fatalf("system prompt missing active skills section:\n%s", systemPrompt) + } + if !strings.Contains(systemPrompt, "### Skill: shell") { + t.Fatalf("system prompt missing requested skill content:\n%s", systemPrompt) + } + + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" { + t.Fatalf("last provider message = %+v, want rewritten user message", lastMessage) + } +} + +func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + agent := al.GetRegistry().GetDefaultAgent() + + opts := processOptions{} + reply, handled := al.handleCommand(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/use missing explain how to list files", + }, agent, &opts) + if !handled { + t.Fatal("expected /use with unknown skill to be handled") + } + if !strings.Contains(reply, "Unknown skill: missing") { + t.Fatalf("reply = %q, want unknown skill error", reply) + } +} + +func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { + tmpDir := t.TempDir() + skillDir := filepath.Join(tmpDir, "skills", "shell") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("mkdir skill dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("# shell\n\nPrefer concise shell commands and explain them briefly."), + 0o644, + ); err != nil { + t.Fatalf("write skill file: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/use shell", + }) + if err != nil { + t.Fatalf("processMessage() arm error = %v", err) + } + if !strings.Contains(response, `Skill "shell" is armed for your next message.`) { + t.Fatalf("arm response = %q, want armed confirmation", response) + } + + response, err = al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "explain how to list files", + }) + if err != nil { + t.Fatalf("processMessage() follow-up error = %v", err) + } + if response != "Mock response" { + t.Fatalf("follow-up response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "### Skill: shell") { + t.Fatalf("system prompt missing pending skill content:\n%s", systemPrompt) + } + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" { + t.Fatalf("last provider message = %+v, want unchanged follow-up user message", lastMessage) + } +} + func TestRecordLastChannel(t *testing.T) { al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) defer cleanup() @@ -179,7 +336,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 +372,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 +429,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 +465,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 +509,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, }, @@ -397,6 +554,29 @@ func (m *simpleMockProvider) GetDefaultModel() string { return "mock-model" } +type reasoningContentProvider struct { + response string + reasoningContent string +} + +func (m *reasoningContentProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: m.response, + ReasoningContent: m.reasoningContent, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *reasoningContentProvider) GetDefaultModel() string { + return "reasoning-content-model" +} + type countingMockProvider struct { response string calls int @@ -558,7 +738,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 +794,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 +874,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 +953,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 +1034,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 +1148,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 +1190,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 +1261,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 +1341,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 +1372,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 +1429,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 +1481,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 +1551,7 @@ func TestHandleReasoning(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -1509,6 +1711,62 @@ func TestHandleReasoning(t *testing.T) { }) } +func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &reasoningContentProvider{ + response: "final answer", + reasoningContent: "thinking trace", + } + al := NewAgentLoop(cfg, msgBus, provider) + + chManager, err := channels.NewManager(&config.Config{}, msgBus, nil) + if err != nil { + t.Fatalf("Failed to create channel manager: %v", err) + } + chManager.RegisterChannel("telegram", &fakeChannel{id: "reason-chat"}) + al.SetChannelManager(chManager) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "final answer" { + t.Fatalf("processMessage() response = %q, want %q", response, "final answer") + } + + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.Channel != "telegram" { + t.Fatalf("reasoning channel = %q, want %q", outbound.Channel, "telegram") + } + if outbound.ChatID != "reason-chat" { + t.Fatalf("reasoning chatID = %q, want %q", outbound.ChatID, "reason-chat") + } + if outbound.Content != "thinking trace" { + t.Fatalf("reasoning content = %q, want %q", outbound.Content, "thinking trace") + } + case <-time.After(2 * time.Second): + t.Fatal("expected reasoning content to be published to reasoning channel") + } +} + func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() 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..4ea71f6df 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) @@ -357,6 +357,7 @@ type qqMediaUpload struct { FileType uint64 `json:"file_type"` URL string `json:"url,omitempty"` FileData string `json:"file_data,omitempty"` + FileName string `json:"file_name,omitempty"` SrvSendMsg bool `json:"srv_send_msg,omitempty"` } @@ -393,6 +394,7 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) if isHTTPURL(mediaRef) { payload.FileType = qqFileType(c.outboundMediaType(part, "")) payload.URL = mediaRef + payload.FileName = qqUploadFilename(part, mediaRef, payload.FileType) return payload, nil } @@ -415,9 +417,11 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) if isHTTPURL(resolved) { payload.FileType = qqFileType(c.outboundMediaType(part, "")) payload.URL = resolved + payload.FileName = qqUploadFilename(part, resolved, payload.FileType) return payload, nil } payload.FileType = qqFileType(c.outboundMediaType(part, resolved)) + payload.FileName = qqUploadFilename(part, resolved, payload.FileType) if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 { info, statErr := os.Stat(resolved) @@ -444,6 +448,28 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) return payload, nil } +func qqUploadFilename(part bus.MediaPart, resolved string, fileType uint64) string { + if fileType != qqFileType("file") { + return "" + } + if part.Filename != "" { + return part.Filename + } + if isHTTPURL(resolved) { + if parsed, err := url.Parse(resolved); err == nil { + if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" { + return base + } + } + return "" + } + + if base := filepath.Base(resolved); base != "" && base != "." { + return base + } + return "" +} + func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string { if part.Type != "audio" { return part.Type diff --git a/pkg/channels/qq/qq_test.go b/pkg/channels/qq/qq_test.go index 108965c00..7ed736827 100644 --- a/pkg/channels/qq/qq_test.go +++ b/pkg/channels/qq/qq_test.go @@ -444,6 +444,9 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { if upload.body.FileType != 4 { t.Fatalf("upload file_type = %d, want 4", upload.body.FileType) } + if upload.body.FileName != "report.pdf" { + t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName) + } if len(api.c2cMessages) != 1 { t.Fatalf("c2cMessages = %d, want 1", len(api.c2cMessages)) @@ -460,6 +463,59 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { } } +func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf")) + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.pdf", + ContentType: "application/pdf", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("local-file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("user-1", "direct") + + err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "user-1", + Parts: []bus.MediaPart{{ + Type: "file", + Ref: ref, + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + upload := api.transportCalls[0] + if upload.body.FileType != 4 { + t.Fatalf("upload file_type = %d, want 4", upload.body.FileType) + } + if upload.body.FileName != "report.pdf" { + t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName) + } + if upload.body.FileData == "" { + t.Fatal("upload file_data = empty, want base64 payload") + } +} + func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) { messageBus := bus.NewMessageBus() ch := &QQChannel{ 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..d0011d21b 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) } @@ -481,13 +481,26 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe _, err = c.bot.SendDocument(ctx, docParams) } case "audio": - params := &telego.SendAudioParams{ - ChatID: tu.ID(chatID), - MessageThreadID: threadID, - Audio: telego.InputFile{File: file}, - Caption: part.Caption, + // Send OGG files with "voice" in the filename as Telegram voice + // bubbles (SendVoice) instead of audio attachments (SendAudio). + fn := strings.ToLower(part.Filename) + if strings.Contains(fn, "voice") && (strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".oga")) { + vparams := &telego.SendVoiceParams{ + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Voice: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendVoice(ctx, vparams) + } else { + params := &telego.SendAudioParams{ + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Audio: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendAudio(ctx, params) } - _, err = c.bot.SendAudio(ctx, params) case "video": params := &telego.SendVideoParams{ ChatID: tu.ID(chatID), 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/commands/builtin.go b/pkg/commands/builtin.go index 7bd36b653..39e76f752 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -10,6 +10,7 @@ func BuiltinDefinitions() []Definition { helpCommand(), showCommand(), listCommand(), + useCommand(), switchCommand(), checkCommand(), clearCommand(), diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index 66a84825e..5fd8dd9bc 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -39,9 +39,14 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { if !strings.Contains(reply, "/show [model|channel|agents]") { t.Fatalf("/help reply missing /show usage, got %q", reply) } - if !strings.Contains(reply, "/list [models|channels|agents]") { + if !strings.Contains(reply, "/list [models|channels|agents|skills]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } + if !strings.Contains(reply, "/use ") { + if !strings.Contains(reply, "/use [message]") { + t.Fatalf("/help reply missing /use usage, got %q", reply) + } + } } func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { @@ -143,3 +148,43 @@ func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) { t.Fatalf("/list agents reply=%q, want agent IDs", reply) } } + +func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) { + rt := &Runtime{ + ListSkillNames: func() []string { + return []string{"shell", "git"} + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/list skills", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/list skills: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "shell") || !strings.Contains(reply, "git") { + t.Fatalf("/list skills reply=%q, want installed skill names", reply) + } +} + +func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) { + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{ + Text: "/use shell run ls", + }) + if res.Outcome != OutcomePassthrough { + t.Fatalf("/use outcome=%v, want=%v", res.Outcome, OutcomePassthrough) + } + if res.Command != "use" { + t.Fatalf("/use command=%q, want=%q", res.Command, "use") + } +} diff --git a/pkg/commands/cmd_list.go b/pkg/commands/cmd_list.go index bf47b6e9c..7186a6c25 100644 --- a/pkg/commands/cmd_list.go +++ b/pkg/commands/cmd_list.go @@ -47,6 +47,23 @@ func listCommand() Definition { Description: "Registered agents", Handler: agentsHandler(), }, + { + Name: "skills", + Description: "Installed skills", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListSkillNames == nil { + return req.Reply(unavailableMsg) + } + names := rt.ListSkillNames() + if len(names) == 0 { + return req.Reply("No installed skills") + } + return req.Reply(fmt.Sprintf( + "Installed Skills:\n- %s\n\nUse /use to force one for a single request, or /use to apply it to your next message.", + strings.Join(names, "\n- "), + )) + }, + }, }, } } diff --git a/pkg/commands/cmd_use.go b/pkg/commands/cmd_use.go new file mode 100644 index 000000000..4698f5f5e --- /dev/null +++ b/pkg/commands/cmd_use.go @@ -0,0 +1,9 @@ +package commands + +func useCommand() Definition { + return Definition{ + Name: "use", + Description: "Force a specific installed skill for one request", + Usage: "/use [message]", + } +} diff --git a/pkg/commands/request.go b/pkg/commands/request.go index 62ee600f2..233b3ef9c 100644 --- a/pkg/commands/request.go +++ b/pkg/commands/request.go @@ -41,6 +41,11 @@ func parseCommandName(input string) (string, bool) { return name, true } +// CommandName returns the normalized command name for an input if present. +func CommandName(input string) (string, bool) { + return parseCommandName(input) +} + func trimCommandPrefix(token string) (string, bool) { for _, prefix := range commandPrefixes { if strings.HasPrefix(token, prefix) { diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index f714e1ca4..5ba6a1bd2 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -10,6 +10,7 @@ type Runtime struct { GetModelInfo func() (name, provider string) ListAgentIDs func() []string ListDefinitions func() []Definition + ListSkillNames func() []string GetEnabledChannels func() []string GetActiveTurn func() any // Returning any to avoid circular dependency with agent package SwitchModel func(value string) (oldModel string, err error) diff --git a/pkg/commands/show_list_handlers_test.go b/pkg/commands/show_list_handlers_test.go index 047708f0f..28d481b67 100644 --- a/pkg/commands/show_list_handlers_test.go +++ b/pkg/commands/show_list_handlers_test.go @@ -61,6 +61,9 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) { GetEnabledChannels: func() []string { return []string{"telegram"} }, + ListSkillNames: func() []string { + return []string{"shell"} + }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) @@ -82,4 +85,20 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) { if !strings.Contains(reply, "telegram") { t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply) } + + reply = "" + res = ex.Execute(context.Background(), Request{ + Channel: "whatsapp", + Text: "/list skills", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("whatsapp /list skills outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "shell") { + t.Fatalf("whatsapp /list skills reply=%q, expected installed skills content", reply) + } } 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 7db70d5d7..cabf302d6 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. @@ -94,13 +96,17 @@ type TeamToolsConfig struct { AllowedModels []TeamModelConfig `json:"allowed_models"` } +// 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"` @@ -109,6 +115,40 @@ 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 +} + +// FilterSensitiveData filters sensitive values from content before sending to LLM. +// This prevents the LLM from seeing its own credentials. +// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig). +// Short content (below FilterMinLength) is returned unchanged for performance. +func (c *Config) FilterSensitiveData(content string) string { + if c.security == nil || content == "" { + return content + } + // Check if filtering is enabled (default: true) + if !c.Tools.IsFilterSensitiveDataEnabled() { + return content + } + // Fast path: skip filtering for short content + if len(content) < c.Tools.GetFilterMinLength() { + return content + } + return c.security.SensitiveDataReplacer().Replace(content) } type HooksConfig struct { @@ -151,19 +191,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 @@ -288,7 +322,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"` @@ -333,10 +366,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 { @@ -393,8 +423,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"` @@ -404,25 +434,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"` @@ -430,6 +506,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 { @@ -441,42 +529,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"` @@ -484,12 +619,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"` @@ -498,12 +645,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"` @@ -511,12 +681,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"` @@ -525,15 +707,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"` @@ -541,14 +746,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"` @@ -556,21 +795,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"` @@ -579,6 +861,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 { @@ -592,22 +886,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 { @@ -621,90 +946,9 @@ type DevicesConfig struct { } type VoiceConfig struct { - ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"` - 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"` + ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"` + EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` + ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"` } // ModelConfig represents a model-centric provider configuration. @@ -721,8 +965,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 @@ -732,10 +974,24 @@ type ModelConfig struct { 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 + RPM int `json:"rpm,omitempty"` // Requests per minute limit + MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") + RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive + ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body + + // from security + secModelName string + apiKeys []string + secDirty bool +} + +// APIKey returns the first API key from apiKeys +func (c *ModelConfig) APIKey() string { + if len(c.apiKeys) > 0 { + return c.apiKeys[0] + } + return "" } // Validate checks if the ModelConfig has all required fields. @@ -749,6 +1005,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"` @@ -771,18 +1036,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 { @@ -791,10 +1106,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 { @@ -804,20 +1144,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 { @@ -878,8 +1241,16 @@ type ReadFileToolConfig struct { } type ToolsConfig struct { - AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` - AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` + AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` + AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` + // FilterSensitiveData controls whether to filter sensitive values (API keys, + // tokens, secrets) from tool results before sending to the LLM. + // Default: true (enabled) + FilterSensitiveData bool `json:"filter_sensitive_data" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` + // FilterMinLength is the minimum content length required for filtering. + // Content shorter than this will be returned unchanged for performance. + // Default: 8 + FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` @@ -905,6 +1276,19 @@ type ToolsConfig struct { WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } +// IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled +func (c *ToolsConfig) IsFilterSensitiveDataEnabled() bool { + return c.FilterSensitiveData +} + +// GetFilterMinLength returns the minimum content length for filtering (default: 8) +func (c *ToolsConfig) GetFilterMinLength() int { + if c.FilterMinLength <= 0 { + return 8 + } + return c.FilterMinLength +} + type SearchCacheConfig struct { MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"` TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` @@ -915,14 +1299,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"` @@ -931,6 +1328,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 @@ -964,43 +1372,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 + } } } } @@ -1013,56 +1454,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.Weixin.token = sec.Channels.Weixin.Token + } + + // Handle DingTalk client secret + if sec.Channels.DingTalk != nil && sec.Channels.DingTalk.ClientSecret != "" { + cfg.Channels.DingTalk.clientSecret = sec.Channels.DingTalk.ClientSecret + } + + // Handle Slack tokens + if sec.Channels.Slack != nil { + if sec.Channels.Slack.BotToken != "" { + cfg.Channels.Slack.botToken = sec.Channels.Slack.BotToken + } + if sec.Channels.Slack.AppToken != "" { + cfg.Channels.Slack.appToken = sec.Channels.Slack.AppToken + } + } + + // Handle Matrix access token + if sec.Channels.Matrix != nil && sec.Channels.Matrix.AccessToken != "" { + cfg.Channels.Matrix.accessToken = sec.Channels.Matrix.AccessToken + } + + // Handle LINE credentials + if sec.Channels.LINE != nil { + if sec.Channels.LINE.ChannelSecret != "" { + cfg.Channels.LINE.channelSecret = sec.Channels.LINE.ChannelSecret + } + if sec.Channels.LINE.ChannelAccessToken != "" { + cfg.Channels.LINE.channelAccessToken = sec.Channels.LINE.ChannelAccessToken + } + } + + // Handle OneBot access token + if sec.Channels.OneBot != nil && sec.Channels.OneBot.AccessToken != "" { + cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken + } + + // Handle WeCom token and encoding key + if sec.Channels.WeCom != nil { + if sec.Channels.WeCom.Token != "" { + cfg.Channels.WeCom.token = sec.Channels.WeCom.Token + } + if sec.Channels.WeCom.EncodingAESKey != "" { + cfg.Channels.WeCom.encodingAESKey = sec.Channels.WeCom.EncodingAESKey + } + } + + // Handle WeCom App credentials + if sec.Channels.WeComApp != nil { + if sec.Channels.WeComApp.CorpSecret != "" { + cfg.Channels.WeComApp.corpSecret = sec.Channels.WeComApp.CorpSecret + } + if sec.Channels.WeComApp.Token != "" { + cfg.Channels.WeComApp.token = sec.Channels.WeComApp.Token + } + if sec.Channels.WeComApp.EncodingAESKey != "" { + cfg.Channels.WeComApp.encodingAESKey = sec.Channels.WeComApp.EncodingAESKey + } + } + + // Handle WeCom AI Bot credentials + if sec.Channels.WeComAIBot != nil { + if sec.Channels.WeComAIBot.Token != "" { + cfg.Channels.WeComAIBot.token = sec.Channels.WeComAIBot.Token + } + if sec.Channels.WeComAIBot.EncodingAESKey != "" { + cfg.Channels.WeComAIBot.encodingAESKey = sec.Channels.WeComAIBot.EncodingAESKey + } + if sec.Channels.WeComAIBot.Secret != "" { + cfg.Channels.WeComAIBot.secret = sec.Channels.WeComAIBot.Secret + } + } + + // Handle Pico channel token + if sec.Channels.Pico != nil && sec.Channels.Pico.Token != "" { + cfg.Channels.Pico.token = sec.Channels.Pico.Token + } + + // Handle IRC passwords + if sec.Channels.IRC != nil { + if sec.Channels.IRC.Password != "" { + cfg.Channels.IRC.password = sec.Channels.IRC.Password + } + if sec.Channels.IRC.NickServPassword != "" { + cfg.Channels.IRC.nickServPassword = sec.Channels.IRC.NickServPassword + } + if sec.Channels.IRC.SASLPassword != "" { + cfg.Channels.IRC.saslPassword = sec.Channels.IRC.SASLPassword + } + } + + // Handle QQ app secret + if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" { + cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret + } + + cfg.security = sec + + return nil +} + +func toNameIndex(list []*ModelConfig) []string { + nameList := make([]string, 0, len(list)) + countMap := make(map[string]int) + for _, model := range list { + name := model.ModelName + index := countMap[name] + nameList = append(nameList, fmt.Sprintf("%s:%d", name, index)) + countMap[name]++ + } + return nameList +} + // encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values // encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or // empty). Returns (nil, error) if any key fails to encrypt — callers must treat // this as a hard failure to prevent a mixed plaintext/ciphertext state on disk. // Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig // and leave JSON marshaling to the caller. -func encryptPlaintextAPIKeys(models []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 @@ -1072,19 +1721,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( @@ -1095,7 +1736,7 @@ func resolveAPIKeys(models []ModelConfig, configDir string) error { err, ) } - models[i].APIKeys[j] = resolved + models[i].apiKeys[j] = resolved } } return nil @@ -1115,17 +1756,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 { @@ -1138,53 +1949,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 @@ -1208,17 +1972,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]) @@ -1227,11 +1991,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. @@ -1244,6 +2003,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 @@ -1267,28 +2030,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 } @@ -1303,11 +2130,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, @@ -1316,17 +2143,17 @@ func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, + ExtraBody: m.ExtraBody, } expanded = append(expanded, additionalEntry) fallbackNames = append(fallbackNames, expandedName) } // Create the primary entry with first key and fallbacks - primaryEntry := ModelConfig{ + primaryEntry := &ModelConfig{ ModelName: originalName, Model: m.Model, APIBase: m.APIBase, - APIKey: keys[0], Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, @@ -1335,6 +2162,8 @@ func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, + ExtraBody: m.ExtraBody, + 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..7d0e3657a 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) } @@ -463,6 +436,40 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { } } +func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.FilterSensitiveData { + t.Fatal("DefaultConfig().Tools.FilterSensitiveData should be true") + } +} + +func TestDefaultConfig_FilterMinLength(t *testing.T) { + cfg := DefaultConfig() + if cfg.Tools.FilterMinLength != 8 { + t.Fatalf("DefaultConfig().Tools.FilterMinLength = %d, want 8", cfg.Tools.FilterMinLength) + } +} + +func TestToolsConfig_GetFilterMinLength(t *testing.T) { + tests := []struct { + name string + minLen int + expected int + }{ + {"zero returns default", 0, 8}, + {"negative returns default", -1, 8}, + {"positive returns value", 16, 16}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &ToolsConfig{FilterMinLength: tt.minLen} + if got := cfg.GetFilterMinLength(); got != tt.expected { + t.Errorf("GetFilterMinLength() = %v, want %v", got, tt.expected) + } + }) + } +} + func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Tools.Cron.AllowCommand { @@ -493,26 +500,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 +520,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 +537,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 +562,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 +815,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 +841,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 +861,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 +886,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 +923,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 +942,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 +962,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 +997,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 +1039,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 +1063,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 +1092,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 +1137,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 +1188,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 +1213,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) } @@ -1197,3 +1227,242 @@ func TestConfigLogLevelEmpty(t *testing.T) { t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel) } } + +func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfg := &Config{ + ModelList: []*ModelConfig{ + { + ModelName: "test-model", + Model: "openai/test", + apiKeys: []string{"sk-test"}, + ExtraBody: map[string]any{"custom_field": "value", "num_field": 42}, + }, + }, + security: &SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{"test-model:0": {APIKeys: []string{"sk-test"}}}, + }, + } + + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + loaded, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + + if loaded.ModelList[0].ExtraBody == nil { + t.Fatal("ExtraBody should not be nil after round-trip") + } + if got := loaded.ModelList[0].ExtraBody["custom_field"]; got != "value" { + t.Errorf("ExtraBody[custom_field] = %v, want value", got) + } + if got := loaded.ModelList[0].ExtraBody["num_field"]; got != float64(42) { + t.Errorf("ExtraBody[num_field] = %v, want 42", got) + } +} + +func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { + cfg := DefaultConfig() + + var minimaxCfg *ModelConfig + for i := range cfg.ModelList { + if cfg.ModelList[i].Model == "minimax/MiniMax-M2.5" { + minimaxCfg = cfg.ModelList[i] + break + } + } + if minimaxCfg == nil { + t.Fatal("Minimax model not found in ModelList") + } + if minimaxCfg.ExtraBody == nil { + t.Fatal("Minimax ExtraBody should not be nil") + } + if got, ok := minimaxCfg.ExtraBody["reasoning_split"]; !ok || got != true { + t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got) + } +} + +func TestFilterSensitiveData(t *testing.T) { + // Test with nil security config + cfg := &Config{} + if got := cfg.FilterSensitiveData("hello sk-key123 world"); got != "hello sk-key123 world" { + t.Errorf("nil security: got %q, want original", got) + } + + // Test with empty content + cfg.security = &SecurityConfig{} + if got := cfg.FilterSensitiveData(""); got != "" { + t.Errorf("empty content: got %q, want empty", got) + } + + // Test short content (less than FilterMinLength=8, should skip filtering) + cfg.security.ModelList = map[string]ModelSecurityEntry{ + "test": {APIKeys: []string{"sk-long-key-12345"}}, + } + cfg.Tools.FilterSensitiveData = true + cfg.Tools.FilterMinLength = 8 + + // Debug: check if sensitive values are collected + values := cfg.security.collectSensitiveValues() + t.Logf("collected %d sensitive values: %v", len(values), values) + + if got := cfg.FilterSensitiveData("sk-key"); got != "sk-key" { + t.Errorf("short content should not be filtered: got %q", got) + } + + // Test filtering works + content := "Your API key is sk-long-key-12345 and token abc123" + // abc123 is not in sensitive values, only sk-long-key-12345 should be filtered + expected := "Your API key is [FILTERED] and token abc123" + if got := cfg.FilterSensitiveData(content); got != expected { + t.Errorf("filtering failed: got %q, want %q", got, expected) + } + + // Test disabled filtering + cfg.Tools.FilterSensitiveData = false + if got := cfg.FilterSensitiveData(content); got != content { + t.Errorf("disabled filtering: got %q, want original %q", got, content) + } +} + +func TestFilterSensitiveData_MultipleKeys(t *testing.T) { + cfg := &Config{ + Tools: ToolsConfig{ + FilterSensitiveData: true, + FilterMinLength: 8, + }, + } + cfg.security = &SecurityConfig{ + ModelList: map[string]ModelSecurityEntry{ + "model1": {APIKeys: []string{"key-one", "key-two"}}, + "model2": {APIKeys: []string{"key-three"}}, + }, + } + + content := "key-one and key-two and key-three should be filtered" + expected := "[FILTERED] and [FILTERED] and [FILTERED] should be filtered" + if got := cfg.FilterSensitiveData(content); got != expected { + t.Errorf("multiple keys: got %q, want %q", got, expected) + } +} + +func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { + cfg := &Config{ + Tools: ToolsConfig{ + FilterSensitiveData: true, + FilterMinLength: 8, + }, + } + cfg.security = &SecurityConfig{ + // Model API keys + ModelList: map[string]ModelSecurityEntry{ + "test-model": {APIKeys: []string{"sk-model-key-12345"}}, + }, + // Channel tokens + Channels: ChannelsSecurity{ + Telegram: &TelegramSecurity{Token: "telegram-bot-token-abcdef"}, + Discord: &DiscordSecurity{Token: "discord-bot-token-xyz789"}, + Slack: &SlackSecurity{BotToken: "xoxb-slack-bot-token", AppToken: "xapp-slack-app-token"}, + Matrix: &MatrixSecurity{AccessToken: "matrix-access-token-abc"}, + Feishu: &FeishuSecurity{AppSecret: "feishu-app-secret-123", EncryptKey: "feishu-encrypt-key"}, + DingTalk: &DingTalkSecurity{ClientSecret: "dingtalk-client-secret"}, + OneBot: &OneBotSecurity{AccessToken: "onebot-access-token"}, + WeCom: &WeComSecurity{Token: "wecom-token", EncodingAESKey: "wecom-aes-key"}, + WeComApp: &WeComAppSecurity{CorpSecret: "wecom-app-secret", Token: "wecom-app-token"}, + Pico: &PicoSecurity{Token: "pico-token-abc123"}, + IRC: &IRCSecurity{ + Password: "irc-password", + NickServPassword: "nickserv-pass", + SASLPassword: "sasl-pass", + }, + }, + // Web tool API keys + Web: WebToolsSecurity{ + Brave: &BraveSecurity{APIKeys: []string{"brave-api-key"}}, + Tavily: &TavilySecurity{APIKeys: []string{"tavily-api-key"}}, + Perplexity: &PerplexitySecurity{APIKeys: []string{"perplexity-api-key"}}, + GLMSearch: &GLMSearchSecurity{APIKey: "glm-search-key"}, + BaiduSearch: &BaiduSearchSecurity{APIKey: "baidu-search-key"}, + }, + // Skills tokens + Skills: SkillsSecurity{ + Github: &GithubSecurity{Token: "github-token-xyz"}, + ClawHub: &ClawHubSecurity{AuthToken: "clawhub-auth-token"}, + }, + } + + tests := []struct { + name string + content string + want string + }{ + { + name: "model_api_key", + content: "Using model with key sk-model-key-12345", + want: "Using model with key [FILTERED]", + }, + { + name: "telegram_token", + content: "Telegram token: telegram-bot-token-abcdef", + want: "Telegram token: [FILTERED]", + }, + { + name: "discord_token", + content: "Discord token: discord-bot-token-xyz789", + want: "Discord token: [FILTERED]", + }, + { + name: "slack_tokens", + content: "Slack bot: xoxb-slack-bot-token, app: xapp-slack-app-token", + want: "Slack bot: [FILTERED], app: [FILTERED]", + }, + { + name: "matrix_token", + content: "Matrix access token: matrix-access-token-abc", + want: "Matrix access token: [FILTERED]", + }, + { + name: "brave_api_key", + content: "Brave key: brave-api-key", + want: "Brave key: [FILTERED]", + }, + { + name: "tavily_api_key", + content: "Tavily key: tavily-api-key", + want: "Tavily key: [FILTERED]", + }, + { + name: "github_token", + content: "GitHub token: github-token-xyz", + want: "GitHub token: [FILTERED]", + }, + { + name: "irc_passwords", + content: "IRC password: irc-password, nickserv: nickserv-pass", + want: "IRC password: [FILTERED], nickserv: [FILTERED]", + }, + { + name: "mixed_content", + content: "Model key sk-model-key-12345 and Telegram token telegram-bot-token-abcdef", + want: "Model key [FILTERED] and Telegram token [FILTERED]", + }, + { + name: "short_key_not_filtered", + content: "Key abc not filtered because length < 8", + want: "Key abc not filtered because length < 8", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cfg.FilterSensitiveData(tt.content); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7c47c8474..48c03f988 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,7 @@ func DefaultConfig() *Config { ModelName: "MiniMax-M2.5", Model: "minimax/MiniMax-M2.5", APIBase: "https://api.minimaxi.com/v1", - APIKey: "", + ExtraBody: map[string]any{"reasoning_split": true}, }, // LongCat - https://longcat.chat/platform @@ -391,7 +347,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 +354,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 +361,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 +369,6 @@ func DefaultConfig() *Config { ModelName: "azure-gpt5", Model: "azure/my-gpt5-deployment", APIBase: "https://your-resource.openai.azure.com", - APIKey: "", }, }, Gateway: GatewayConfig{ @@ -426,6 +378,8 @@ func DefaultConfig() *Config { LogLevel: "fatal", }, Tools: ToolsConfig{ + FilterSensitiveData: true, + FilterMinLength: 8, MediaCleanup: MediaCleanupConfig{ ToolConfig: ToolConfig{ Enabled: true, @@ -443,14 +397,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 +409,6 @@ func DefaultConfig() *Config { }, Perplexity: PerplexityConfig{ Enabled: false, - APIKey: "", - APIKeys: nil, MaxResults: 5, }, SearXNG: SearXNGConfig{ @@ -470,14 +418,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 +537,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..c6641f099 --- /dev/null +++ b/pkg/config/security.go @@ -0,0 +1,314 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + + "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"` + + // cache for sensitive values and compiled regex (computed once) + sensitiveCache *SensitiveDataCache +} + +// 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) +} + +// SensitiveDataCache caches the compiled regex for filtering sensitive data. +// SensitiveDataCache caches the strings.Replacer for filtering sensitive data. +// Computed once on first access via sync.Once. +type SensitiveDataCache struct { + replacer *strings.Replacer + once sync.Once +} + +// SensitiveDataReplacer returns the strings.Replacer for filtering sensitive data. +// It is computed once on first access via sync.Once. +func (sec *SecurityConfig) SensitiveDataReplacer() *strings.Replacer { + sec.initSensitiveCache() + return sec.sensitiveCache.replacer +} + +// initSensitiveCache initializes the sensitive data cache if not already done. +func (sec *SecurityConfig) initSensitiveCache() { + if sec.sensitiveCache == nil { + sec.sensitiveCache = &SensitiveDataCache{} + } + sec.sensitiveCache.once.Do(func() { + values := sec.collectSensitiveValues() + if len(values) == 0 { + sec.sensitiveCache.replacer = strings.NewReplacer() + return + } + + // Build old/new pairs for strings.Replacer + var pairs []string + for _, v := range values { + if len(v) > 3 { + pairs = append(pairs, v, "[FILTERED]") + } + } + if len(pairs) == 0 { + sec.sensitiveCache.replacer = strings.NewReplacer() + return + } + sec.sensitiveCache.replacer = strings.NewReplacer(pairs...) + }) +} + +// collectSensitiveValues collects all sensitive strings from SecurityConfig using reflection. +func (sec *SecurityConfig) collectSensitiveValues() []string { + var values []string + collectSensitive(reflect.ValueOf(sec), &values) + return values +} + +// collectSensitive recursively traverses the value and collects all non-empty string fields. +func collectSensitive(v reflect.Value, values *[]string) { + // Dereference pointers/interfaces to get the underlying value + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + if v.IsNil() { + return + } + v = v.Elem() + } + + switch v.Kind() { + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := v.Type().Field(i) + if !fieldType.IsExported() { + continue + } + collectSensitive(field, values) + } + case reflect.String: + if v.String() != "" { + *values = append(*values, v.String()) + } + case reflect.Slice: + if v.Type().Elem().Kind() == reflect.String { + for i := 0; i < v.Len(); i++ { + if s := v.Index(i).String(); s != "" { + *values = append(*values, s) + } + } + } + case reflect.Map: + for _, key := range v.MapKeys() { + collectSensitive(v.MapIndex(key), values) + } + } +} 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..fc2465747 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -47,6 +47,10 @@ const ( serviceShutdownTimeout = 30 * time.Second providerReloadTimeout = 30 * time.Second gracefulShutdownTimeout = 15 * time.Second + + logPath = "logs" + panicFile = "gateway_panic.log" + logFile = "gateway.log" ) type services struct { @@ -79,7 +83,19 @@ func (p *startupBlockedProvider) GetDefaultModel() string { } // Run starts the gateway runtime using the configuration loaded from configPath. -func Run(debug bool, configPath string, allowEmptyStartup bool) error { +func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error { + panicPath := filepath.Join(homePath, logPath, panicFile) + panicFunc, err := logger.InitPanic(panicPath) + if err != nil { + return fmt.Errorf("error initializing panic log: %w", err) + } + defer panicFunc() + + if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil { + panic(fmt.Sprintf("error enabling file logging: %v", err)) + } + defer logger.DisableFileLogging() + cfg, err := config.LoadConfig(configPath) if err != nil { return fmt.Errorf("error loading config: %w", err) @@ -381,9 +397,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/logger/panic.go b/pkg/logger/panic.go new file mode 100644 index 000000000..e53e4351a --- /dev/null +++ b/pkg/logger/panic.go @@ -0,0 +1,36 @@ +package logger + +import ( + "fmt" + "os" + "path/filepath" + "runtime/debug" + "time" +) + +func InitPanic(filePath string) (func(), error) { + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + writer := initPanicFile(filePath) + if writer == nil { + return nil, fmt.Errorf("failed to create log file: %s", filePath) + } + return func() { + defer writer.Close() + if err := recover(); err != nil { + now := time.Now().Format("2006-01-02 15:04:05") + stack := debug.Stack() + logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf( + "%v", + err, + ) + "\n" + string( + stack, + ) + + writer.Write([]byte(logMsg)) + + os.Exit(1) + } + }, nil +} diff --git a/pkg/logger/panic_unix.go b/pkg/logger/panic_unix.go new file mode 100644 index 000000000..48f393b45 --- /dev/null +++ b/pkg/logger/panic_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package logger + +import ( + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +func initPanicFile(panicFile string) io.WriteCloser { + file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND|os.O_SYNC, 0o600) + if err != nil { + panic(fmt.Sprintf("error in open panic: %v", err)) + } + if err = unix.Dup2(int(file.Fd()), int(os.Stderr.Fd())); err != nil { + panic(fmt.Sprintf("error in syscall.Dup2: %v", err)) + } + return file +} diff --git a/pkg/logger/panic_win.go b/pkg/logger/panic_win.go new file mode 100644 index 000000000..1e6eead02 --- /dev/null +++ b/pkg/logger/panic_win.go @@ -0,0 +1,25 @@ +//go:build windows +// +build windows + +package logger + +import ( + "fmt" + "io" + "os" + + "golang.org/x/sys/windows" +) + +func initPanicFile(panicFile string) io.WriteCloser { + file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_SYNC|os.O_APPEND, 0o600) + if err != nil { + panic(fmt.Sprintf("error in open panic: %v", err)) + } + err = windows.SetStdHandle(windows.STD_ERROR_HANDLE, windows.Handle(file.Fd())) + if err != nil { + panic(fmt.Sprintf("Failed to redirect stderr to file: %v", err)) + } + os.Stderr = file + return file +} 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/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index 2b19e941a..6a1c473dd 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -188,17 +188,23 @@ func buildRequestBody( case "user": if msg.ToolCallID != "" { - // Tool result message - content := []map[string]any{ - { - "type": "tool_result", - "tool_use_id": msg.ToolCallID, - "content": msg.Content, - }, + // Tool result message — merge into previous user message if it contains tool_results + toolResultBlock := map[string]any{ + "type": "tool_result", + "tool_use_id": msg.ToolCallID, + "content": msg.Content, + } + if len(apiMessages) > 0 { + if prev, ok := apiMessages[len(apiMessages)-1].(map[string]any); ok && prev["role"] == "user" { + if content, ok := prev["content"].([]map[string]any); ok { + prev["content"] = append(content, toolResultBlock) + continue + } + } } apiMessages = append(apiMessages, map[string]any{ "role": "user", - "content": content, + "content": []map[string]any{toolResultBlock}, }) } else { // Regular user message @@ -246,17 +252,23 @@ func buildRequestBody( }) case "tool": - // Tool result (alternative format) - content := []map[string]any{ - { - "type": "tool_result", - "tool_use_id": msg.ToolCallID, - "content": msg.Content, - }, + // Tool result (alternative format) — merge into previous user message if it contains tool_results + toolResultBlock := map[string]any{ + "type": "tool_result", + "tool_use_id": msg.ToolCallID, + "content": msg.Content, + } + if len(apiMessages) > 0 { + if prev, ok := apiMessages[len(apiMessages)-1].(map[string]any); ok && prev["role"] == "user" { + if content, ok := prev["content"].([]map[string]any); ok { + prev["content"] = append(content, toolResultBlock) + continue + } + } } apiMessages = append(apiMessages, map[string]any{ "role": "user", - "content": content, + "content": []map[string]any{toolResultBlock}, }) } } diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go index 8eabc15fa..39bc48117 100644 --- a/pkg/providers/anthropic_messages/provider_test.go +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -562,6 +562,96 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) { } } +func TestBuildRequestBody_ConsecutiveToolResultsMerged(t *testing.T) { + // Consecutive tool results (role "tool") should be merged into a single "user" message + messages := []Message{ + {Role: "user", Content: "Use tools"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{ + {ID: "t1", Name: "tool_a", Arguments: map[string]any{"x": 1}}, + {ID: "t2", Name: "tool_b", Arguments: map[string]any{"y": 2}}, + }}, + {Role: "tool", ToolCallID: "t1", Content: "result1"}, + {Role: "tool", ToolCallID: "t2", Content: "result2"}, + } + + got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192}) + if err != nil { + t.Fatalf("buildRequestBody() error: %v", err) + } + + apiMessages, ok := got["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any") + } + + // Expect: user, assistant, user (merged tool results) + if len(apiMessages) != 3 { + for i, m := range apiMessages { + t.Logf("message[%d]: %+v", i, m) + } + t.Fatalf("expected 3 API messages, got %d", len(apiMessages)) + } + + // The third message should be a user message with 2 tool_result blocks + toolResultMsg, ok := apiMessages[2].(map[string]any) + if !ok { + t.Fatalf("tool result message is not map[string]any") + } + if toolResultMsg["role"] != "user" { + t.Errorf("expected role 'user', got %v", toolResultMsg["role"]) + } + content, ok := toolResultMsg["content"].([]map[string]any) + if !ok { + t.Fatalf("content is not []map[string]any: %T", toolResultMsg["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 tool_result blocks, got %d", len(content)) + } + if content[0]["tool_use_id"] != "t1" { + t.Errorf("first tool_result tool_use_id = %v, want t1", content[0]["tool_use_id"]) + } + if content[1]["tool_use_id"] != "t2" { + t.Errorf("second tool_result tool_use_id = %v, want t2", content[1]["tool_use_id"]) + } +} + +func TestBuildRequestBody_UserToolResultsMerged(t *testing.T) { + // Consecutive tool results using role "user" with ToolCallID should also be merged + messages := []Message{ + {Role: "user", Content: "Use tools"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{ + {ID: "t1", Name: "tool_a", Arguments: map[string]any{"x": 1}}, + {ID: "t2", Name: "tool_b", Arguments: map[string]any{"y": 2}}, + }}, + {Role: "user", ToolCallID: "t1", Content: "result1"}, + {Role: "user", ToolCallID: "t2", Content: "result2"}, + } + + got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192}) + if err != nil { + t.Fatalf("buildRequestBody() error: %v", err) + } + + apiMessages, ok := got["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any") + } + + // Expect: user, assistant, user (merged tool results) + if len(apiMessages) != 3 { + t.Fatalf("expected 3 API messages, got %d", len(apiMessages)) + } + + toolResultMsg := apiMessages[2].(map[string]any) + content, ok := toolResultMsg["content"].([]map[string]any) + if !ok { + t.Fatalf("content is not []map[string]any: %T", toolResultMsg["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 tool_result blocks, got %d", len(content)) + } +} + // TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody. func TestParseResponseBodyEdgeCases(t *testing.T) { tests := []struct { diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go new file mode 100644 index 000000000..15c4f664e --- /dev/null +++ b/pkg/providers/bedrock/provider_bedrock.go @@ -0,0 +1,582 @@ +//go:build bedrock + +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package bedrock implements the LLM provider interface for AWS Bedrock. +// It uses the Bedrock Runtime Converse API for unified access to multiple +// model families (Claude, Llama, Mistral, etc.) with tool/function calling support. +package bedrock + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "math" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + + "github.com/sipeed/picoclaw/pkg/providers/common" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +) + +// Provider implements the LLM provider interface for AWS Bedrock. +type Provider struct { + client *bedrockruntime.Client + region string + requestTimeout time.Duration +} + +// Option configures the Bedrock Provider. +type Option func(*providerConfig) + +type providerConfig struct { + region string + profile string + baseEndpoint string + requestTimeout time.Duration +} + +// WithRegion sets the AWS region for Bedrock requests. +func WithRegion(region string) Option { + return func(c *providerConfig) { + c.region = region + } +} + +// WithProfile sets the AWS profile to use for credentials. +func WithProfile(profile string) Option { + return func(c *providerConfig) { + c.profile = profile + } +} + +// WithBaseEndpoint sets a custom Bedrock endpoint URL. +// Example: https://bedrock-runtime.us-east-1.amazonaws.com +func WithBaseEndpoint(endpoint string) Option { + return func(c *providerConfig) { + c.baseEndpoint = endpoint + } +} + +// WithRequestTimeout sets the timeout for Bedrock API requests. +func WithRequestTimeout(timeout time.Duration) Option { + return func(c *providerConfig) { + c.requestTimeout = timeout + } +} + +// NewProvider creates a new AWS Bedrock provider. +// It uses the default AWS credential chain (env vars, shared config, IAM roles, etc.). +func NewProvider(ctx context.Context, opts ...Option) (*Provider, error) { + pc := &providerConfig{} + for _, opt := range opts { + opt(pc) + } + + // Build AWS config options + var configOpts []func(*config.LoadOptions) error + + if pc.region != "" { + configOpts = append(configOpts, config.WithRegion(pc.region)) + } + + if pc.profile != "" { + configOpts = append(configOpts, config.WithSharedConfigProfile(pc.profile)) + } + + // Load AWS config with automatic credential discovery + cfg, err := config.LoadDefaultConfig(ctx, configOpts...) + if err != nil { + return nil, fmt.Errorf("loading AWS config: %w", err) + } + + // Validate region is set - required for Bedrock request signing + if cfg.Region == "" { + return nil, fmt.Errorf( + "AWS region not configured: set AWS_REGION, AWS_DEFAULT_REGION, or use WithRegion option", + ) + } + + // Build client options + var clientOpts []func(*bedrockruntime.Options) + if pc.baseEndpoint != "" { + clientOpts = append(clientOpts, func(o *bedrockruntime.Options) { + o.BaseEndpoint = aws.String(pc.baseEndpoint) + }) + } + + client := bedrockruntime.NewFromConfig(cfg, clientOpts...) + + return &Provider{ + client: client, + region: cfg.Region, + requestTimeout: pc.requestTimeout, + }, nil +} + +// Chat sends messages to AWS Bedrock using the Converse API. +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + // Apply request timeout if context doesn't already have a deadline. + // Use explicit timeout if set, otherwise fall back to common default. + effectiveTimeout := p.requestTimeout + if effectiveTimeout <= 0 { + effectiveTimeout = common.DefaultRequestTimeout + } + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, effectiveTimeout) + defer cancel() + } + + // Build the Converse API input + input := &bedrockruntime.ConverseInput{ + ModelId: aws.String(model), + } + + // Convert messages to Bedrock format + bedrockMessages, systemPrompts := convertMessages(messages) + input.Messages = bedrockMessages + + // Set system prompts if any + if len(systemPrompts) > 0 { + input.System = systemPrompts + } + + // Set inference configuration only when options are provided + var inferenceConfig *types.InferenceConfiguration + + if maxTokens, ok := common.AsInt(options["max_tokens"]); ok && maxTokens > 0 { + if inferenceConfig == nil { + inferenceConfig = &types.InferenceConfiguration{} + } + // Clamp to int32 range to avoid overflow + if maxTokens > math.MaxInt32 { + maxTokens = math.MaxInt32 + } + inferenceConfig.MaxTokens = aws.Int32(int32(maxTokens)) + } + + if temp, ok := common.AsFloat(options["temperature"]); ok { + if inferenceConfig == nil { + inferenceConfig = &types.InferenceConfiguration{} + } + inferenceConfig.Temperature = aws.Float32(float32(temp)) + } + + if inferenceConfig != nil { + input.InferenceConfig = inferenceConfig + } + + // Convert tools to Bedrock format + // Only set ToolConfig if at least one valid tool was produced + if len(tools) > 0 { + toolConfig := convertTools(tools) + if len(toolConfig.Tools) > 0 { + input.ToolConfig = toolConfig + } + } + + // Call Bedrock Converse API + output, err := p.client.Converse(ctx, input) + if err != nil { + return nil, fmt.Errorf("bedrock converse: %w", err) + } + + // Parse the response + return parseResponse(output) +} + +// GetDefaultModel returns an empty string as Bedrock models are user-configured. +func (p *Provider) GetDefaultModel() string { + return "" +} + +// Region returns the AWS region configured for this Provider. +func (p *Provider) Region() string { + return p.region +} + +// convertMessages converts internal messages to Bedrock Converse format. +// Returns the conversation messages and any system prompts separately. +// Note: Bedrock requires all tool results for a given assistant turn to be in a single +// user message with multiple ToolResultBlock content blocks. This function merges +// consecutive tool result messages accordingly. +func convertMessages(messages []Message) ([]types.Message, []types.SystemContentBlock) { + var bedrockMessages []types.Message + var systemPrompts []types.SystemContentBlock + + // Helper to check if a message is a tool result + isToolResult := func(msg Message) bool { + return (msg.Role == "tool" || (msg.Role == "user" && msg.ToolCallID != "")) && msg.ToolCallID != "" + } + + // Helper to create a tool result content block + makeToolResultBlock := func(msg Message) types.ContentBlock { + return &types.ContentBlockMemberToolResult{ + Value: types.ToolResultBlock{ + ToolUseId: aws.String(msg.ToolCallID), + Content: []types.ToolResultContentBlock{ + &types.ToolResultContentBlockMemberText{ + Value: msg.Content, + }, + }, + }, + } + } + + i := 0 + for i < len(messages) { + msg := messages[i] + + switch { + case msg.Role == "system": + // System messages go to the System field + systemPrompts = append(systemPrompts, &types.SystemContentBlockMemberText{ + Value: msg.Content, + }) + i++ + + case isToolResult(msg): + // Collect all consecutive tool results into a single user message + // Bedrock requires all tool results for a turn in one message + var toolResultBlocks []types.ContentBlock + for i < len(messages) && isToolResult(messages[i]) { + toolResultBlocks = append(toolResultBlocks, makeToolResultBlock(messages[i])) + i++ + } + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleUser, + Content: toolResultBlocks, + }) + + case msg.Role == "user": + // Regular user message (no ToolCallID) + content := buildUserContent(msg) + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleUser, + Content: content, + }) + i++ + + case msg.Role == "assistant": + content := buildAssistantContent(msg) + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleAssistant, + Content: content, + }) + i++ + + case msg.Role == "tool" && msg.ToolCallID == "": + // Tool message without ToolCallID - treat as regular user message + content := buildUserContent(msg) + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleUser, + Content: content, + }) + i++ + + default: + // Unknown role - skip + i++ + } + } + + return bedrockMessages, systemPrompts +} + +// buildUserContent builds Bedrock content blocks for a user message. +func buildUserContent(msg Message) []types.ContentBlock { + var content []types.ContentBlock + + // Add text content + if msg.Content != "" { + content = append(content, &types.ContentBlockMemberText{ + Value: msg.Content, + }) + } + + // Add images from Media field + for _, mediaURL := range msg.Media { + if strings.HasPrefix(mediaURL, "data:image/") { + // Parse data URL: data:image/jpeg;base64, + parts := strings.SplitN(mediaURL, ",", 2) + if len(parts) != 2 { + continue + } + + // Extract media type from "data:image/jpeg;base64" + mediaType := "" + header := parts[0] + if idx := strings.Index(header, "/"); idx != -1 { + end := strings.Index(header[idx:], ";") + if end == -1 { + end = len(header) - idx + } + mediaType = header[idx+1 : idx+end] + } + + // Verify this is base64 encoded + if !strings.Contains(header, ";base64") { + continue // Skip non-base64 encoded data + } + + // Map media type to Bedrock format + var format types.ImageFormat + switch mediaType { + case "jpeg", "jpg": + format = types.ImageFormatJpeg + case "png": + format = types.ImageFormatPng + case "gif": + format = types.ImageFormatGif + case "webp": + format = types.ImageFormatWebp + default: + continue // Skip unsupported formats + } + + // Check size before decoding to prevent excessive memory allocation + // Bedrock has a ~20MB request limit; cap decoded images at 10MB + const maxImageSize = 10 * 1024 * 1024 + decodedLen := base64.StdEncoding.DecodedLen(len(parts[1])) + if decodedLen > maxImageSize { + log.Printf("bedrock: skipping image exceeding size limit (%d bytes > %d)", decodedLen, maxImageSize) + continue + } + + // Decode base64 data + imageData, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + log.Printf("bedrock: failed to decode base64 image data: %v", err) + continue + } + + content = append(content, &types.ContentBlockMemberImage{ + Value: types.ImageBlock{ + Format: format, + Source: &types.ImageSourceMemberBytes{ + Value: imageData, + }, + }, + }) + } + } + + // Bedrock requires at least one content block; add empty text if needed + if len(content) == 0 { + content = append(content, &types.ContentBlockMemberText{Value: ""}) + } + + return content +} + +// buildAssistantContent builds Bedrock content blocks for an assistant message. +func buildAssistantContent(msg Message) []types.ContentBlock { + var content []types.ContentBlock + + // Add text content if present + if msg.Content != "" { + content = append(content, &types.ContentBlockMemberText{ + Value: msg.Content, + }) + } + + // Add tool use blocks + for _, tc := range msg.ToolCalls { + // Validate tool call ID - Bedrock requires non-empty ToolUseId + if strings.TrimSpace(tc.ID) == "" { + log.Printf("bedrock: skipping tool call with empty ID (name: %q)", tc.Name) + continue + } + + // Resolve tool name: prefer tc.Name, fallback to tc.Function.Name + // (tc.Name/tc.Arguments are json:"-" and may be empty when from JSON) + toolName := tc.Name + if toolName == "" && tc.Function != nil { + toolName = tc.Function.Name + } + if strings.TrimSpace(toolName) == "" { + continue + } + + // Resolve arguments: prefer tc.Arguments, fallback to parsing tc.Function.Arguments + args := tc.Arguments + if args == nil && tc.Function != nil && tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + log.Printf("bedrock: failed to parse Function.Arguments for tool %q: %v", toolName, err) + args = map[string]any{} + } + } + if args == nil { + args = map[string]any{} + } + + // Convert arguments to a Bedrock document using NewLazyDocument + inputDoc := document.NewLazyDocument(args) + + content = append(content, &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String(tc.ID), + Name: aws.String(toolName), + Input: inputDoc, + }, + }) + } + + // Bedrock requires at least one content block; add empty text if needed + if len(content) == 0 { + content = append(content, &types.ContentBlockMemberText{Value: ""}) + } + + return content +} + +// convertTools converts tool definitions to Bedrock format. +func convertTools(tools []ToolDefinition) *types.ToolConfiguration { + bedrockTools := make([]types.Tool, 0, len(tools)) + + for _, tool := range tools { + // Skip tools with empty names + if strings.TrimSpace(tool.Function.Name) == "" { + continue + } + + // Ensure parameters is not nil - default to minimal object schema + params := tool.Function.Parameters + if params == nil { + params = map[string]any{ + "type": "object", + "properties": map[string]any{}, + } + } + + // Convert parameters schema to a Bedrock document + inputSchema := document.NewLazyDocument(params) + + bedrockTools = append(bedrockTools, &types.ToolMemberToolSpec{ + Value: types.ToolSpecification{ + Name: aws.String(tool.Function.Name), + Description: aws.String(tool.Function.Description), + InputSchema: &types.ToolInputSchemaMemberJson{ + Value: inputSchema, + }, + }, + }) + } + + return &types.ToolConfiguration{ + Tools: bedrockTools, + } +} + +// parseResponse converts Bedrock Converse output to LLMResponse. +func parseResponse(output *bedrockruntime.ConverseOutput) (*LLMResponse, error) { + var content strings.Builder + toolCalls := make([]ToolCall, 0) + + // Process output content blocks + if output.Output != nil { + if msgOutput, ok := output.Output.(*types.ConverseOutputMemberMessage); ok { + for _, block := range msgOutput.Value.Content { + switch b := block.(type) { + case *types.ContentBlockMemberText: + content.WriteString(b.Value) + + case *types.ContentBlockMemberToolUse: + // Unmarshal the document interface to a map + args := make(map[string]any) + if b.Value.Input != nil { + if err := b.Value.Input.UnmarshalSmithyDocument(&args); err != nil { + log.Printf("bedrock: failed to unmarshal tool input for tool %q (id %q): %v", + aws.ToString(b.Value.Name), + aws.ToString(b.Value.ToolUseId), + err, + ) + args = make(map[string]any) + } + } + + // Serialize arguments to JSON string for FunctionCall + argsJSON, err := json.Marshal(args) + if err != nil { + log.Printf("bedrock: failed to marshal tool arguments for tool %q (id %q): %v", + aws.ToString(b.Value.Name), + aws.ToString(b.Value.ToolUseId), + err, + ) + argsJSON = []byte("{}") + } + + toolCalls = append(toolCalls, ToolCall{ + ID: aws.ToString(b.Value.ToolUseId), + Name: aws.ToString(b.Value.Name), + Arguments: args, + Function: &FunctionCall{ + Name: aws.ToString(b.Value.Name), + Arguments: string(argsJSON), + }, + }) + } + } + } + } + + // Map stop reason + finishReason := "stop" + switch output.StopReason { + case types.StopReasonToolUse: + finishReason = "tool_calls" + case types.StopReasonMaxTokens: + finishReason = "length" + case types.StopReasonEndTurn: + finishReason = "stop" + case types.StopReasonStopSequence: + finishReason = "stop" + case types.StopReasonContentFiltered: + finishReason = "content_filter" + } + + // Build usage info + var usage *UsageInfo + if output.Usage != nil { + usage = &UsageInfo{ + PromptTokens: int(aws.ToInt32(output.Usage.InputTokens)), + CompletionTokens: int(aws.ToInt32(output.Usage.OutputTokens)), + TotalTokens: int(aws.ToInt32(output.Usage.InputTokens)) + int(aws.ToInt32(output.Usage.OutputTokens)), + } + } + + return &LLMResponse{ + Content: content.String(), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, + }, nil +} diff --git a/pkg/providers/bedrock/provider_bedrock_test.go b/pkg/providers/bedrock/provider_bedrock_test.go new file mode 100644 index 000000000..754d112ee --- /dev/null +++ b/pkg/providers/bedrock/provider_bedrock_test.go @@ -0,0 +1,541 @@ +//go:build bedrock + +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package bedrock + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +func TestConvertMessages_SystemPrompts(t *testing.T) { + messages := []Message{ + {Role: "system", Content: "You are a helpful assistant."}, + {Role: "user", Content: "Hello"}, + } + + bedrockMsgs, systemPrompts := convertMessages(messages) + + assert.Len(t, systemPrompts, 1) + assert.Len(t, bedrockMsgs, 1) + + // Check system prompt + textBlock, ok := systemPrompts[0].(*types.SystemContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "You are a helpful assistant.", textBlock.Value) + + // Check user message + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) +} + +func TestConvertMessages_UserMessage(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "What is 2+2?"}, + } + + bedrockMsgs, systemPrompts := convertMessages(messages) + + assert.Empty(t, systemPrompts) + assert.Len(t, bedrockMsgs, 1) + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) + + textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "What is 2+2?", textBlock.Value) +} + +func TestConvertMessages_AssistantMessage(t *testing.T) { + messages := []Message{ + {Role: "assistant", Content: "The answer is 4."}, + } + + bedrockMsgs, _ := convertMessages(messages) + + assert.Len(t, bedrockMsgs, 1) + assert.Equal(t, types.ConversationRoleAssistant, bedrockMsgs[0].Role) + + textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "The answer is 4.", textBlock.Value) +} + +func TestConvertMessages_ToolResult(t *testing.T) { + messages := []Message{ + {Role: "tool", Content: "Result from tool", ToolCallID: "call_123"}, + } + + bedrockMsgs, _ := convertMessages(messages) + + assert.Len(t, bedrockMsgs, 1) + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) + + toolResult, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberToolResult) + require.True(t, ok) + assert.Equal(t, "call_123", aws.ToString(toolResult.Value.ToolUseId)) +} + +func TestConvertMessages_MultipleToolResultsMerged(t *testing.T) { + // When an assistant makes multiple tool calls, all tool results must be + // merged into a single user message for Bedrock + messages := []Message{ + {Role: "user", Content: "What's the weather in NYC and LA?"}, + { + Role: "assistant", + Content: "Let me check both cities.", + ToolCalls: []protocoltypes.ToolCall{ + {ID: "call_nyc", Name: "get_weather", Arguments: map[string]any{"city": "NYC"}}, + {ID: "call_la", Name: "get_weather", Arguments: map[string]any{"city": "LA"}}, + }, + }, + {Role: "tool", Content: "NYC: 72°F, sunny", ToolCallID: "call_nyc"}, + {Role: "tool", Content: "LA: 85°F, clear", ToolCallID: "call_la"}, + } + + bedrockMsgs, _ := convertMessages(messages) + + // Should be: user message, assistant message, merged tool results (single user message) + assert.Len(t, bedrockMsgs, 3) + + // First message: user + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) + + // Second message: assistant with tool calls + assert.Equal(t, types.ConversationRoleAssistant, bedrockMsgs[1].Role) + + // Third message: merged tool results in single user message + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[2].Role) + assert.Len(t, bedrockMsgs[2].Content, 2) // Both tool results in one message + + // Verify both tool results are present + result1, ok := bedrockMsgs[2].Content[0].(*types.ContentBlockMemberToolResult) + require.True(t, ok) + assert.Equal(t, "call_nyc", aws.ToString(result1.Value.ToolUseId)) + + result2, ok := bedrockMsgs[2].Content[1].(*types.ContentBlockMemberToolResult) + require.True(t, ok) + assert.Equal(t, "call_la", aws.ToString(result2.Value.ToolUseId)) +} + +func TestConvertMessages_AssistantWithToolCalls(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + Content: "Let me calculate that.", + ToolCalls: []protocoltypes.ToolCall{ + { + ID: "call_456", + Name: "calculator", + Arguments: map[string]any{"expression": "2+2"}, + }, + }, + }, + } + + bedrockMsgs, _ := convertMessages(messages) + + assert.Len(t, bedrockMsgs, 1) + assert.Len(t, bedrockMsgs[0].Content, 2) // text + tool use + + // Check text content + textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "Let me calculate that.", textBlock.Value) + + // Check tool use + toolUse, ok := bedrockMsgs[0].Content[1].(*types.ContentBlockMemberToolUse) + require.True(t, ok) + assert.Equal(t, "call_456", aws.ToString(toolUse.Value.ToolUseId)) + assert.Equal(t, "calculator", aws.ToString(toolUse.Value.Name)) +} + +func TestConvertTools_Basic(t *testing.T) { + tools := []ToolDefinition{ + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get the current weather", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + + toolConfig := convertTools(tools) + + assert.NotNil(t, toolConfig) + assert.Len(t, toolConfig.Tools, 1) + + toolSpec, ok := toolConfig.Tools[0].(*types.ToolMemberToolSpec) + require.True(t, ok) + assert.Equal(t, "get_weather", aws.ToString(toolSpec.Value.Name)) + assert.Equal(t, "Get the current weather", aws.ToString(toolSpec.Value.Description)) +} + +func TestConvertTools_SkipsEmptyName(t *testing.T) { + tools := []ToolDefinition{ + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: "", + Description: "Empty name tool", + }, + }, + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: " ", + Description: "Whitespace name tool", + }, + }, + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: "valid_tool", + Description: "Valid tool", + }, + }, + } + + toolConfig := convertTools(tools) + + assert.Len(t, toolConfig.Tools, 1) + toolSpec := toolConfig.Tools[0].(*types.ToolMemberToolSpec) + assert.Equal(t, "valid_tool", aws.ToString(toolSpec.Value.Name)) +} + +func TestConvertTools_NilParameters(t *testing.T) { + tools := []ToolDefinition{ + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: "simple_tool", + Description: "A tool with no parameters", + Parameters: nil, + }, + }, + } + + toolConfig := convertTools(tools) + + assert.Len(t, toolConfig.Tools, 1) + // Should not panic and should create a valid tool +} + +func TestBuildUserContent_TextOnly(t *testing.T) { + msg := Message{Content: "Hello world"} + + content := buildUserContent(msg) + + assert.Len(t, content, 1) + textBlock, ok := content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "Hello world", textBlock.Value) +} + +func TestBuildUserContent_WithImage(t *testing.T) { + // Base64-encoded 1x1 PNG (the provider doesn't validate image correctness, + // it just verifies the format and base64 decoding works) + b64Data := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + + msg := Message{ + Content: "Look at this image", + Media: []string{"data:image/png;base64," + b64Data}, + } + + content := buildUserContent(msg) + + assert.Len(t, content, 2) + + // Check text + textBlock, ok := content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "Look at this image", textBlock.Value) + + // Check image + imageBlock, ok := content[1].(*types.ContentBlockMemberImage) + require.True(t, ok) + assert.Equal(t, types.ImageFormatPng, imageBlock.Value.Format) +} + +func TestBuildUserContent_SkipsInvalidBase64(t *testing.T) { + msg := Message{ + Content: "Invalid image", + Media: []string{"data:image/png;base64,not-valid-base64!!!"}, + } + + content := buildUserContent(msg) + + // Should only have text, image should be skipped + assert.Len(t, content, 1) +} + +func TestBuildUserContent_SkipsNonBase64Data(t *testing.T) { + msg := Message{ + Content: "Non-base64 image", + Media: []string{"data:image/png,raw-data-here"}, + } + + content := buildUserContent(msg) + + // Should only have text, non-base64 image should be skipped + assert.Len(t, content, 1) +} + +func TestBuildAssistantContent_SkipsEmptyToolName(t *testing.T) { + msg := Message{ + Content: "Response", + ToolCalls: []protocoltypes.ToolCall{ + {ID: "1", Name: "", Arguments: map[string]any{}}, + {ID: "2", Name: " ", Arguments: map[string]any{}}, + {ID: "3", Name: "valid", Arguments: map[string]any{}}, + }, + } + + content := buildAssistantContent(msg) + + // Should have text + 1 valid tool + assert.Len(t, content, 2) +} + +func TestBuildAssistantContent_NilArguments(t *testing.T) { + msg := Message{ + ToolCalls: []protocoltypes.ToolCall{ + {ID: "1", Name: "tool", Arguments: nil}, + }, + } + + content := buildAssistantContent(msg) + + assert.Len(t, content, 1) + toolUse, ok := content[0].(*types.ContentBlockMemberToolUse) + require.True(t, ok) + assert.NotNil(t, toolUse.Value.Input) +} + +func TestBuildAssistantContent_FunctionFallback(t *testing.T) { + // When Name/Arguments are empty (json:"-"), should fallback to Function fields + msg := Message{ + ToolCalls: []protocoltypes.ToolCall{ + { + ID: "1", + Name: "", // empty, should fallback to Function.Name + Function: &protocoltypes.FunctionCall{ + Name: "fallback_tool", + Arguments: `{"key":"value"}`, + }, + }, + }, + } + + content := buildAssistantContent(msg) + + assert.Len(t, content, 1) + toolUse, ok := content[0].(*types.ContentBlockMemberToolUse) + require.True(t, ok) + assert.Equal(t, "fallback_tool", aws.ToString(toolUse.Value.Name)) +} + +func TestParseResponse_TextOnly(t *testing.T) { + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Role: types.ConversationRoleAssistant, + Content: []types.ContentBlock{ + &types.ContentBlockMemberText{Value: "Hello!"}, + }, + }, + }, + StopReason: types.StopReasonEndTurn, + Usage: &types.TokenUsage{ + InputTokens: aws.Int32(10), + OutputTokens: aws.Int32(5), + }, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Equal(t, "Hello!", resp.Content) + assert.Equal(t, "stop", resp.FinishReason) + assert.Empty(t, resp.ToolCalls) + assert.Equal(t, 10, resp.Usage.PromptTokens) + assert.Equal(t, 5, resp.Usage.CompletionTokens) +} + +func TestParseResponse_StopReasons(t *testing.T) { + tests := []struct { + stopReason types.StopReason + expectedFinish string + }{ + {types.StopReasonEndTurn, "stop"}, + {types.StopReasonToolUse, "tool_calls"}, + {types.StopReasonMaxTokens, "length"}, + {types.StopReasonStopSequence, "stop"}, + {types.StopReasonContentFiltered, "content_filter"}, + } + + for _, tt := range tests { + t.Run(string(tt.stopReason), func(t *testing.T) { + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Content: []types.ContentBlock{ + &types.ContentBlockMemberText{Value: "test"}, + }, + }, + }, + StopReason: tt.stopReason, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Equal(t, tt.expectedFinish, resp.FinishReason) + }) + } +} + +func TestParseResponse_WithToolCalls(t *testing.T) { + // Note: document.NewLazyDocument has limitations with UnmarshalSmithyDocument in tests, + // so we test the structure extraction and verify Arguments gets populated (even if empty + // due to SDK limitations). The actual unmarshal works correctly at runtime. + toolInput := document.NewLazyDocument(map[string]any{ + "location": "San Francisco", + "unit": "celsius", + }) + + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Role: types.ConversationRoleAssistant, + Content: []types.ContentBlock{ + &types.ContentBlockMemberText{Value: "Let me check the weather."}, + &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String("call_weather_123"), + Name: aws.String("get_weather"), + Input: toolInput, + }, + }, + }, + }, + }, + StopReason: types.StopReasonToolUse, + Usage: &types.TokenUsage{ + InputTokens: aws.Int32(20), + OutputTokens: aws.Int32(15), + }, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Equal(t, "Let me check the weather.", resp.Content) + assert.Equal(t, "tool_calls", resp.FinishReason) + assert.Len(t, resp.ToolCalls, 1) + + // Verify tool call ID and Name are extracted correctly + tc := resp.ToolCalls[0] + assert.Equal(t, "call_weather_123", tc.ID) + assert.Equal(t, "get_weather", tc.Name) + + // Verify Function fields are also populated + require.NotNil(t, tc.Function) + assert.Equal(t, "get_weather", tc.Function.Name) + + // Verify Arguments is not nil (content may vary due to SDK limitations in tests) + assert.NotNil(t, tc.Arguments) + + // Verify usage + assert.Equal(t, 20, resp.Usage.PromptTokens) + assert.Equal(t, 15, resp.Usage.CompletionTokens) + assert.Equal(t, 35, resp.Usage.TotalTokens) +} + +func TestParseResponse_MultipleToolCalls(t *testing.T) { + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Role: types.ConversationRoleAssistant, + Content: []types.ContentBlock{ + &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String("call_1"), + Name: aws.String("tool_a"), + Input: document.NewLazyDocument(map[string]any{"arg": "value1"}), + }, + }, + &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String("call_2"), + Name: aws.String("tool_b"), + Input: document.NewLazyDocument(map[string]any{"arg": "value2"}), + }, + }, + }, + }, + }, + StopReason: types.StopReasonToolUse, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Equal(t, "tool_calls", resp.FinishReason) + assert.Len(t, resp.ToolCalls, 2) + + // Verify tool call structure + assert.Equal(t, "call_1", resp.ToolCalls[0].ID) + assert.Equal(t, "tool_a", resp.ToolCalls[0].Name) + assert.NotNil(t, resp.ToolCalls[0].Arguments) + assert.NotNil(t, resp.ToolCalls[0].Function) + assert.Equal(t, "tool_a", resp.ToolCalls[0].Function.Name) + + assert.Equal(t, "call_2", resp.ToolCalls[1].ID) + assert.Equal(t, "tool_b", resp.ToolCalls[1].Name) + assert.NotNil(t, resp.ToolCalls[1].Arguments) + assert.NotNil(t, resp.ToolCalls[1].Function) + assert.Equal(t, "tool_b", resp.ToolCalls[1].Function.Name) +} + +func TestParseResponse_ToolCallWithNilInput(t *testing.T) { + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Role: types.ConversationRoleAssistant, + Content: []types.ContentBlock{ + &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String("call_nil"), + Name: aws.String("no_args_tool"), + Input: nil, + }, + }, + }, + }, + }, + StopReason: types.StopReasonToolUse, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Len(t, resp.ToolCalls, 1) + assert.Equal(t, "call_nil", resp.ToolCalls[0].ID) + assert.Equal(t, "no_args_tool", resp.ToolCalls[0].Name) + // Arguments should be empty map, not nil + assert.NotNil(t, resp.ToolCalls[0].Arguments) + assert.Empty(t, resp.ToolCalls[0].Arguments) +} diff --git a/pkg/providers/bedrock/provider_stub.go b/pkg/providers/bedrock/provider_stub.go new file mode 100644 index 000000000..894d9f2ca --- /dev/null +++ b/pkg/providers/bedrock/provider_stub.go @@ -0,0 +1,73 @@ +//go:build !bedrock + +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package bedrock provides a stub implementation when built without the bedrock tag. +// To enable AWS Bedrock support, build with: go build -tags bedrock +package bedrock + +import ( + "context" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + LLMResponse = protocoltypes.LLMResponse + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition +) + +// Provider is a stub that returns an error when Bedrock support is not compiled in. +type Provider struct{} + +// Option is a no-op when Bedrock is not enabled. +type Option func(*providerConfig) + +type providerConfig struct{} + +// WithRegion is a no-op when Bedrock is not enabled. +func WithRegion(region string) Option { + return func(c *providerConfig) {} +} + +// WithProfile is a no-op when Bedrock is not enabled. +func WithProfile(profile string) Option { + return func(c *providerConfig) {} +} + +// WithBaseEndpoint is a no-op when Bedrock is not enabled. +func WithBaseEndpoint(endpoint string) Option { + return func(c *providerConfig) {} +} + +// WithRequestTimeout is a no-op when Bedrock is not enabled. +func WithRequestTimeout(timeout time.Duration) Option { + return func(c *providerConfig) {} +} + +// NewProvider returns an error indicating Bedrock support is not compiled in. +func NewProvider(ctx context.Context, opts ...Option) (*Provider, error) { + return nil, fmt.Errorf("bedrock provider not available: build with -tags bedrock to enable AWS Bedrock support") +} + +// Chat returns an error - this should never be called since NewProvider fails. +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + return nil, fmt.Errorf("bedrock provider not available: build with -tags bedrock to enable AWS Bedrock support") +} + +// GetDefaultModel returns an empty string. +func (p *Provider) GetDefaultModel() string { + return "" +} diff --git a/pkg/providers/bedrock/provider_stub_test.go b/pkg/providers/bedrock/provider_stub_test.go new file mode 100644 index 000000000..50ec8340f --- /dev/null +++ b/pkg/providers/bedrock/provider_stub_test.go @@ -0,0 +1,35 @@ +//go:build !bedrock + +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package bedrock + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewProvider_ReturnsStubError(t *testing.T) { + provider, err := NewProvider(context.Background()) + + assert.Nil(t, provider) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "build with -tags bedrock"), + "error should mention build tag requirement, got: %s", err.Error()) +} + +func TestNewProvider_WithOptions_ReturnsStubError(t *testing.T) { + provider, err := NewProvider(context.Background(), WithRegion("us-west-2"), WithProfile("test")) + + assert.Nil(t, provider) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "build with -tags bedrock"), + "error should mention build tag requirement, got: %s", err.Error()) +} 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..1128fc042 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -6,12 +6,15 @@ package providers import ( + "context" "fmt" "strings" + "time" "github.com/sipeed/picoclaw/pkg/config" anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" "github.com/sipeed/picoclaw/pkg/providers/azure" + "github.com/sipeed/picoclaw/pkg/providers/bedrock" ) // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. @@ -55,8 +58,9 @@ func ExtractProtocol(model string) (protocol, modelID string) { // CreateProviderFromConfig creates a provider based on the ModelConfig. // It uses the protocol prefix in the Model field to determine which provider to create. -// Supported protocols: openai, litellm, novita, anthropic, anthropic-messages, -// antigravity, claude-cli, codex-cli, github-copilot +// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini), +// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims. +// See the switch on protocol in this function for the authoritative list. // Returns the provider, the model ID (without protocol prefix), and any error. func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { if cfg == nil { @@ -80,7 +84,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,17 +92,18 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase = getDefaultAPIBase(protocol) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, + cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, + cfg.ExtraBody, ), modelID, nil 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,19 +112,55 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err ) } return azure.NewProviderWithTimeout( - cfg.APIKey, + cfg.APIKey(), cfg.APIBase, cfg.Proxy, cfg.RequestTimeout, ), modelID, nil + case "bedrock": + // AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.) + // api_base can be: + // - A full endpoint URL: https://bedrock-runtime.us-east-1.amazonaws.com + // - A region name: us-east-1 (AWS SDK resolves endpoint automatically) + var opts []bedrock.Option + if cfg.APIBase != "" { + if !strings.Contains(cfg.APIBase, "://") { + // Treat as region: let AWS SDK resolve the correct endpoint + // (supports all AWS partitions: aws, aws-cn, aws-us-gov, etc.) + opts = append(opts, bedrock.WithRegion(cfg.APIBase)) + } else { + // Full endpoint URL provided (for custom endpoints or testing) + opts = append(opts, bedrock.WithBaseEndpoint(cfg.APIBase)) + } + } + // Use a separate timeout for AWS config loading (credential resolution can block) + initTimeout := 30 * time.Second + if cfg.RequestTimeout > 0 { + reqTimeout := time.Duration(cfg.RequestTimeout) * time.Second + // Set request timeout for API calls + opts = append(opts, bedrock.WithRequestTimeout(reqTimeout)) + // Ensure init timeout is at least as large as request timeout + if reqTimeout > initTimeout { + initTimeout = reqTimeout + } + } + ctx, cancel := context.WithTimeout(context.Background(), initTimeout) + defer cancel() + // Note: AWS_PROFILE env var is automatically used by AWS SDK + provider, err := bedrock.NewProvider(ctx, opts...) + if err != nil { + return nil, "", fmt.Errorf("creating bedrock provider: %w", err) + } + return provider, modelID, nil + case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "qwen-us", "dashscope-us", "mistral", "avian", "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,11 +168,37 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase = getDefaultAPIBase(protocol) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, + cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, + cfg.ExtraBody, + ), modelID, nil + + case "minimax": + // Minimax requires reasoning_split: true in the request body + 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 + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + extraBody := cfg.ExtraBody + if extraBody == nil { + extraBody = make(map[string]any) + } + if _, ok := extraBody["reasoning_split"]; !ok { + extraBody["reasoning_split"] = true + } + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey(), + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, + extraBody, ), modelID, nil case "anthropic": @@ -148,15 +215,16 @@ 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, cfg.RequestTimeout, + cfg.ExtraBody, ), modelID, nil case "anthropic-messages": @@ -165,11 +233,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 +248,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..2fed18c35 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -6,6 +6,7 @@ package providers import ( + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -89,9 +90,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 +130,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 +156,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 +176,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 +199,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 +228,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 +256,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 +341,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 +383,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 +413,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 +433,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 +466,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 +490,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 +524,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 +558,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 { @@ -603,3 +605,173 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { } } } + +func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-minimax", + Model: "minimax/MiniMax-M2.5", + APIBase: server.URL, + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "MiniMax-M2.5" { + t.Errorf("modelID = %q, want %q", modelID, "MiniMax-M2.5") + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_split is automatically injected + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } +} + +func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-minimax-custom", + Model: "minimax/MiniMax-M2.5", + APIBase: server.URL, + ExtraBody: map[string]any{"custom_field": "test"}, + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_split is automatically injected + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } + // Verify user's custom field is preserved + if got, ok := requestBody["custom_field"]; !ok || got != "test" { + t.Fatalf("custom_field = %v, want test", got) + } +} + +func TestCreateProviderFromConfig_Bedrock(t *testing.T) { + // Set dummy AWS env vars to make test deterministic + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + // Clear profile-related env vars to avoid loading shared config + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") + t.Setenv("AWS_SDK_LOAD_CONFIG", "") + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "") + + cfg := &config.ModelConfig{ + ModelName: "bedrock-claude", + Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", + APIBase: "us-west-2", // Region (also sets AWS region) + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err == nil { + // Provider created successfully (built with -tags bedrock) + if provider == nil { + t.Error("provider is nil on success") + } + if modelID != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Errorf("modelID = %q, want %q", modelID, "us.anthropic.claude-sonnet-4-20250514-v1:0") + } + return + } + errMsg := err.Error() + // When built without -tags bedrock, expect stub error + if strings.Contains(errMsg, "build with -tags bedrock") { + return // Expected stub error + } + // Unexpected error - fail the test + t.Errorf("unexpected error from bedrock provider: %v", err) +} + +func TestCreateProviderFromConfig_BedrockWithEndpointURL(t *testing.T) { + // Set dummy AWS env vars to make test deterministic + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + t.Setenv("AWS_REGION", "us-east-1") // Required when using endpoint URL + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + // Clear profile-related env vars to avoid loading shared config + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") + t.Setenv("AWS_SDK_LOAD_CONFIG", "") + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "") + + cfg := &config.ModelConfig{ + ModelName: "bedrock-claude", + Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", + APIBase: "https://bedrock-runtime.us-east-1.amazonaws.com", // Full endpoint URL + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err == nil { + // Provider created successfully (built with -tags bedrock) + if provider == nil { + t.Error("provider is nil on success") + } + if modelID != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Errorf("modelID = %q, want %q", modelID, "us.anthropic.claude-sonnet-4-20250514-v1:0") + } + return + } + errMsg := err.Error() + // When built without -tags bedrock, expect stub error + if strings.Contains(errMsg, "build with -tags bedrock") { + return // Expected stub error + } + // Unexpected error - fail the test + t.Errorf("unexpected error from bedrock provider: %v", err) +} 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/http_provider.go b/pkg/providers/http_provider.go index 803165edb..f2ff52f1d 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -24,12 +24,13 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0) + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil) } func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( apiKey, apiBase, proxy, maxTokensField string, requestTimeoutSeconds int, + extraBody map[string]any, ) *HTTPProvider { return &HTTPProvider{ delegate: openai_compat.NewProvider( @@ -38,6 +39,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( proxy, openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), + openai_compat.WithExtraBody(extraBody), ), } } 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/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 938e4ea8b..90bc683b8 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -35,6 +35,7 @@ type Provider struct { apiBase string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client + extraBody map[string]any // Additional fields to inject into request body } type Option func(*Provider) @@ -55,6 +56,12 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +func WithExtraBody(extraBody map[string]any) Option { + return func(p *Provider) { + p.extraBody = extraBody + } +} + func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, @@ -140,6 +147,12 @@ func (p *Provider) buildRequestBody( } } + // Merge extra body fields configured per-provider/model. + // These are injected last so they take precedence over defaults. + for k, v := range p.extraBody { + requestBody[k] = v + } + return requestBody } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index efb03ccb8..ab632ccf3 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -610,6 +610,90 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) { } } +func TestProviderChat_ExtraBodyInjected(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + extraBody := map[string]any{"reasoning_split": true, "custom_field": "test"} + p := NewProvider("key", server.URL, "", WithExtraBody(extraBody)) + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "minimax/abab7", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } + if got, ok := requestBody["custom_field"]; !ok || got != "test" { + t.Fatalf("custom_field = %v, want test", got) + } +} + +func TestProviderChat_ExtraBodyOverridesOptions(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + extraBody := map[string]any{"temperature": 0.9} + p := NewProvider("key", server.URL, "", WithExtraBody(extraBody)) + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "gpt-4o", + map[string]any{"temperature": 0.5}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // ExtraBody takes precedence over options since it is merged last. + if got := requestBody["temperature"]; got != float64(0.9) { + t.Fatalf("temperature = %v, want 0.9 (from extraBody, overriding options)", got) + } +} + type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { 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/tools/team_test.go b/pkg/tools/team_test.go index 986374b80..1fa3224fa 100644 --- a/pkg/tools/team_test.go +++ b/pkg/tools/team_test.go @@ -67,25 +67,34 @@ func TestUpgradeRegistryForConcurrency(t *testing.T) { func TestBuildWorkerConfig(t *testing.T) { // 1. Setup global config with model aliases cfg := &config.Config{ - ModelList: []config.ModelConfig{ - { - ModelName: "strong-model", - Model: "openai/gpt-4o", - APIKey: "sk-test", - APIBase: "https://api.openai.com/v1", - }, - { - ModelName: "fast-model", - Model: "anthropic/claude-3-haiku", - APIKey: "sk-test", - APIBase: "https://api.anthropic.com/v1", - }, - { - ModelName: "direct-id", - Model: "openai/gpt-3.5-turbo", - APIKey: "sk-test", - APIBase: "https://api.openai.com/v1", - }, + ModelList: []*config.ModelConfig{ + func() *config.ModelConfig { + m := &config.ModelConfig{ + ModelName: "strong-model", + Model: "openai/gpt-4o", + APIBase: "https://api.openai.com/v1", + } + m.SetAPIKey("sk-test") + return m + }(), + func() *config.ModelConfig { + m := &config.ModelConfig{ + ModelName: "fast-model", + Model: "anthropic/claude-3-haiku", + APIBase: "https://api.anthropic.com/v1", + } + m.SetAPIKey("sk-test") + return m + }(), + func() *config.ModelConfig { + m := &config.ModelConfig{ + ModelName: "direct-id", + Model: "openai/gpt-3.5-turbo", + APIBase: "https://api.openai.com/v1", + } + m.SetAPIKey("sk-test") + return m + }(), }, } @@ -216,5 +225,3 @@ func TestExecuteSequential(t *testing.T) { assert.Contains(t, result.ForLLM, "Derived result from Agent B") assert.Equal(t, 2, mock.callCount, "Should have called mock provider exactly twice") } - - 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/elevenlabs_transcriber.go b/pkg/voice/elevenlabs_transcriber.go new file mode 100644 index 000000000..93db10f8d --- /dev/null +++ b/pkg/voice/elevenlabs_transcriber.go @@ -0,0 +1,141 @@ +package voice + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// ElevenLabsTranscriber uses the ElevenLabs Scribe API for speech-to-text. +type ElevenLabsTranscriber struct { + apiKey string + apiBase string + httpClient *http.Client +} + +func NewElevenLabsTranscriber(apiKey string) *ElevenLabsTranscriber { + logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""}) + + return &ElevenLabsTranscriber{ + apiKey: apiKey, + apiBase: "https://api.elevenlabs.io", + httpClient: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting ElevenLabs transcription", map[string]any{"audio_file": audioFilePath}) + + audioFile, err := os.Open(audioFilePath) + if err != nil { + logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err}) + return nil, fmt.Errorf("failed to open audio file: %w", err) + } + defer audioFile.Close() + + fileInfo, err := audioFile.Stat() + if err != nil { + logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err}) + return nil, fmt.Errorf("failed to get file info: %w", err) + } + + logger.DebugCF("voice", "Audio file details", map[string]any{ + "size_bytes": fileInfo.Size(), + "file_name": filepath.Base(audioFilePath), + }) + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) + if err != nil { + logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to create form file: %w", err) + } + + if _, err = io.Copy(part, audioFile); err != nil { + logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to copy file content: %w", err) + } + + if err = writer.WriteField("model_id", "scribe_v1"); err != nil { + return nil, fmt.Errorf("failed to write model_id field: %w", err) + } + + if err = writer.Close(); err != nil { + logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + url := t.apiBase + "/v1/speech-to-text" + req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) + if err != nil { + logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Xi-Api-Key", t.apiKey) + + logger.DebugCF("voice", "Sending transcription request to ElevenLabs API", map[string]any{ + "url": url, + "request_size_bytes": requestBody.Len(), + "file_size_bytes": fileInfo.Size(), + }) + + resp, err := t.httpClient.Do(req) + if err != nil { + logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("voice", "ElevenLabs API error", map[string]any{ + "status_code": resp.StatusCode, + "response": string(body), + }) + return nil, fmt.Errorf("ElevenLabs API error (status %d): %s", resp.StatusCode, string(body)) + } + + logger.DebugCF("voice", "Received response from ElevenLabs API", map[string]any{ + "status_code": resp.StatusCode, + "response_size_bytes": len(body), + }) + + var result TranscriptionResponse + if err := json.Unmarshal(body, &result); err != nil { + logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + logger.InfoCF("voice", "ElevenLabs transcription completed successfully", map[string]any{ + "text_length": len(result.Text), + "language": result.Language, + "transcription_preview": utils.Truncate(result.Text, 50), + }) + + return &result, nil +} + +func (t *ElevenLabsTranscriber) Name() string { + return "elevenlabs" +} diff --git a/pkg/voice/elevenlabs_transcriber_test.go b/pkg/voice/elevenlabs_transcriber_test.go new file mode 100644 index 000000000..78be8958a --- /dev/null +++ b/pkg/voice/elevenlabs_transcriber_test.go @@ -0,0 +1,83 @@ +package voice + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// Ensure ElevenLabsTranscriber satisfies the Transcriber interface at compile time. +var _ Transcriber = (*ElevenLabsTranscriber)(nil) + +func TestElevenLabsTranscriberName(t *testing.T) { + tr := NewElevenLabsTranscriber("sk_test") + if got := tr.Name(); got != "elevenlabs" { + t.Errorf("Name() = %q, want %q", got, "elevenlabs") + } +} + +func TestElevenLabsTranscribe(t *testing.T) { + tmpDir := t.TempDir() + audioPath := filepath.Join(tmpDir, "clip.ogg") + if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil { + t.Fatalf("failed to write fake audio file: %v", err) + } + + t.Run("success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/speech-to-text" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Xi-Api-Key") != "sk_test" { + t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TranscriptionResponse{ + Text: "hello from elevenlabs", + Language: "en", + }) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("sk_test") + tr.apiBase = srv.URL + + resp, err := tr.Transcribe(context.Background(), audioPath) + if err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + if resp.Text != "hello from elevenlabs" { + t.Errorf("Text = %q, want %q", resp.Text, "hello from elevenlabs") + } + if resp.Language != "en" { + t.Errorf("Language = %q, want %q", resp.Language, "en") + } + }) + + t.Run("api error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("sk_bad") + tr.apiBase = srv.URL + + _, err := tr.Transcribe(context.Background(), audioPath) + if err == nil { + t.Fatal("expected error for non-200 response, got nil") + } + }) + + t.Run("missing file", func(t *testing.T) { + tr := NewElevenLabsTranscriber("sk_test") + _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + }) +} diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index f3e6af71e..f56fdeedd 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -54,14 +54,14 @@ func DetectTranscriber(cfg *config.Config) Transcriber { } } - // Direct Groq provider config takes priority. - if key := cfg.Providers.Groq.APIKey; key != "" { - return NewGroqTranscriber(key) + // ElevenLabs voice config (supports Scribe STT). + if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" { + return NewElevenLabsTranscriber(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..70a7fca8f 100644 --- a/pkg/voice/transcriber_test.go +++ b/pkg/voice/transcriber_test.go @@ -18,101 +18,175 @@ 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, }, + { + name: "elevenlabs voice config key", + cfg: &config.Config{ + Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"}, + }, + wantName: "elevenlabs", + }, + { + name: "elevenlabs takes priority over groq model list", + cfg: (&config.Config{ + Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"}, + ModelList: []*config.ModelConfig{ + {ModelName: "groq", Model: "groq/llama-3.3-70b"}, + }, + }).WithSecurity(&config.SecurityConfig{ + ModelList: map[string]config.ModelSecurityEntry{ + "groq": { + APIKeys: []string{"sk-groq-direct"}, + }, + }, + }), + wantName: "elevenlabs", + }, + { + name: "voice model name takes priority over elevenlabs", + cfg: (&config.Config{ + Voice: config.VoiceConfig{ + ModelName: "voice-gemini", + ElevenLabsAPIKey: "sk_elevenlabs_test", + }, + 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", + }, } for _, tc := range tests { 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..802b28526 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -31,12 +31,13 @@ type modelResponse struct { Proxy string `json:"proxy,omitempty"` AuthMethod string `json:"auth_method,omitempty"` // Advanced fields - ConnectMode string `json:"connect_mode,omitempty"` - Workspace string `json:"workspace,omitempty"` - RPM int `json:"rpm,omitempty"` - MaxTokensField string `json:"max_tokens_field,omitempty"` - RequestTimeout int `json:"request_timeout,omitempty"` - ThinkingLevel string `json:"thinking_level,omitempty"` + ConnectMode string `json:"connect_mode,omitempty"` + Workspace string `json:"workspace,omitempty"` + RPM int `json:"rpm,omitempty"` + MaxTokensField string `json:"max_tokens_field,omitempty"` + RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` + ExtraBody map[string]any `json:"extra_body,omitempty"` // Meta Configured bool `json:"configured"` IsDefault bool `json:"is_default"` @@ -58,7 +59,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 +73,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, @@ -81,6 +82,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, + ExtraBody: m.ExtraBody, Configured: configured[i], IsDefault: m.ModelName == defaultModel, }) @@ -122,7 +124,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 +182,14 @@ 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()) + } + if mc.ExtraBody == nil { + mc.ExtraBody = cfg.ModelList[idx].ExtraBody } - 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 +229,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") } } diff --git a/web/backend/main.go b/web/backend/main.go index b1db3c57a..8183731fe 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -33,6 +33,10 @@ import ( const ( appName = "PicoClaw" + + logPath = "logs" + panicFile = "launcher_panic.log" + logFile = "launcher.log" ) var ( @@ -72,6 +76,14 @@ func main() { // Initialize logger picoHome := utils.GetPicoclawHome() + + f := filepath.Join(picoHome, logPath, panicFile) + panicFunc, err := logger.InitPanic(f) + if err != nil { + panic(fmt.Sprintf("error initializing panic log: %v", err)) + } + defer panicFunc() + // By default, detect terminal to decide console log behavior // If -console-logs flag is explicitly set, it overrides the detection enableConsole := *console @@ -79,11 +91,9 @@ func main() { // Disable console logging by setting level to Fatal (no output) logger.SetConsoleLevel(logger.FATAL) - logPath := filepath.Join(picoHome, "logs", "web.log") - if err := logger.EnableFileLogging(logPath); err != nil { - // FIXME: https://github.com/sipeed/picoclaw/issues/1734 - fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) - os.Exit(1) + f := filepath.Join(picoHome, logPath, logFile) + if err = logger.EnableFileLogging(f); err != nil { + panic(fmt.Sprintf("error enabling file logging: %v", err)) } defer logger.DisableFileLogging() } diff --git a/web/frontend/package.json b/web/frontend/package.json index b1cc09b7b..8053d1f2a 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -31,6 +31,8 @@ "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "shadcn": "^4.1.0", "sonner": "^2.0.7", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index f893abda9..edaf49ccc 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -62,6 +62,12 @@ importers: react-textarea-autosize: specifier: ^8.5.9 version: 8.5.9(@types/react@19.2.14)(react@19.2.4) + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -2155,6 +2161,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2467,12 +2477,30 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} @@ -2492,6 +2520,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3141,6 +3172,9 @@ packages: parse-statements@1.0.11: resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3390,6 +3424,12 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3812,6 +3852,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -3862,6 +3905,9 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -5945,6 +5991,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -6318,6 +6366,43 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -6338,10 +6423,28 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + headers-polyfill@4.0.3: {} hermes-estree@0.25.1: {} @@ -6358,6 +6461,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -7135,6 +7240,10 @@ snapshots: parse-statements@1.0.11: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -7369,6 +7478,17 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -7860,6 +7980,11 @@ snapshots: vary@1.1.2: {} + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -7887,6 +8012,8 @@ snapshots: void-elements@3.1.0: {} + web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} webpack-virtual-modules@0.6.2: {} diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 8e49b48b4..2fd042593 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -17,6 +17,7 @@ export interface ModelInfo { max_tokens_field?: string request_timeout?: number thinking_level?: string + extra_body?: Record // Meta configured: boolean is_default: boolean diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 150f2f87d..05da3ceb1 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -1,6 +1,8 @@ import { IconCheck, IconCopy } from "@tabler/icons-react" import { useState } from "react" import ReactMarkdown from "react-markdown" +import rehypeRaw from "rehype-raw" +import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" import { Button } from "@/components/ui/button" @@ -42,7 +44,12 @@ export function AssistantMessage({
- {content} + + {content} +