Merge branch 'main' into feat/team
This commit is contained in:
commit
4a714a7534
138 changed files with 10094 additions and 2706 deletions
|
|
@ -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.
|
||||
|
||||
<details>
|
||||
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:")
|
||||
|
|
|
|||
|
|
@ -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 <skill> <message>` forces a skill for a single request.
|
||||
- `/use <skill>` 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 <skill> <message>`:只在本次请求中强制使用指定技能。
|
||||
- `/use <skill>`:为同一聊天中的下一条消息预先启用该技能。
|
||||
- `/use clear`:清除待应用的技能覆盖。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
/list skills
|
||||
/use git explain how to squash the last 3 commits
|
||||
/use italiapersonalfinance
|
||||
dammi le ultime news
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 <skill> <message>`
|
||||
- `/use <skill>` 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.
|
||||
|
||||
|
|
|
|||
230
docs/config-versioning.md
Normal file
230
docs/config-versioning.md
Normal file
|
|
@ -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
|
||||
|
||||
|
|
@ -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 <skill> <message>` forces a specific skill for a single request.
|
||||
- `/use <skill>` arms that skill for your next message in the same chat session.
|
||||
- `/use clear` cancels a pending skill override created by `/use <skill>`.
|
||||
|
||||
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 |
|
||||
|
|
|
|||
107
docs/sensitive_data_filtering.md
Normal file
107
docs/sensitive_data_filtering.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <skill> <message>`
|
||||
- `/use <skill>`,然后在下一条消息里发送真正的请求
|
||||
- `/use clear`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="discord"></a>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,24 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
|||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||
```
|
||||
|
||||
### 在聊天频道中使用技能
|
||||
|
||||
技能安装完成后,可以直接在聊天频道里查看并显式启用它们:
|
||||
|
||||
- `/list skills`:显示当前 Agent 可用的已安装技能名称。
|
||||
- `/use <skill> <message>`:只对当前这一条请求强制使用指定技能。
|
||||
- `/use <skill>`:为同一会话中的下一条消息预先启用该技能。
|
||||
- `/use clear`:取消通过 `/use <skill>` 设置的待应用技能。
|
||||
|
||||
示例:
|
||||
|
||||
```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 协调、并发控制、生命周期管理 |
|
||||
|
|
|
|||
107
docs/zh/sensitive_data_filtering.md
Normal file
107
docs/zh/sensitive_data_filtering.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -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 工具用于网页搜索和抓取。
|
||||
|
|
|
|||
20
go.mod
20
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
|
||||
)
|
||||
|
|
|
|||
32
go.sum
32
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=
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func newHookTestLoop(
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <skill> [message]"
|
||||
}
|
||||
|
||||
names := agent.ContextBuilder.ListSkillNames()
|
||||
if len(names) == 0 {
|
||||
return "Usage: /use <skill> [message]\nNo installed skills found."
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Usage: /use <skill> [message]\n\nInstalled Skills:\n- %s\n\nUse /use <skill> to apply a skill to your next message, or /use <skill> <message> 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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.<value>" (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.<value>" 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 := "<xml><Content>Hello</Content></xml>"
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 := "<xml><Content>Hello</Content></xml>"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ func BuiltinDefinitions() []Definition {
|
|||
helpCommand(),
|
||||
showCommand(),
|
||||
listCommand(),
|
||||
useCommand(),
|
||||
switchCommand(),
|
||||
checkCommand(),
|
||||
clearCommand(),
|
||||
|
|
|
|||
|
|
@ -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 <skill> <message>") {
|
||||
if !strings.Contains(reply, "/use <skill> [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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <skill> <message> to force one for a single request, or /use <skill> to apply it to your next message.",
|
||||
strings.Join(names, "\n- "),
|
||||
))
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
pkg/commands/cmd_use.go
Normal file
9
pkg/commands/cmd_use.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package commands
|
||||
|
||||
func useCommand() Definition {
|
||||
return Definition{
|
||||
Name: "use",
|
||||
Description: "Force a specific installed skill for one request",
|
||||
Usage: "/use <skill> [message]",
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
551
pkg/config/SECURITY_CONFIG.md
Normal file
551
pkg/config/SECURITY_CONFIG.md
Normal file
|
|
@ -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.<model_name>.api_key`
|
||||
|
||||
Example: `ref:model_list.gpt-5.4.api_key`
|
||||
|
||||
### Channel Tokens/Secrets
|
||||
|
||||
Format: `ref:channels.<channel_name>.<field>`
|
||||
|
||||
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.<provider>.<field>`
|
||||
|
||||
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.<registry>.<field>`
|
||||
|
||||
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_<SECTION>_<KEY1>_<KEY2>_<FIELD>` 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
|
||||
1475
pkg/config/config.go
1475
pkg/config/config.go
File diff suppressed because it is too large
Load diff
1032
pkg/config/config_old.go
Normal file
1032
pkg/config/config_old.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
423
pkg/config/example_security_usage.go
Normal file
423
pkg/config/example_security_usage.go
Normal file
|
|
@ -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.<model_name>.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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
568
pkg/config/migration_integration_test.go
Normal file
568
pkg/config/migration_integration_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
314
pkg/config/security.go
Normal file
314
pkg/config/security.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
472
pkg/config/security_integration_test.go
Normal file
472
pkg/config/security_integration_test.go
Normal file
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
90
pkg/config/security_test.go
Normal file
90
pkg/config/security_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
12
pkg/env.go
Normal file
12
pkg/env.go
Normal file
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
36
pkg/logger/panic.go
Normal file
36
pkg/logger/panic.go
Normal file
|
|
@ -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
|
||||
}
|
||||
22
pkg/logger/panic_unix.go
Normal file
22
pkg/logger/panic_unix.go
Normal file
|
|
@ -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
|
||||
}
|
||||
25
pkg/logger/panic_win.go
Normal file
25
pkg/logger/panic_win.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
582
pkg/providers/bedrock/provider_bedrock.go
Normal file
582
pkg/providers/bedrock/provider_bedrock.go
Normal file
|
|
@ -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,<data>
|
||||
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
|
||||
}
|
||||
541
pkg/providers/bedrock/provider_bedrock_test.go
Normal file
541
pkg/providers/bedrock/provider_bedrock_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
73
pkg/providers/bedrock/provider_stub.go
Normal file
73
pkg/providers/bedrock/provider_stub.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
35
pkg/providers/bedrock/provider_stub_test.go
Normal file
35
pkg/providers/bedrock/provider_stub_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue