mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-08-01 01:26:19 +00:00
Merge pull request #1352 from cytown/version
refactor Config to add Version and migratable
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,8 +3,8 @@ module github.com/sipeed/picoclaw
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+23
-9
@@ -161,30 +161,33 @@ func registerSharedTools(
|
||||
|
||||
if cfg.Tools.IsToolEnabled("web") {
|
||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
|
||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||
TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
|
||||
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey(), cfg.Tools.Web.Brave.APIKeys()),
|
||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||
TavilyAPIKeys: config.MergeAPIKeys(
|
||||
cfg.Tools.Web.Tavily.APIKey(),
|
||||
cfg.Tools.Web.Tavily.APIKeys(),
|
||||
),
|
||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||
PerplexityAPIKeys: config.MergeAPIKeys(
|
||||
cfg.Tools.Web.Perplexity.APIKey,
|
||||
cfg.Tools.Web.Perplexity.APIKeys,
|
||||
cfg.Tools.Web.Perplexity.APIKey(),
|
||||
cfg.Tools.Web.Perplexity.APIKeys(),
|
||||
),
|
||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
||||
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
||||
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
||||
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey(),
|
||||
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
||||
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
||||
BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey,
|
||||
BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey(),
|
||||
BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
|
||||
BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
|
||||
BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled,
|
||||
@@ -250,9 +253,20 @@ func registerSharedTools(
|
||||
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
||||
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: clawHubConfig.Enabled,
|
||||
BaseURL: clawHubConfig.BaseURL,
|
||||
AuthToken: clawHubConfig.AuthToken(),
|
||||
SearchPath: clawHubConfig.SearchPath,
|
||||
SkillsPath: clawHubConfig.SkillsPath,
|
||||
DownloadPath: clawHubConfig.DownloadPath,
|
||||
Timeout: clawHubConfig.Timeout,
|
||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||
},
|
||||
})
|
||||
|
||||
if find_skills_enable {
|
||||
|
||||
+50
-28
@@ -67,7 +67,7 @@ func newTestAgentLoop(
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -90,7 +90,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -179,7 +179,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -215,7 +215,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -272,7 +272,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -308,7 +308,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Workspace = tmpDir
|
||||
cfg.Agents.Defaults.Model = "test-model"
|
||||
cfg.Agents.Defaults.ModelName = "test-model"
|
||||
cfg.Agents.Defaults.MaxTokens = 4096
|
||||
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||
|
||||
@@ -352,7 +352,7 @@ func TestAgentLoop_Stop(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -558,7 +558,7 @@ func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -614,7 +614,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -694,26 +694,34 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Provider: "openai",
|
||||
Model: "local",
|
||||
ModelName: "local",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "local",
|
||||
Model: "openai/local-model",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://local.example.invalid/v1",
|
||||
},
|
||||
{
|
||||
ModelName: "deepseek",
|
||||
Model: "openrouter/deepseek/deepseek-v3.2",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"local": {
|
||||
APIKeys: []string{"test-key"},
|
||||
},
|
||||
"deepseek": {
|
||||
APIKeys: []string{"test-key"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &countingMockProvider{response: "LLM reply"}
|
||||
@@ -765,20 +773,26 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Provider: "openai",
|
||||
Model: "local",
|
||||
ModelName: "local",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "local",
|
||||
Model: "openai/local-model",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://local.example.invalid/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"local": {
|
||||
APIKeys: []string{"test-key"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &countingMockProvider{response: "LLM reply"}
|
||||
@@ -840,26 +854,34 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Provider: "openai",
|
||||
Model: "local",
|
||||
ModelName: "local",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "local",
|
||||
Model: "openai/Qwen3.5-35B-A3B",
|
||||
APIKey: "local-key",
|
||||
APIBase: localServer.URL,
|
||||
},
|
||||
{
|
||||
ModelName: "deepseek",
|
||||
Model: "openrouter/deepseek/deepseek-v3.2",
|
||||
APIKey: "remote-key",
|
||||
APIBase: remoteServer.URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"local": {
|
||||
APIKeys: []string{"local-key"},
|
||||
},
|
||||
"deepseek": {
|
||||
APIKeys: []string{"remote-key"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider, _, err := providers.CreateProvider(cfg)
|
||||
@@ -946,7 +968,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -988,7 +1010,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -1059,7 +1081,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -1139,7 +1161,7 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 3,
|
||||
},
|
||||
@@ -1170,7 +1192,7 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 1,
|
||||
},
|
||||
@@ -1227,7 +1249,7 @@ func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -1279,7 +1301,7 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -1349,7 +1371,7 @@ func TestHandleReasoning(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+11
-11
@@ -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,
|
||||
},
|
||||
|
||||
+11
-11
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+10
-11
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+11
-11
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+1100
-315
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+117
-121
@@ -8,6 +8,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
)
|
||||
|
||||
@@ -78,18 +81,19 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProvidersConfig_IsEmpty(t *testing.T) {
|
||||
var empty ProvidersConfig
|
||||
var empty providersConfigV0
|
||||
t.Logf("empty: %+v", empty)
|
||||
if !empty.IsEmpty() {
|
||||
t.Fatal("empty ProvidersConfig should report empty")
|
||||
t.Fatal("empty providersConfig should report empty")
|
||||
}
|
||||
|
||||
novita := ProvidersConfig{
|
||||
Novita: ProviderConfig{
|
||||
novita := providersConfigV0{
|
||||
Novita: providerConfigV0{
|
||||
APIKey: "test-key",
|
||||
},
|
||||
}
|
||||
if novita.IsEmpty() {
|
||||
t.Fatal("ProvidersConfig with novita settings should not report empty")
|
||||
t.Fatal("providersConfig with novita settings should not report empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,15 +241,6 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Model verifies model is set
|
||||
func TestDefaultConfig_Model(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.Model != "" {
|
||||
t.Error("Model should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_MaxTokens verifies max tokens has default value
|
||||
func TestDefaultConfig_MaxTokens(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
@@ -288,21 +283,6 @@ func TestDefaultConfig_Gateway(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Providers verifies provider structure
|
||||
func TestDefaultConfig_Providers(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Providers.Anthropic.APIKey != "" {
|
||||
t.Error("Anthropic API key should be empty by default")
|
||||
}
|
||||
if cfg.Providers.OpenAI.APIKey != "" {
|
||||
t.Error("OpenAI API key should be empty by default")
|
||||
}
|
||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
t.Error("OpenRouter API key should be empty by default")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Channels verifies channels are disabled by default
|
||||
func TestDefaultConfig_Channels(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
@@ -329,7 +309,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
|
||||
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
||||
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
||||
}
|
||||
if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
|
||||
if len(cfg.Tools.Web.Brave.APIKeys()) != 0 {
|
||||
t.Error("Brave API key should be empty by default")
|
||||
}
|
||||
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
||||
@@ -387,9 +367,6 @@ func TestConfig_Complete(t *testing.T) {
|
||||
if cfg.Agents.Defaults.Workspace == "" {
|
||||
t.Error("Workspace should not be empty")
|
||||
}
|
||||
if cfg.Agents.Defaults.Model != "" {
|
||||
t.Error("Model should be empty")
|
||||
}
|
||||
if cfg.Agents.Defaults.Temperature != nil {
|
||||
t.Error("Temperature should be nil when not provided")
|
||||
}
|
||||
@@ -408,12 +385,8 @@ func TestConfig_Complete(t *testing.T) {
|
||||
if !cfg.Heartbeat.Enabled {
|
||||
t.Error("Heartbeat should be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Providers.OpenAI.WebSearch {
|
||||
t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true")
|
||||
if !cfg.Tools.Exec.AllowRemote {
|
||||
t.Error("Exec.AllowRemote should be true by default")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +400,7 @@ func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) {
|
||||
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"enabled":true}}}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"web":{"enabled":true}}}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
@@ -493,26 +466,11 @@ func TestDefaultConfig_LogLevel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if !cfg.Providers.OpenAI.WebSearch {
|
||||
t.Fatal("OpenAI codex web search should remain true when unset in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"exec":{"enable_deny_patterns":true}}}`),
|
||||
0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
@@ -528,7 +486,11 @@ func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
|
||||
func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"tools":{"cron":{"exec_timeout_minutes":5}}}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(
|
||||
configPath,
|
||||
[]byte(`{"version":1,"tools":{"cron":{"exec_timeout_minutes":5}}}`),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
@@ -541,22 +503,6 @@ func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if cfg.Providers.OpenAI.WebSearch {
|
||||
t.Fatal("OpenAI codex web search should be false when disabled in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
@@ -582,6 +528,7 @@ func TestLoadConfig_HooksProcessConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
configJSON := `{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"processes": {
|
||||
"review-gate": {
|
||||
@@ -834,7 +781,20 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
|
||||
func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
const original = `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
|
||||
const original = `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
|
||||
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
secPath := filepath.Join(dir, SecurityConfigFile)
|
||||
const securityConfig = `
|
||||
model_list:
|
||||
test:0:
|
||||
api_keys:
|
||||
- "sk-plaintext"
|
||||
`
|
||||
if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
@@ -847,10 +807,10 @@ func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
// In-memory value must be the resolved plaintext.
|
||||
if cfg.ModelList[0].APIKey != "sk-plaintext" {
|
||||
t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext")
|
||||
if cfg.ModelList[0].APIKey() != "sk-plaintext" {
|
||||
t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey(), "sk-plaintext")
|
||||
}
|
||||
// The file on disk must remain unchanged — LoadConfig must not write anything.
|
||||
// The file on disk must remain unchanged — no need upgrade version
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
if string(raw) != original {
|
||||
t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw))
|
||||
@@ -867,15 +827,19 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
|
||||
mustSetupSSHKey(t)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.ModelList = []ModelConfig{
|
||||
{ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"},
|
||||
cfg.ModelList = []*ModelConfig{
|
||||
{ModelName: "test", Model: "openai/gpt-4", apiKeys: []string{"sk-plaintext"}},
|
||||
}
|
||||
cfg.security = &SecurityConfig{
|
||||
ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"sk-plaintext"}}},
|
||||
}
|
||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
// Disk must contain enc://, not the raw key.
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
secPath := filepath.Join(dir, SecurityConfigFile)
|
||||
raw, _ := os.ReadFile(secPath)
|
||||
if !strings.Contains(string(raw), "enc://") {
|
||||
t.Errorf("saved file should contain enc://, got:\n%s", string(raw))
|
||||
}
|
||||
@@ -888,8 +852,8 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after SaveConfig: %v", err)
|
||||
}
|
||||
if cfg2.ModelList[0].APIKey != "sk-plaintext" {
|
||||
t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext")
|
||||
if cfg2.ModelList[0].APIKey() != "sk-plaintext" {
|
||||
t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey(), "sk-plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,10 +889,17 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) {
|
||||
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}`
|
||||
data := `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4"}]}`
|
||||
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
secPath := filepath.Join(dir, SecurityConfigFile)
|
||||
if err := saveSecurityConfig(
|
||||
secPath,
|
||||
&SecurityConfig{ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"file://openai.key"}}}},
|
||||
); err != nil {
|
||||
t.Fatalf("saveSecurityConfig: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||
@@ -937,7 +908,7 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
raw, _ := os.ReadFile(secPath)
|
||||
if !strings.Contains(string(raw), "file://openai.key") {
|
||||
t.Error("file:// reference should be preserved unchanged in the config file")
|
||||
}
|
||||
@@ -957,23 +928,28 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
|
||||
|
||||
// Pre-encrypt one key so we have a genuine enc:// value to put in the config.
|
||||
if err := SaveConfig(cfgPath, &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"},
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "pre", Model: "openai/gpt-4"},
|
||||
},
|
||||
security: &SecurityConfig{
|
||||
ModelList: map[string]ModelSecurityEntry{
|
||||
"pre:0": {APIKeys: []string{"sk-already-plain"}},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("setup SaveConfig: %v", err)
|
||||
}
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
||||
// Extract the enc:// value from the saved file.
|
||||
var tmp struct {
|
||||
ModelList []struct {
|
||||
APIKey string `json:"api_key"`
|
||||
} `json:"model_list"`
|
||||
ModelList map[string]struct {
|
||||
APIKeys []string `yaml:"api_keys"`
|
||||
} `yaml:"model_list"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {
|
||||
if err := yaml.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {
|
||||
t.Fatalf("setup: could not parse saved config: %v", err)
|
||||
}
|
||||
alreadyEncrypted := tmp.ModelList[0].APIKey
|
||||
alreadyEncrypted := tmp.ModelList["pre:0"].APIKeys[0]
|
||||
if !strings.HasPrefix(alreadyEncrypted, "enc://") {
|
||||
t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted)
|
||||
}
|
||||
@@ -987,19 +963,28 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
cfg := &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"},
|
||||
{ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted},
|
||||
{ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"},
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "plain", Model: "openai/gpt-4", apiKeys: []string{"sk-new-plaintext"}},
|
||||
{ModelName: "enc", Model: "openai/gpt-4", apiKeys: []string{alreadyEncrypted}},
|
||||
{ModelName: "file", Model: "openai/gpt-4", apiKeys: []string{"file://api.key"}},
|
||||
},
|
||||
security: &SecurityConfig{
|
||||
ModelList: map[string]ModelSecurityEntry{
|
||||
"plain:0": {APIKeys: []string{"sk-new-plaintext"}},
|
||||
"enc:0": {APIKeys: []string{alreadyEncrypted}},
|
||||
"file:0": {APIKeys: []string{"file://api.key"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
raw, _ = os.ReadFile(cfgPath)
|
||||
raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
||||
s := string(raw)
|
||||
|
||||
t.Logf("saved file:\n%s", s)
|
||||
|
||||
// 1. Plaintext must be encrypted.
|
||||
if strings.Contains(s, "sk-new-plaintext") {
|
||||
t.Error("plaintext key must not appear in saved file")
|
||||
@@ -1020,7 +1005,7 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
|
||||
}
|
||||
byName := make(map[string]string)
|
||||
for _, m := range cfg2.ModelList {
|
||||
byName[m.ModelName] = m.APIKey
|
||||
byName[m.ModelName] = m.APIKey()
|
||||
}
|
||||
if byName["plain"] != "sk-new-plaintext" {
|
||||
t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext")
|
||||
@@ -1044,26 +1029,26 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||
mustSetupSSHKey(t)
|
||||
if err := SaveConfig(cfgPath, &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"},
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "m", Model: "openai/gpt-4", apiKeys: []string{"sk-secret"}},
|
||||
},
|
||||
security: &SecurityConfig{
|
||||
ModelList: map[string]ModelSecurityEntry{
|
||||
"m:0": {APIKeys: []string{"sk-secret"}},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("setup SaveConfig: %v", err)
|
||||
}
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
var tmp struct {
|
||||
ModelList []struct {
|
||||
APIKey string `json:"api_key"`
|
||||
} `json:"model_list"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tmp); err != nil {
|
||||
t.Fatalf("setup parse: %v", err)
|
||||
}
|
||||
encValue := tmp.ModelList[0].APIKey
|
||||
raw, err := LoadConfig(cfgPath)
|
||||
assert.NoError(t, err)
|
||||
encValue := raw.security.ModelList["m:0"].APIKeys[0]
|
||||
assert.NotEmpty(t, encValue)
|
||||
assert.Equal(t, "enc://", encValue[:6])
|
||||
|
||||
// Write a mixed config: enc:// + plaintext + file://
|
||||
keyFile := filepath.Join(dir, "api.key")
|
||||
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
||||
if err = os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
mixed, _ := json.Marshal(map[string]any{
|
||||
@@ -1073,14 +1058,24 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
|
||||
{"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"},
|
||||
},
|
||||
})
|
||||
if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil {
|
||||
if err = os.WriteFile(cfgPath, mixed, 0o600); err != nil {
|
||||
t.Fatalf("setup write: %v", err)
|
||||
}
|
||||
secs, _ := yaml.Marshal(map[string]any{
|
||||
"model_list": map[string]map[string]any{
|
||||
"enc:0": {"api_keys": []string{encValue}},
|
||||
"plain:0": {"api_keys": []string{"sk-plain"}},
|
||||
"file:0": {"api_keys": []string{"file://api.key"}},
|
||||
},
|
||||
})
|
||||
if err = os.WriteFile(filepath.Join(dir, SecurityConfigFile), secs, 0o600); err != nil {
|
||||
t.Fatalf("security write: %v", err)
|
||||
}
|
||||
|
||||
// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||
|
||||
_, err := LoadConfig(cfgPath)
|
||||
_, err = LoadConfig(cfgPath)
|
||||
if err == nil {
|
||||
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
|
||||
}
|
||||
@@ -1108,14 +1103,15 @@ func TestSaveConfig_UsesPassphraseProvider(t *testing.T) {
|
||||
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.ModelList = []ModelConfig{
|
||||
{ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"},
|
||||
cfg.ModelList = []*ModelConfig{
|
||||
{ModelName: "test", Model: "openai/gpt-4"},
|
||||
}
|
||||
cfg.security.ModelList["test:0"] = ModelSecurityEntry{APIKeys: []string{"sk-plaintext"}}
|
||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
||||
if !strings.Contains(string(raw), "enc://") {
|
||||
t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw)
|
||||
}
|
||||
@@ -1158,15 +1154,15 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if cfg.ModelList[0].APIKey != plainKey {
|
||||
t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey)
|
||||
if cfg.ModelList[0].APIKey() != plainKey {
|
||||
t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey(), plainKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigParsesLogLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
data := `{"gateway":{"log_level":"debug"}}`
|
||||
data := `{"version":1,"gateway":{"log_level":"debug"}}`
|
||||
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
@@ -1183,7 +1179,7 @@ func TestConfigParsesLogLevel(t *testing.T) {
|
||||
func TestConfigLogLevelEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
data := `{}`
|
||||
data := `{"version":1}`
|
||||
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
+42
-94
@@ -8,6 +8,8 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg"
|
||||
)
|
||||
|
||||
// DefaultConfig returns the default configuration for PicoClaw.
|
||||
@@ -19,17 +21,17 @@ func DefaultConfig() *Config {
|
||||
homePath = picoclawHome
|
||||
} else {
|
||||
userHome, _ := os.UserHomeDir()
|
||||
homePath = filepath.Join(userHome, ".picoclaw")
|
||||
homePath = filepath.Join(userHome, pkg.DefaultPicoClawHome)
|
||||
}
|
||||
workspacePath := filepath.Join(homePath, "workspace")
|
||||
workspacePath := filepath.Join(homePath, pkg.WorkspaceName)
|
||||
|
||||
return &Config{
|
||||
Version: CurrentVersion,
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Workspace: workspacePath,
|
||||
RestrictToWorkspace: true,
|
||||
Provider: "",
|
||||
Model: "",
|
||||
MaxTokens: 32768,
|
||||
Temperature: nil, // nil means use provider default
|
||||
MaxToolIterations: 50,
|
||||
@@ -56,7 +58,6 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
Telegram: TelegramConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
Typing: TypingConfig{Enabled: true},
|
||||
Placeholder: PlaceholderConfig{
|
||||
@@ -67,16 +68,12 @@ func DefaultConfig() *Config {
|
||||
UseMarkdownV2: false,
|
||||
},
|
||||
Feishu: FeishuConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AppSecret: "",
|
||||
EncryptKey: "",
|
||||
VerificationToken: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Discord: DiscordConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
MentionOnly: false,
|
||||
},
|
||||
@@ -89,28 +86,23 @@ func DefaultConfig() *Config {
|
||||
QQ: QQConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AppSecret: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
MaxMessageLength: 2000,
|
||||
MaxBase64FileSizeMiB: 0,
|
||||
},
|
||||
DingTalk: DingTalkConfig{
|
||||
Enabled: false,
|
||||
ClientID: "",
|
||||
ClientSecret: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
Enabled: false,
|
||||
ClientID: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Slack: SlackConfig{
|
||||
Enabled: false,
|
||||
BotToken: "",
|
||||
AppToken: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Matrix: MatrixConfig{
|
||||
Enabled: false,
|
||||
Homeserver: "https://matrix.org",
|
||||
UserID: "",
|
||||
AccessToken: "",
|
||||
DeviceID: "",
|
||||
JoinOnInvite: true,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
@@ -123,51 +115,40 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
},
|
||||
LINE: LINEConfig{
|
||||
Enabled: false,
|
||||
ChannelSecret: "",
|
||||
ChannelAccessToken: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18791,
|
||||
WebhookPath: "/webhook/line",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
|
||||
Enabled: false,
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18791,
|
||||
WebhookPath: "/webhook/line",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
|
||||
},
|
||||
OneBot: OneBotConfig{
|
||||
Enabled: false,
|
||||
WSUrl: "ws://127.0.0.1:3001",
|
||||
AccessToken: "",
|
||||
ReconnectInterval: 5,
|
||||
GroupTriggerPrefix: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
Enabled: false,
|
||||
WSUrl: "ws://127.0.0.1:3001",
|
||||
ReconnectInterval: 5,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
WeCom: WeComConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
EncodingAESKey: "",
|
||||
WebhookURL: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18793,
|
||||
WebhookPath: "/webhook/wecom",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
Enabled: false,
|
||||
WebhookURL: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18793,
|
||||
WebhookPath: "/webhook/wecom",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
},
|
||||
WeComApp: WeComAppConfig{
|
||||
Enabled: false,
|
||||
CorpID: "",
|
||||
CorpSecret: "",
|
||||
AgentID: 0,
|
||||
Token: "",
|
||||
EncodingAESKey: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18792,
|
||||
WebhookPath: "/webhook/wecom-app",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
Enabled: false,
|
||||
CorpID: "",
|
||||
AgentID: 0,
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18792,
|
||||
WebhookPath: "/webhook/wecom-app",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
},
|
||||
WeComAIBot: WeComAIBotConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
EncodingAESKey: "",
|
||||
WebhookPath: "/webhook/wecom-aibot",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
@@ -177,7 +158,6 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
Weixin: WeixinConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
BaseURL: "https://ilinkai.weixin.qq.com/",
|
||||
CDNBaseURL: "https://novac2c.cdn.weixin.qq.com/c2c",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
@@ -185,7 +165,6 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
Pico: PicoConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
PingInterval: 30,
|
||||
ReadTimeout: 60,
|
||||
WriteTimeout: 10,
|
||||
@@ -201,10 +180,7 @@ func DefaultConfig() *Config {
|
||||
ApprovalTimeoutMS: 60000,
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||
},
|
||||
ModelList: []ModelConfig{
|
||||
ModelList: []*ModelConfig{
|
||||
// ============================================
|
||||
// Add your API key to the model you want to use
|
||||
// ============================================
|
||||
@@ -214,7 +190,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "glm-4.7",
|
||||
Model: "zhipu/glm-4.7",
|
||||
APIBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// OpenAI - https://platform.openai.com/api-keys
|
||||
@@ -222,7 +197,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
APIBase: "https://api.openai.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Anthropic Claude - https://console.anthropic.com/settings/keys
|
||||
@@ -230,7 +204,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
APIBase: "https://api.anthropic.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// DeepSeek - https://platform.deepseek.com/
|
||||
@@ -238,7 +211,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "deepseek-chat",
|
||||
Model: "deepseek/deepseek-chat",
|
||||
APIBase: "https://api.deepseek.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Google Gemini - https://ai.google.dev/
|
||||
@@ -246,7 +218,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "gemini-2.0-flash",
|
||||
Model: "gemini/gemini-2.0-flash-exp",
|
||||
APIBase: "https://generativelanguage.googleapis.com/v1beta",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
|
||||
@@ -254,7 +225,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "qwen-plus",
|
||||
Model: "qwen/qwen-plus",
|
||||
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
|
||||
@@ -262,7 +232,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "moonshot-v1-8k",
|
||||
Model: "moonshot/moonshot-v1-8k",
|
||||
APIBase: "https://api.moonshot.cn/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Groq - https://console.groq.com/keys
|
||||
@@ -270,7 +239,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "llama-3.3-70b",
|
||||
Model: "groq/llama-3.3-70b-versatile",
|
||||
APIBase: "https://api.groq.com/openai/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// OpenRouter (100+ models) - https://openrouter.ai/keys
|
||||
@@ -278,13 +246,11 @@ func DefaultConfig() *Config {
|
||||
ModelName: "openrouter-auto",
|
||||
Model: "openrouter/auto",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
{
|
||||
ModelName: "openrouter-gpt-5.4",
|
||||
Model: "openrouter/openai/gpt-5.4",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// NVIDIA - https://build.nvidia.com/
|
||||
@@ -292,7 +258,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "nemotron-4-340b",
|
||||
Model: "nvidia/nemotron-4-340b-instruct",
|
||||
APIBase: "https://integrate.api.nvidia.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Cerebras - https://inference.cerebras.ai/
|
||||
@@ -300,7 +265,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "cerebras-llama-3.3-70b",
|
||||
Model: "cerebras/llama-3.3-70b",
|
||||
APIBase: "https://api.cerebras.ai/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Vivgrid - https://vivgrid.com
|
||||
@@ -308,7 +272,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "vivgrid-auto",
|
||||
Model: "vivgrid/auto",
|
||||
APIBase: "https://api.vivgrid.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
||||
@@ -316,13 +279,11 @@ func DefaultConfig() *Config {
|
||||
ModelName: "ark-code-latest",
|
||||
Model: "volcengine/ark-code-latest",
|
||||
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
APIKey: "",
|
||||
},
|
||||
{
|
||||
ModelName: "doubao-pro",
|
||||
Model: "volcengine/doubao-pro-32k",
|
||||
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// ShengsuanYun (神算云)
|
||||
@@ -330,7 +291,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "deepseek-v3",
|
||||
Model: "shengsuanyun/deepseek-v3",
|
||||
APIBase: "https://api.shengsuanyun.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Antigravity (Google Cloud Code Assist) - OAuth only
|
||||
@@ -353,7 +313,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "llama3",
|
||||
Model: "ollama/llama3",
|
||||
APIBase: "http://localhost:11434/v1",
|
||||
APIKey: "ollama",
|
||||
},
|
||||
|
||||
// Mistral AI - https://console.mistral.ai/api-keys
|
||||
@@ -361,7 +320,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "mistral-small",
|
||||
Model: "mistral/mistral-small-latest",
|
||||
APIBase: "https://api.mistral.ai/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Avian - https://avian.io
|
||||
@@ -369,13 +327,11 @@ func DefaultConfig() *Config {
|
||||
ModelName: "deepseek-v3.2",
|
||||
Model: "avian/deepseek/deepseek-v3.2",
|
||||
APIBase: "https://api.avian.io/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
{
|
||||
ModelName: "kimi-k2.5",
|
||||
Model: "avian/moonshotai/kimi-k2.5",
|
||||
APIBase: "https://api.avian.io/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Minimax - https://api.minimaxi.com/
|
||||
@@ -383,7 +339,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "MiniMax-M2.5",
|
||||
Model: "minimax/MiniMax-M2.5",
|
||||
APIBase: "https://api.minimaxi.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// LongCat - https://longcat.chat/platform
|
||||
@@ -391,7 +346,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "LongCat-Flash-Thinking",
|
||||
Model: "longcat/LongCat-Flash-Thinking",
|
||||
APIBase: "https://api.longcat.chat/openai",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
|
||||
@@ -399,7 +353,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "modelscope-qwen",
|
||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
APIBase: "https://api-inference.modelscope.cn/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// VLLM (local) - http://localhost:8000
|
||||
@@ -407,7 +360,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "local-model",
|
||||
Model: "vllm/custom-model",
|
||||
APIBase: "http://localhost:8000/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Azure OpenAI - https://portal.azure.com
|
||||
@@ -416,7 +368,6 @@ func DefaultConfig() *Config {
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
APIBase: "https://your-resource.openai.azure.com",
|
||||
APIKey: "",
|
||||
},
|
||||
},
|
||||
Gateway: GatewayConfig{
|
||||
@@ -443,14 +394,10 @@ func DefaultConfig() *Config {
|
||||
Format: "plaintext",
|
||||
Brave: BraveConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
APIKeys: nil,
|
||||
MaxResults: 5,
|
||||
},
|
||||
Tavily: TavilyConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
APIKeys: nil,
|
||||
MaxResults: 5,
|
||||
},
|
||||
DuckDuckGo: DuckDuckGoConfig{
|
||||
@@ -459,8 +406,6 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
Perplexity: PerplexityConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
APIKeys: nil,
|
||||
MaxResults: 5,
|
||||
},
|
||||
SearXNG: SearXNGConfig{
|
||||
@@ -470,14 +415,12 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
GLMSearch: GLMSearchConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search",
|
||||
SearchEngine: "search_std",
|
||||
MaxResults: 5,
|
||||
},
|
||||
BaiduSearch: BaiduSearchConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
BaseURL: "https://qianfan.baidubce.com/v2/ai_search/web_search",
|
||||
MaxResults: 10,
|
||||
},
|
||||
@@ -591,5 +534,10 @@ func DefaultConfig() *Config {
|
||||
BuildTime: BuildTime,
|
||||
GoVersion: GoVersion,
|
||||
},
|
||||
security: &SecurityConfig{
|
||||
ModelList: map[string]ModelSecurityEntry{},
|
||||
Channels: ChannelsSecurity{},
|
||||
Web: WebToolsSecurity{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+141
-156
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+143
-280
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+56
-128
@@ -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"},
|
||||
|
||||
+49
-53
@@ -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"},
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/tencent-connect/botgo/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
)
|
||||
|
||||
const (
|
||||
SecurityConfigFile = ".security.yml"
|
||||
)
|
||||
|
||||
// SecurityConfig stores all sensitive data (API keys, tokens, secrets, passwords)
|
||||
// This data is loaded from security.yml and kept separate from the main config
|
||||
type SecurityConfig struct {
|
||||
// Model API keys. Map key is model_name, can include suffix like "abc:0", "abc:1"
|
||||
// for load balancing with same model_name. The suffix ":N" is used to distinguish
|
||||
// multiple configs that share the same base model_name.
|
||||
ModelList map[string]ModelSecurityEntry `yaml:"model_list,omitempty"`
|
||||
|
||||
// Channel tokens/secrets
|
||||
Channels ChannelsSecurity `yaml:"channels,omitempty"`
|
||||
|
||||
Web WebToolsSecurity `yaml:"web,omitempty"`
|
||||
Skills SkillsSecurity `yaml:"skills,omitempty"`
|
||||
}
|
||||
|
||||
// ModelSecurityEntry stores security data for a model
|
||||
type ModelSecurityEntry struct {
|
||||
APIKeys []string `yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
|
||||
}
|
||||
|
||||
// ChannelsSecurity stores channel-related security data
|
||||
type ChannelsSecurity struct {
|
||||
Telegram *TelegramSecurity `yaml:"telegram,omitempty"`
|
||||
Feishu *FeishuSecurity `yaml:"feishu,omitempty"`
|
||||
Discord *DiscordSecurity `yaml:"discord,omitempty"`
|
||||
Weixin *WeixinSecurity `yaml:"weixin,omitempty"`
|
||||
QQ *QQSecurity `yaml:"qq,omitempty"`
|
||||
DingTalk *DingTalkSecurity `yaml:"dingtalk,omitempty"`
|
||||
Slack *SlackSecurity `yaml:"slack,omitempty"`
|
||||
Matrix *MatrixSecurity `yaml:"matrix,omitempty"`
|
||||
LINE *LINESecurity `yaml:"line,omitempty"`
|
||||
OneBot *OneBotSecurity `yaml:"onebot,omitempty"`
|
||||
WeCom *WeComSecurity `yaml:"wecom,omitempty"`
|
||||
WeComApp *WeComAppSecurity `yaml:"wecom_app,omitempty"`
|
||||
WeComAIBot *WeComAIBotSecurity `yaml:"wecom_aibot,omitempty"`
|
||||
Pico *PicoSecurity `yaml:"pico,omitempty"`
|
||||
IRC *IRCSecurity `yaml:"irc,omitempty"`
|
||||
}
|
||||
|
||||
type TelegramSecurity struct {
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
}
|
||||
|
||||
type FeishuSecurity struct {
|
||||
AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
||||
EncryptKey string `yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||
VerificationToken string `yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||
}
|
||||
|
||||
type DiscordSecurity struct {
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||
}
|
||||
|
||||
type WeixinSecurity struct {
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
|
||||
}
|
||||
|
||||
type QQSecurity struct {
|
||||
AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||
}
|
||||
|
||||
type DingTalkSecurity struct {
|
||||
ClientSecret string `yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||
}
|
||||
|
||||
type SlackSecurity struct {
|
||||
BotToken string `yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||
AppToken string `yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||
}
|
||||
|
||||
type MatrixSecurity struct {
|
||||
AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
||||
}
|
||||
|
||||
type LINESecurity struct {
|
||||
ChannelSecret string `yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||
ChannelAccessToken string `yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||
}
|
||||
|
||||
type OneBotSecurity struct {
|
||||
AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||
}
|
||||
|
||||
type WeComSecurity struct {
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
|
||||
EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
|
||||
}
|
||||
|
||||
type WeComAppSecurity struct {
|
||||
CorpSecret string `yaml:"corp_secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
|
||||
EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
|
||||
}
|
||||
|
||||
type WeComAIBotSecurity struct {
|
||||
Secret string `yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
|
||||
EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
|
||||
}
|
||||
|
||||
type PicoSecurity struct {
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
||||
}
|
||||
|
||||
type IRCSecurity struct {
|
||||
Password string `yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
|
||||
NickServPassword string `yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
|
||||
SASLPassword string `yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
|
||||
}
|
||||
|
||||
type WebToolsSecurity struct {
|
||||
Brave *BraveSecurity `yaml:"brave,omitempty"`
|
||||
Tavily *TavilySecurity `yaml:"tavily,omitempty"`
|
||||
Perplexity *PerplexitySecurity `yaml:"perplexity,omitempty"`
|
||||
GLMSearch *GLMSearchSecurity `yaml:"glm_search,omitempty"`
|
||||
BaiduSearch *BaiduSearchSecurity `yaml:"baidu_search,omitempty"`
|
||||
}
|
||||
|
||||
type BraveSecurity struct {
|
||||
APIKeys []string `yaml:"api_keys,omitempty"`
|
||||
}
|
||||
|
||||
type TavilySecurity struct {
|
||||
APIKeys []string `yaml:"api_keys,omitempty"`
|
||||
}
|
||||
|
||||
type PerplexitySecurity struct {
|
||||
APIKeys []string `yaml:"api_keys,omitempty"`
|
||||
}
|
||||
|
||||
type GLMSearchSecurity struct {
|
||||
APIKey string `yaml:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
type BaiduSearchSecurity struct {
|
||||
APIKey string `yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"`
|
||||
}
|
||||
|
||||
type SkillsSecurity struct {
|
||||
Github *GithubSecurity `yaml:"github,omitempty"`
|
||||
ClawHub *ClawHubSecurity `yaml:"clawhub,omitempty"`
|
||||
}
|
||||
|
||||
type GithubSecurity struct {
|
||||
Token string `yaml:"token,omitempty"`
|
||||
}
|
||||
|
||||
type ClawHubSecurity struct {
|
||||
AuthToken string `yaml:"auth_token,omitempty"`
|
||||
}
|
||||
|
||||
// securityPath returns the path to security.yml relative to the config file
|
||||
func securityPath(configPath string) string {
|
||||
configDir := filepath.Dir(configPath)
|
||||
return filepath.Join(configDir, SecurityConfigFile)
|
||||
}
|
||||
|
||||
// loadSecurityConfig loads the security configuration from security.yml
|
||||
// Returns an empty SecurityConfig if the file doesn't exist
|
||||
func loadSecurityConfig(securityPath string) (*SecurityConfig, error) {
|
||||
data, err := os.ReadFile(securityPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &SecurityConfig{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read security config: %w", err)
|
||||
}
|
||||
|
||||
var sec SecurityConfig
|
||||
if err := yaml.Unmarshal(data, &sec); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse security config: %w", err)
|
||||
}
|
||||
|
||||
// No need to validate model_name format here - both formats are supported:
|
||||
// - "model-name:0" (with index for multiple entries)
|
||||
// - "model-name" (without index for single entry or default to index 0)
|
||||
|
||||
if err := env.Parse(&sec); err != nil {
|
||||
log.Errorf("failed to parse environment variables: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &sec, nil
|
||||
}
|
||||
|
||||
// saveSecurityConfig saves the security configuration to security.yml
|
||||
func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
|
||||
var buf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
enc.SetIndent(2)
|
||||
err := enc.Encode(sec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal security config: %w", err)
|
||||
}
|
||||
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
@@ -381,9 +381,6 @@ func handleConfigReload(
|
||||
logger.Info("🔄 Config file changed, reloading...")
|
||||
|
||||
newModel := newCfg.Agents.Defaults.ModelName
|
||||
if newModel == "" {
|
||||
newModel = newCfg.Agents.Defaults.Model
|
||||
}
|
||||
|
||||
logger.Infof(" New model is '%s', recreating provider...", newModel)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,400 +1,7 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const defaultAnthropicAPIBase = "https://api.anthropic.com/v1"
|
||||
|
||||
var getCredential = auth.GetCredential
|
||||
|
||||
type providerType int
|
||||
|
||||
const (
|
||||
providerTypeHTTPCompat providerType = iota
|
||||
providerTypeClaudeAuth
|
||||
providerTypeCodexAuth
|
||||
providerTypeCodexCLIToken
|
||||
providerTypeClaudeCLI
|
||||
providerTypeCodexCLI
|
||||
providerTypeGitHubCopilot
|
||||
)
|
||||
|
||||
type providerSelection struct {
|
||||
providerType providerType
|
||||
apiKey string
|
||||
apiBase string
|
||||
proxy string
|
||||
model string
|
||||
workspace string
|
||||
connectMode string
|
||||
enableWebSearch bool
|
||||
}
|
||||
|
||||
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||
model := cfg.Agents.Defaults.GetModelName()
|
||||
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||
lowerModel := strings.ToLower(model)
|
||||
|
||||
if providerName == "" && model == "" {
|
||||
return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty")
|
||||
}
|
||||
|
||||
sel := providerSelection{
|
||||
providerType: providerTypeHTTPCompat,
|
||||
model: model,
|
||||
}
|
||||
|
||||
// First, prefer explicit provider configuration.
|
||||
if providerName != "" {
|
||||
switch providerName {
|
||||
case "groq":
|
||||
if cfg.Providers.Groq.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.Groq.APIKey
|
||||
sel.apiBase = cfg.Providers.Groq.APIBase
|
||||
sel.proxy = cfg.Providers.Groq.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.groq.com/openai/v1"
|
||||
}
|
||||
}
|
||||
case "openai", "gpt":
|
||||
if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
|
||||
sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
|
||||
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
|
||||
sel.providerType = providerTypeCodexCLIToken
|
||||
return sel, nil
|
||||
}
|
||||
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
||||
sel.providerType = providerTypeCodexAuth
|
||||
return sel, nil
|
||||
}
|
||||
sel.apiKey = cfg.Providers.OpenAI.APIKey
|
||||
sel.apiBase = cfg.Providers.OpenAI.APIBase
|
||||
sel.proxy = cfg.Providers.OpenAI.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
case "anthropic", "claude":
|
||||
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
|
||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
||||
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = defaultAnthropicAPIBase
|
||||
}
|
||||
sel.providerType = providerTypeClaudeAuth
|
||||
return sel, nil
|
||||
}
|
||||
sel.apiKey = cfg.Providers.Anthropic.APIKey
|
||||
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||
sel.proxy = cfg.Providers.Anthropic.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = defaultAnthropicAPIBase
|
||||
}
|
||||
}
|
||||
case "openrouter":
|
||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.OpenRouter.APIKey
|
||||
sel.proxy = cfg.Providers.OpenRouter.Proxy
|
||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||
sel.apiBase = cfg.Providers.OpenRouter.APIBase
|
||||
} else {
|
||||
sel.apiBase = "https://openrouter.ai/api/v1"
|
||||
}
|
||||
}
|
||||
case "litellm":
|
||||
if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" {
|
||||
sel.apiKey = cfg.Providers.LiteLLM.APIKey
|
||||
sel.apiBase = cfg.Providers.LiteLLM.APIBase
|
||||
sel.proxy = cfg.Providers.LiteLLM.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "http://localhost:4000/v1"
|
||||
}
|
||||
}
|
||||
case "zhipu", "glm":
|
||||
if cfg.Providers.Zhipu.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.Zhipu.APIKey
|
||||
sel.apiBase = cfg.Providers.Zhipu.APIBase
|
||||
sel.proxy = cfg.Providers.Zhipu.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
}
|
||||
case "gemini", "google":
|
||||
if cfg.Providers.Gemini.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.Gemini.APIKey
|
||||
sel.apiBase = cfg.Providers.Gemini.APIBase
|
||||
sel.proxy = cfg.Providers.Gemini.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
}
|
||||
case "vllm":
|
||||
if cfg.Providers.VLLM.APIBase != "" {
|
||||
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||
sel.proxy = cfg.Providers.VLLM.Proxy
|
||||
}
|
||||
case "shengsuanyun":
|
||||
if cfg.Providers.ShengSuanYun.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.ShengSuanYun.APIKey
|
||||
sel.apiBase = cfg.Providers.ShengSuanYun.APIBase
|
||||
sel.proxy = cfg.Providers.ShengSuanYun.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://router.shengsuanyun.com/api/v1"
|
||||
}
|
||||
}
|
||||
case "nvidia":
|
||||
if cfg.Providers.Nvidia.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.Nvidia.APIKey
|
||||
sel.apiBase = cfg.Providers.Nvidia.APIBase
|
||||
sel.proxy = cfg.Providers.Nvidia.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://integrate.api.nvidia.com/v1"
|
||||
}
|
||||
}
|
||||
case "vivgrid":
|
||||
if cfg.Providers.Vivgrid.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.Vivgrid.APIKey
|
||||
sel.apiBase = cfg.Providers.Vivgrid.APIBase
|
||||
sel.proxy = cfg.Providers.Vivgrid.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.vivgrid.com/v1"
|
||||
}
|
||||
}
|
||||
case "claude-cli", "claude-code", "claudecode":
|
||||
workspace := cfg.WorkspacePath()
|
||||
if workspace == "" {
|
||||
workspace = "."
|
||||
}
|
||||
sel.providerType = providerTypeClaudeCLI
|
||||
sel.workspace = workspace
|
||||
return sel, nil
|
||||
case "codex-cli", "codex-code":
|
||||
workspace := cfg.WorkspacePath()
|
||||
if workspace == "" {
|
||||
workspace = "."
|
||||
}
|
||||
sel.providerType = providerTypeCodexCLI
|
||||
sel.workspace = workspace
|
||||
return sel, nil
|
||||
case "deepseek":
|
||||
if cfg.Providers.DeepSeek.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.DeepSeek.APIKey
|
||||
sel.apiBase = cfg.Providers.DeepSeek.APIBase
|
||||
sel.proxy = cfg.Providers.DeepSeek.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.deepseek.com/v1"
|
||||
}
|
||||
if model != "deepseek-chat" && model != "deepseek-reasoner" {
|
||||
sel.model = "deepseek-chat"
|
||||
}
|
||||
}
|
||||
case "avian":
|
||||
if cfg.Providers.Avian.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.Avian.APIKey
|
||||
sel.apiBase = cfg.Providers.Avian.APIBase
|
||||
sel.proxy = cfg.Providers.Avian.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.avian.io/v1"
|
||||
}
|
||||
}
|
||||
case "mistral":
|
||||
if cfg.Providers.Mistral.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.Mistral.APIKey
|
||||
sel.apiBase = cfg.Providers.Mistral.APIBase
|
||||
sel.proxy = cfg.Providers.Mistral.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.mistral.ai/v1"
|
||||
}
|
||||
}
|
||||
case "minimax":
|
||||
if cfg.Providers.Minimax.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.Minimax.APIKey
|
||||
sel.apiBase = cfg.Providers.Minimax.APIBase
|
||||
sel.proxy = cfg.Providers.Minimax.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.minimaxi.com/v1"
|
||||
}
|
||||
}
|
||||
case "longcat":
|
||||
if cfg.Providers.LongCat.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.LongCat.APIKey
|
||||
sel.apiBase = cfg.Providers.LongCat.APIBase
|
||||
sel.proxy = cfg.Providers.LongCat.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.longcat.chat/openai"
|
||||
}
|
||||
}
|
||||
case "github_copilot", "copilot":
|
||||
sel.providerType = providerTypeGitHubCopilot
|
||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||
sel.apiBase = cfg.Providers.GitHubCopilot.APIBase
|
||||
} else {
|
||||
sel.apiBase = "localhost:4321"
|
||||
}
|
||||
sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode
|
||||
return sel, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: infer provider from model and configured keys.
|
||||
if sel.apiKey == "" && sel.apiBase == "" {
|
||||
switch {
|
||||
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Moonshot.APIKey
|
||||
sel.apiBase = cfg.Providers.Moonshot.APIBase
|
||||
sel.proxy = cfg.Providers.Moonshot.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.moonshot.cn/v1"
|
||||
}
|
||||
case strings.HasPrefix(model, "openrouter/") ||
|
||||
strings.HasPrefix(model, "anthropic/") ||
|
||||
strings.HasPrefix(model, "openai/") ||
|
||||
strings.HasPrefix(model, "meta-llama/") ||
|
||||
strings.HasPrefix(model, "deepseek/") ||
|
||||
strings.HasPrefix(model, "google/"):
|
||||
sel.apiKey = cfg.Providers.OpenRouter.APIKey
|
||||
sel.proxy = cfg.Providers.OpenRouter.Proxy
|
||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||
sel.apiBase = cfg.Providers.OpenRouter.APIBase
|
||||
} else {
|
||||
sel.apiBase = "https://openrouter.ai/api/v1"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
|
||||
(cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
|
||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
||||
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = defaultAnthropicAPIBase
|
||||
}
|
||||
sel.providerType = providerTypeClaudeAuth
|
||||
return sel, nil
|
||||
}
|
||||
sel.apiKey = cfg.Providers.Anthropic.APIKey
|
||||
sel.apiBase = cfg.Providers.Anthropic.APIBase
|
||||
sel.proxy = cfg.Providers.Anthropic.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = defaultAnthropicAPIBase
|
||||
}
|
||||
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) &&
|
||||
(cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
|
||||
sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
|
||||
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
|
||||
sel.providerType = providerTypeCodexCLIToken
|
||||
return sel, nil
|
||||
}
|
||||
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
||||
sel.providerType = providerTypeCodexAuth
|
||||
return sel, nil
|
||||
}
|
||||
sel.apiKey = cfg.Providers.OpenAI.APIKey
|
||||
sel.apiBase = cfg.Providers.OpenAI.APIBase
|
||||
sel.proxy = cfg.Providers.OpenAI.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.openai.com/v1"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Gemini.APIKey
|
||||
sel.apiBase = cfg.Providers.Gemini.APIBase
|
||||
sel.proxy = cfg.Providers.Gemini.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Zhipu.APIKey
|
||||
sel.apiBase = cfg.Providers.Zhipu.APIBase
|
||||
sel.proxy = cfg.Providers.Zhipu.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Groq.APIKey
|
||||
sel.apiBase = cfg.Providers.Groq.APIBase
|
||||
sel.proxy = cfg.Providers.Groq.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.groq.com/openai/v1"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Nvidia.APIKey
|
||||
sel.apiBase = cfg.Providers.Nvidia.APIBase
|
||||
sel.proxy = cfg.Providers.Nvidia.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://integrate.api.nvidia.com/v1"
|
||||
}
|
||||
case strings.HasPrefix(model, "vivgrid/") && cfg.Providers.Vivgrid.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Vivgrid.APIKey
|
||||
sel.apiBase = cfg.Providers.Vivgrid.APIBase
|
||||
sel.proxy = cfg.Providers.Vivgrid.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.vivgrid.com/v1"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Ollama.APIKey
|
||||
sel.apiBase = cfg.Providers.Ollama.APIBase
|
||||
sel.proxy = cfg.Providers.Ollama.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "http://localhost:11434/v1"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Mistral.APIKey
|
||||
sel.apiBase = cfg.Providers.Mistral.APIBase
|
||||
sel.proxy = cfg.Providers.Mistral.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.mistral.ai/v1"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "minimax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.Minimax.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Minimax.APIKey
|
||||
sel.apiBase = cfg.Providers.Minimax.APIBase
|
||||
sel.proxy = cfg.Providers.Minimax.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.minimaxi.com/v1"
|
||||
}
|
||||
case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.Avian.APIKey
|
||||
sel.apiBase = cfg.Providers.Avian.APIBase
|
||||
sel.proxy = cfg.Providers.Avian.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.avian.io/v1"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "longcat") || strings.HasPrefix(model, "longcat/")) && cfg.Providers.LongCat.APIKey != "":
|
||||
sel.apiKey = cfg.Providers.LongCat.APIKey
|
||||
sel.apiBase = cfg.Providers.LongCat.APIBase
|
||||
sel.proxy = cfg.Providers.LongCat.Proxy
|
||||
if sel.apiBase == "" {
|
||||
sel.apiBase = "https://api.longcat.chat/openai"
|
||||
}
|
||||
case cfg.Providers.VLLM.APIBase != "":
|
||||
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||
sel.proxy = cfg.Providers.VLLM.Proxy
|
||||
default:
|
||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.OpenRouter.APIKey
|
||||
sel.proxy = cfg.Providers.OpenRouter.Proxy
|
||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||
sel.apiBase = cfg.Providers.OpenRouter.APIBase
|
||||
} else {
|
||||
sel.apiBase = "https://openrouter.ai/api/v1"
|
||||
}
|
||||
} else {
|
||||
return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sel.providerType == providerTypeHTTPCompat {
|
||||
if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
|
||||
return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model)
|
||||
}
|
||||
if sel.apiBase == "" {
|
||||
return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model)
|
||||
}
|
||||
}
|
||||
|
||||
return sel, nil
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
return provider, modelID, nil
|
||||
}
|
||||
// OpenAI with API key
|
||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||
if cfg.APIKey() == "" && cfg.APIBase == "" {
|
||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||
}
|
||||
apiBase := cfg.APIBase
|
||||
@@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
apiBase = getDefaultAPIBase(protocol)
|
||||
}
|
||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
cfg.APIKey,
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.Proxy,
|
||||
cfg.MaxTokensField,
|
||||
@@ -98,7 +98,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
case "azure", "azure-openai":
|
||||
// Azure OpenAI uses deployment-based URLs, api-key header auth,
|
||||
// and always sends max_completion_tokens.
|
||||
if cfg.APIKey == "" {
|
||||
if cfg.APIKey() == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for azure protocol")
|
||||
}
|
||||
if cfg.APIBase == "" {
|
||||
@@ -107,7 +107,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
)
|
||||
}
|
||||
return azure.NewProviderWithTimeout(
|
||||
cfg.APIKey,
|
||||
cfg.APIKey(),
|
||||
cfg.APIBase,
|
||||
cfg.Proxy,
|
||||
cfg.RequestTimeout,
|
||||
@@ -119,7 +119,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
|
||||
"coding-plan", "alibaba-coding", "qwen-coding":
|
||||
// All other OpenAI-compatible HTTP providers
|
||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||
if cfg.APIKey() == "" && cfg.APIBase == "" {
|
||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||
}
|
||||
apiBase := cfg.APIBase
|
||||
@@ -127,7 +127,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
apiBase = getDefaultAPIBase(protocol)
|
||||
}
|
||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
cfg.APIKey,
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.Proxy,
|
||||
cfg.MaxTokensField,
|
||||
@@ -148,11 +148,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.anthropic.com/v1"
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
if cfg.APIKey() == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||
}
|
||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
cfg.APIKey,
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.Proxy,
|
||||
cfg.MaxTokensField,
|
||||
@@ -165,11 +165,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.anthropic.com/v1"
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
if cfg.APIKey() == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model)
|
||||
}
|
||||
return anthropicmessages.NewProviderWithTimeout(
|
||||
cfg.APIKey,
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.RequestTimeout,
|
||||
), modelID, nil
|
||||
@@ -180,11 +180,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
if apiBase == "" {
|
||||
apiBase = getDefaultAPIBase(protocol)
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
if cfg.APIKey() == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model)
|
||||
}
|
||||
return anthropicmessages.NewProviderWithTimeout(
|
||||
cfg.APIKey,
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.RequestTimeout,
|
||||
), modelID, nil
|
||||
|
||||
@@ -89,9 +89,9 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-openai",
|
||||
Model: "openai/gpt-4o",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://api.example.com/v1",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -129,8 +129,8 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-" + tt.protocol,
|
||||
Model: tt.protocol + "/test-model",
|
||||
APIKey: "test-key",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, _, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -155,9 +155,9 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-litellm",
|
||||
Model: "litellm/my-proxy-alias",
|
||||
APIKey: "test-key",
|
||||
APIBase: "http://localhost:4000/v1",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -175,9 +175,9 @@ func TestCreateProviderFromConfig_LongCat(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-longcat",
|
||||
Model: "longcat/LongCat-Flash-Thinking",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://api.longcat.chat/openai",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -198,9 +198,9 @@ func TestCreateProviderFromConfig_ModelScope(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-modelscope",
|
||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://api-inference.modelscope.cn/v1",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -227,8 +227,8 @@ func TestCreateProviderFromConfig_Novita(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-novita",
|
||||
Model: "novita/deepseek/deepseek-v3.2",
|
||||
APIKey: "test-key",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -255,8 +255,8 @@ func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-anthropic",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
APIKey: "test-key",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -340,8 +340,8 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-unknown",
|
||||
Model: "unknown-protocol/model",
|
||||
APIKey: "test-key",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
_, _, err := CreateProviderFromConfig(cfg)
|
||||
if err == nil {
|
||||
@@ -382,6 +382,7 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
|
||||
APIBase: server.URL,
|
||||
RequestTimeout: 1,
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -411,9 +412,9 @@ func TestCreateProviderFromConfig_Azure(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
APIKey: "test-azure-key",
|
||||
APIBase: "https://my-resource.openai.azure.com",
|
||||
}
|
||||
cfg.SetAPIKey("test-azure-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -431,9 +432,9 @@ func TestCreateProviderFromConfig_AzureOpenAIAlias(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "azure-gpt4",
|
||||
Model: "azure-openai/my-deployment",
|
||||
APIKey: "test-azure-key",
|
||||
APIBase: "https://my-resource.openai.azure.com",
|
||||
}
|
||||
cfg.SetAPIKey("test-azure-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -464,8 +465,8 @@ func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
APIKey: "test-azure-key",
|
||||
}
|
||||
cfg.SetAPIKey("test-azure-key")
|
||||
|
||||
_, _, err := CreateProviderFromConfig(cfg)
|
||||
if err == nil {
|
||||
@@ -488,8 +489,8 @@ func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-" + tt.protocol,
|
||||
Model: tt.protocol + "/qwen-max",
|
||||
APIKey: "test-key",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -522,8 +523,8 @@ func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-" + tt.protocol,
|
||||
Model: tt.protocol + "/qwen-max",
|
||||
APIKey: "test-key",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -556,8 +557,8 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-" + tt.protocol,
|
||||
Model: tt.protocol + "/claude-sonnet-4-20250514",
|
||||
APIKey: "test-key",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
|
||||
+13
-253
@@ -1,262 +1,22 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestResolveProviderSelection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*config.Config)
|
||||
wantType providerType
|
||||
wantAPIBase string
|
||||
wantProxy string
|
||||
wantErrSubstr string
|
||||
}{
|
||||
{
|
||||
name: "explicit litellm provider uses configured base",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "litellm"
|
||||
cfg.Providers.LiteLLM.APIKey = "litellm-key"
|
||||
cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1"
|
||||
cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "http://localhost:4000/v1",
|
||||
wantProxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
{
|
||||
name: "explicit litellm provider defaults base when only key is configured",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "litellm"
|
||||
cfg.Providers.LiteLLM.APIKey = "litellm-key"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "http://localhost:4000/v1",
|
||||
},
|
||||
{
|
||||
name: "explicit claude-cli provider routes to cli provider type",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
||||
cfg.Agents.Defaults.Workspace = "/tmp/ws"
|
||||
},
|
||||
wantType: providerTypeClaudeCLI,
|
||||
},
|
||||
{
|
||||
name: "explicit copilot provider routes to github copilot type",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "copilot"
|
||||
},
|
||||
wantType: providerTypeGitHubCopilot,
|
||||
wantAPIBase: "localhost:4321",
|
||||
},
|
||||
{
|
||||
name: "explicit deepseek provider uses deepseek defaults",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "deepseek"
|
||||
cfg.Agents.Defaults.Model = "deepseek/deepseek-chat"
|
||||
cfg.Providers.DeepSeek.APIKey = "deepseek-key"
|
||||
cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://api.deepseek.com/v1",
|
||||
wantProxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
{
|
||||
name: "explicit shengsuanyun provider uses defaults",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "shengsuanyun"
|
||||
cfg.Providers.ShengSuanYun.APIKey = "ssy-key"
|
||||
cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://router.shengsuanyun.com/api/v1",
|
||||
wantProxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
{
|
||||
name: "explicit nvidia provider uses defaults",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "nvidia"
|
||||
cfg.Providers.Nvidia.APIKey = "nvapi-test"
|
||||
cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://integrate.api.nvidia.com/v1",
|
||||
wantProxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
{
|
||||
name: "explicit vivgrid provider uses defaults",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "vivgrid"
|
||||
cfg.Providers.Vivgrid.APIKey = "vivgrid-key"
|
||||
cfg.Providers.Vivgrid.Proxy = "http://127.0.0.1:7890"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://api.vivgrid.com/v1",
|
||||
wantProxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
{
|
||||
name: "openrouter model uses openrouter defaults",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "openrouter/auto"
|
||||
cfg.Providers.OpenRouter.APIKey = "sk-or-test"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
{
|
||||
name: "anthropic oauth routes to claude auth provider",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
||||
cfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||
},
|
||||
wantType: providerTypeClaudeAuth,
|
||||
},
|
||||
{
|
||||
name: "openai oauth routes to codex auth provider",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "gpt-4o"
|
||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
},
|
||||
wantType: providerTypeCodexAuth,
|
||||
},
|
||||
{
|
||||
name: "openai codex-cli auth routes to codex cli token provider",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "gpt-4o"
|
||||
cfg.Providers.OpenAI.AuthMethod = "codex-cli"
|
||||
},
|
||||
wantType: providerTypeCodexCLIToken,
|
||||
},
|
||||
{
|
||||
name: "explicit codex-code provider routes to codex cli provider type",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "codex-code"
|
||||
cfg.Agents.Defaults.Workspace = "/tmp/ws"
|
||||
},
|
||||
wantType: providerTypeCodexCLI,
|
||||
},
|
||||
{
|
||||
name: "zhipu model uses zhipu base default",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "glm-4.7"
|
||||
cfg.Providers.Zhipu.APIKey = "zhipu-key"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||
},
|
||||
{
|
||||
name: "groq model uses groq base default",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "groq/llama-3.3-70b"
|
||||
cfg.Providers.Groq.APIKey = "gsk-key"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://api.groq.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "ollama model uses ollama base default",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "ollama/qwen2.5:14b"
|
||||
cfg.Providers.Ollama.APIKey = "ollama-key"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "http://localhost:11434/v1",
|
||||
},
|
||||
{
|
||||
name: "moonshot model keeps proxy and default base",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5"
|
||||
cfg.Providers.Moonshot.APIKey = "moonshot-key"
|
||||
cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://api.moonshot.cn/v1",
|
||||
wantProxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
{
|
||||
name: "explicit longcat provider uses defaults",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Provider = "longcat"
|
||||
cfg.Providers.LongCat.APIKey = "longcat-key"
|
||||
cfg.Providers.LongCat.Proxy = "http://127.0.0.1:7890"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://api.longcat.chat/openai",
|
||||
wantProxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
{
|
||||
name: "longcat model fallback uses longcat base default",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "longcat/LongCat-Flash-Thinking"
|
||||
cfg.Providers.LongCat.APIKey = "longcat-key"
|
||||
},
|
||||
wantType: providerTypeHTTPCompat,
|
||||
wantAPIBase: "https://api.longcat.chat/openai",
|
||||
},
|
||||
{
|
||||
name: "missing keys returns model config error",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "custom-model"
|
||||
},
|
||||
wantErrSubstr: "no API key configured for model",
|
||||
},
|
||||
{
|
||||
name: "openrouter prefix without key returns provider key error",
|
||||
setup: func(cfg *config.Config) {
|
||||
cfg.Agents.Defaults.Model = "openrouter/auto"
|
||||
},
|
||||
wantErrSubstr: "no API key configured for provider",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
tt.setup(cfg)
|
||||
|
||||
got, err := resolveProviderSelection(cfg)
|
||||
if tt.wantErrSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrSubstr) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("resolveProviderSelection() error = %v", err)
|
||||
}
|
||||
if got.providerType != tt.wantType {
|
||||
t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType)
|
||||
}
|
||||
if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase {
|
||||
t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase)
|
||||
}
|
||||
if tt.wantProxy != "" && got.proxy != tt.wantProxy {
|
||||
t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Model = "test-openrouter"
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
{
|
||||
ModelName: "test-openrouter",
|
||||
Model: "openrouter/auto",
|
||||
APIKey: "sk-or-test",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
cfg.Agents.Defaults.ModelName = "test-openrouter"
|
||||
modelCfg := &config.ModelConfig{
|
||||
ModelName: "test-openrouter",
|
||||
Model: "openrouter/auto",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
}
|
||||
modelCfg.SetAPIKey("sk-or-test")
|
||||
cfg.ModelList = []*config.ModelConfig{modelCfg}
|
||||
|
||||
provider, _, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
@@ -270,8 +30,8 @@ func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
|
||||
|
||||
func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Model = "test-codex"
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
cfg.Agents.Defaults.ModelName = "test-codex"
|
||||
cfg.ModelList = []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "test-codex",
|
||||
Model: "codex-cli/codex-model",
|
||||
@@ -291,8 +51,8 @@ func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
|
||||
|
||||
func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Model = "test-claude-cli"
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
cfg.Agents.Defaults.ModelName = "test-claude-cli"
|
||||
cfg.ModelList = []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "test-claude-cli",
|
||||
Model: "claude-cli/claude-sonnet",
|
||||
@@ -324,8 +84,8 @@ func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Model = "test-claude-oauth"
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
cfg.Agents.Defaults.ModelName = "test-claude-oauth"
|
||||
cfg.ModelList = []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "test-claude-oauth",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
|
||||
@@ -18,23 +18,6 @@ import (
|
||||
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
|
||||
model := cfg.Agents.Defaults.GetModelName()
|
||||
|
||||
// Ensure model_list is populated from providers config if needed
|
||||
// This handles two cases:
|
||||
// 1. ModelList is empty - convert all providers
|
||||
// 2. ModelList has some entries but not all providers - merge missing ones
|
||||
if cfg.HasProvidersConfig() {
|
||||
providerModels := config.ConvertProvidersToModelList(cfg)
|
||||
existingModelNames := make(map[string]bool)
|
||||
for _, m := range cfg.ModelList {
|
||||
existingModelNames[m.ModelName] = true
|
||||
}
|
||||
for _, pm := range providerModels {
|
||||
if !existingModelNames[pm.ModelName] {
|
||||
cfg.ModelList = append(cfg.ModelList, pm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Must have model_list at this point
|
||||
if len(cfg.ModelList) == 0 {
|
||||
return nil, "", fmt.Errorf("no providers configured. Please add entries to model_list in your config")
|
||||
|
||||
@@ -11,7 +11,7 @@ func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *co
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: "/tmp/picoclaw-test",
|
||||
Model: "gpt-4",
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
List: agents,
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ func NewAudioModelTranscriber(modelCfg *config.ModelConfig) *AudioModelTranscrib
|
||||
}
|
||||
|
||||
logger.DebugCF("voice", "Creating audio model transcriber", map[string]any{
|
||||
"has_api_key": modelCfg.APIKey != "",
|
||||
"has_api_key": modelCfg.APIKey() != "",
|
||||
"api_base": modelCfg.APIBase,
|
||||
"model": modelCfg.Model,
|
||||
})
|
||||
|
||||
@@ -54,14 +54,10 @@ func DetectTranscriber(cfg *config.Config) Transcriber {
|
||||
}
|
||||
}
|
||||
|
||||
// Direct Groq provider config takes priority.
|
||||
if key := cfg.Providers.Groq.APIKey; key != "" {
|
||||
return NewGroqTranscriber(key)
|
||||
}
|
||||
// Fall back to any model-list entry that uses the groq/ protocol.
|
||||
for _, mc := range cfg.ModelList {
|
||||
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
|
||||
return NewGroqTranscriber(mc.APIKey)
|
||||
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
|
||||
return NewGroqTranscriber(mc.APIKey())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -18,99 +18,131 @@ func TestDetectTranscriber(t *testing.T) {
|
||||
cfg: &config.Config{},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "groq provider key",
|
||||
cfg: &config.Config{
|
||||
Providers: config.ProvidersConfig{
|
||||
Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
|
||||
},
|
||||
},
|
||||
wantName: "groq",
|
||||
},
|
||||
{
|
||||
name: "voice model name selects audio model transcriber",
|
||||
cfg: &config.Config{
|
||||
cfg: (&config.Config{
|
||||
Voice: config.VoiceConfig{ModelName: "voice-gemini"},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash", APIKey: "sk-gemini-model"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"},
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"voice-gemini": {
|
||||
APIKeys: []string{"sk-gemini-model"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
wantName: "audio-model",
|
||||
},
|
||||
{
|
||||
name: "groq via model list",
|
||||
cfg: &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{Model: "openai/gpt-4o", APIKey: "sk-openai"},
|
||||
{Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
|
||||
cfg: (&config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "openai", Model: "openai/gpt-4o"},
|
||||
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"openai": {
|
||||
APIKeys: []string{"sk-openai"},
|
||||
},
|
||||
"groq": {
|
||||
APIKeys: []string{"sk-groq-model"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
wantName: "groq",
|
||||
},
|
||||
{
|
||||
name: "voice model name selects non-gemini audio model transcriber",
|
||||
cfg: &config.Config{
|
||||
cfg: (&config.Config{
|
||||
Voice: config.VoiceConfig{ModelName: "voice-openai-audio"},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "voice-openai-audio", Model: "openai/gpt-4o-audio-preview", APIKey: "sk-openai"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "voice-openai-audio", Model: "openai/gpt-4o-audio-preview"},
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"voice-openai-audio": {
|
||||
APIKeys: []string{"sk-openai"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
wantName: "audio-model",
|
||||
},
|
||||
{
|
||||
name: "voice model name selects azure audio model transcriber",
|
||||
cfg: &config.Config{
|
||||
cfg: (&config.Config{
|
||||
Voice: config.VoiceConfig{ModelName: "voice-azure-audio"},
|
||||
ModelList: []config.ModelConfig{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "voice-azure-audio",
|
||||
Model: "azure/my-audio-deployment",
|
||||
APIKey: "sk-azure",
|
||||
APIBase: "https://example.openai.azure.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"voice-azure-audio": {
|
||||
APIKeys: []string{"sk-azure"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
wantName: "audio-model",
|
||||
},
|
||||
{
|
||||
name: "voice model name with non openai compatible protocol does not select audio model transcriber",
|
||||
cfg: &config.Config{
|
||||
cfg: (&config.Config{
|
||||
Voice: config.VoiceConfig{ModelName: "voice-anthropic"},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "voice-anthropic", Model: "anthropic/claude-sonnet-4.6", APIKey: "sk-anthropic"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "voice-anthropic", Model: "anthropic/claude-sonnet-4.6"},
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"voice-anthropic": {
|
||||
APIKeys: []string{"sk-anthropic"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "groq model list entry without key is skipped",
|
||||
cfg: &config.Config{
|
||||
ModelList: []config.ModelConfig{
|
||||
{Model: "groq/llama-3.3-70b", APIKey: ""},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{Model: "groq/llama-3.3-70b"},
|
||||
},
|
||||
},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "provider key takes priority over model list",
|
||||
cfg: &config.Config{
|
||||
Providers: config.ProvidersConfig{
|
||||
Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
|
||||
cfg: (&config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
|
||||
}).WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"groq": {
|
||||
APIKeys: []string{"sk-groq-model"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
wantName: "groq",
|
||||
},
|
||||
{
|
||||
name: "missing voice model name config returns nil",
|
||||
cfg: &config.Config{
|
||||
cfg: (&config.Config{
|
||||
Voice: config.VoiceConfig{ModelName: "missing"},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "other", Model: "gemini/gemini-2.5-flash", APIKey: "sk-gemini-model"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "other", Model: "gemini/gemini-2.5-flash"},
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"other": {
|
||||
APIKeys: []string{"sk-other-model"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
wantNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
|
||||
@@ -45,7 +46,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
if err = json.Unmarshal(body, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -63,6 +64,14 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("new config: %+v", cfg)
|
||||
oldCfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cfg.SecurityCopyFrom(oldCfg)
|
||||
|
||||
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -150,6 +159,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
newCfg.SecurityCopyFrom(cfg)
|
||||
|
||||
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -175,17 +186,17 @@ func validateConfig(cfg *config.Config) []string {
|
||||
}
|
||||
|
||||
// Pico channel: token required when enabled
|
||||
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" {
|
||||
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token() == "" {
|
||||
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
|
||||
}
|
||||
|
||||
// Telegram: token required when enabled
|
||||
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
|
||||
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token() == "" {
|
||||
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
|
||||
}
|
||||
|
||||
// Discord: token required when enabled
|
||||
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" {
|
||||
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token() == "" {
|
||||
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace"
|
||||
@@ -27,7 +28,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin
|
||||
{
|
||||
"model_name": "custom-default",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "sk-default"
|
||||
"api_keys": ["sk-default"]
|
||||
}
|
||||
]
|
||||
}`))
|
||||
|
||||
@@ -159,10 +159,10 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
|
||||
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
|
||||
}
|
||||
|
||||
if !hasModelConfiguration(*modelCfg) {
|
||||
if !hasModelConfiguration(modelCfg) {
|
||||
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
||||
}
|
||||
if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) {
|
||||
if requiresRuntimeProbe(modelCfg) && !probeLocalModelAvailability(modelCfg) {
|
||||
return false, fmt.Sprintf("default model %q is not reachable", modelName), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
||||
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Model = "missing-model"
|
||||
cfg.Agents.Defaults.ModelName = "missing-model"
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
@@ -124,7 +124,7 @@ func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = "test-key"
|
||||
cfg.ModelList[0].SetAPIKey("test-key")
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
@@ -144,7 +144,7 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = ""
|
||||
cfg.ModelList[0].SetAPIKey("")
|
||||
cfg.ModelList[0].AuthMethod = ""
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
@@ -177,7 +177,7 @@ func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "local-vllm",
|
||||
Model: "vllm/custom-model",
|
||||
APIBase: "http://localhost:8000/v1",
|
||||
@@ -214,7 +214,7 @@ func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "local-vllm",
|
||||
Model: "vllm/custom-model",
|
||||
APIBase: "http://127.0.0.1:8000/v1",
|
||||
@@ -249,12 +249,12 @@ func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "remote-vllm",
|
||||
Model: "vllm/custom-model",
|
||||
APIBase: "https://models.example.com/v1",
|
||||
APIKey: "remote-key",
|
||||
}}
|
||||
cfg.ModelList[0o0].SetAPIKey("remote-key")
|
||||
cfg.Agents.Defaults.ModelName = "remote-vllm"
|
||||
err = config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
@@ -284,7 +284,7 @@ func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "local-ollama",
|
||||
Model: "ollama/llama3",
|
||||
}}
|
||||
@@ -312,7 +312,7 @@ func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "openai-oauth",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
@@ -483,12 +483,12 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = "test-key"
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
cfg.ModelList[0].SetAPIKey("test-key")
|
||||
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "second-model",
|
||||
Model: "openai/gpt-4.1",
|
||||
APIKey: "second-key",
|
||||
})
|
||||
cfg.ModelList[len(cfg.ModelList)-1].SetAPIKey("second-key")
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
@@ -632,7 +632,7 @@ func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = ""
|
||||
cfg.ModelList[0].SetAPIKey("")
|
||||
cfg.ModelList[0].AuthMethod = ""
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
@@ -685,7 +685,7 @@ func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = "test-key"
|
||||
cfg.ModelList[0].SetAPIKey("test-key")
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
@@ -751,7 +751,7 @@ func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = "test-key"
|
||||
cfg.ModelList[0].SetAPIKey("test-key")
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ var (
|
||||
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
|
||||
)
|
||||
|
||||
func hasModelConfiguration(m config.ModelConfig) bool {
|
||||
func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
||||
apiKey := strings.TrimSpace(m.APIKey)
|
||||
apiKey := strings.TrimSpace(m.APIKey())
|
||||
|
||||
if authMethod == "oauth" || authMethod == "token" {
|
||||
if provider, ok := oauthProviderForModel(m.Model); ok {
|
||||
@@ -44,7 +44,7 @@ func hasModelConfiguration(m config.ModelConfig) bool {
|
||||
|
||||
// isModelConfigured reports whether a model is currently available to use.
|
||||
// Local models must be reachable; remote/API-key models only need saved config.
|
||||
func isModelConfigured(m config.ModelConfig) bool {
|
||||
func isModelConfigured(m *config.ModelConfig) bool {
|
||||
if !hasModelConfiguration(m) {
|
||||
return false
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func isModelConfigured(m config.ModelConfig) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func requiresRuntimeProbe(m config.ModelConfig) bool {
|
||||
func requiresRuntimeProbe(m *config.ModelConfig) bool {
|
||||
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
||||
if authMethod == "local" {
|
||||
return true
|
||||
@@ -75,27 +75,27 @@ func requiresRuntimeProbe(m config.ModelConfig) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func probeLocalModelAvailability(m config.ModelConfig) bool {
|
||||
func probeLocalModelAvailability(m *config.ModelConfig) bool {
|
||||
apiBase := modelProbeAPIBase(m)
|
||||
protocol, modelID := splitModel(m.Model)
|
||||
switch protocol {
|
||||
case "ollama":
|
||||
return probeOllamaModelFunc(apiBase, modelID)
|
||||
case "vllm":
|
||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey)
|
||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
||||
case "github-copilot", "copilot":
|
||||
return probeTCPServiceFunc(apiBase)
|
||||
case "claude-cli", "claudecli", "codex-cli", "codexcli":
|
||||
return true
|
||||
default:
|
||||
if hasLocalAPIBase(apiBase) {
|
||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey)
|
||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func modelProbeAPIBase(m config.ModelConfig) string {
|
||||
func modelProbeAPIBase(m *config.ModelConfig) string {
|
||||
if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" {
|
||||
return normalizeModelProbeAPIBase(apiBase)
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
model := config.ModelConfig{
|
||||
model := &config.ModelConfig{
|
||||
Model: "openai/custom-model",
|
||||
APIBase: srv.URL + "/v1",
|
||||
APIKey: apiKey,
|
||||
}
|
||||
model.SetAPIKey(apiKey)
|
||||
|
||||
if !probeLocalModelAvailability(model) {
|
||||
t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured")
|
||||
|
||||
@@ -58,7 +58,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(cfg.ModelList))
|
||||
for i, m := range cfg.ModelList {
|
||||
go func(i int, m config.ModelConfig) {
|
||||
go func(i int, m *config.ModelConfig) {
|
||||
defer wg.Done()
|
||||
configured[i] = isModelConfigured(m)
|
||||
}(i, m)
|
||||
@@ -72,7 +72,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||
ModelName: m.ModelName,
|
||||
Model: m.Model,
|
||||
APIBase: m.APIBase,
|
||||
APIKey: maskAPIKey(m.APIKey),
|
||||
APIKey: maskAPIKey(m.APIKey()),
|
||||
Proxy: m.Proxy,
|
||||
AuthMethod: m.AuthMethod,
|
||||
ConnectMode: m.ConnectMode,
|
||||
@@ -122,7 +122,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.ModelList = append(cfg.ModelList, mc)
|
||||
cfg.ModelList = append(cfg.ModelList, &mc)
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
@@ -180,11 +180,11 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Preserve the existing API key when the caller omits it (empty string).
|
||||
// This lets the UI update api_base / proxy without clearing the stored secret.
|
||||
if mc.APIKey == "" {
|
||||
mc.APIKey = cfg.ModelList[idx].APIKey
|
||||
if mc.APIKey() == "" {
|
||||
mc.SetAPIKey(cfg.ModelList[idx].APIKey())
|
||||
}
|
||||
|
||||
cfg.ModelList[idx] = mc
|
||||
cfg.ModelList[idx] = &mc
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
@@ -224,9 +224,6 @@ func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) {
|
||||
if cfg.Agents.Defaults.ModelName == deletedModelName {
|
||||
cfg.Agents.Defaults.ModelName = ""
|
||||
}
|
||||
if cfg.Agents.Defaults.Model == deletedModelName {
|
||||
cfg.Agents.Defaults.Model = ""
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
cfg.ModelList = []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "openai-oauth",
|
||||
Model: "openai/gpt-5.4",
|
||||
@@ -78,7 +78,6 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||
ModelName: "vllm-remote",
|
||||
Model: "vllm/custom-model",
|
||||
APIBase: "https://models.example.com/v1",
|
||||
APIKey: "remote-key",
|
||||
},
|
||||
{
|
||||
ModelName: "copilot-gpt-5.4",
|
||||
@@ -87,6 +86,11 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
}
|
||||
cfg.WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"vllm-remote": {
|
||||
APIKeys: []string{"remote-key"},
|
||||
},
|
||||
}})
|
||||
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
@@ -152,7 +156,7 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "claude-oauth",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
AuthMethod: "oauth",
|
||||
@@ -215,7 +219,7 @@ func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
cfg.ModelList = []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "local-vllm-a",
|
||||
Model: "vllm/custom-a",
|
||||
@@ -274,7 +278,7 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "vllm-local",
|
||||
Model: "vllm/custom-model",
|
||||
APIBase: "http://0.0.0.0:8000/v1",
|
||||
|
||||
@@ -744,17 +744,6 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case oauthProviderOpenAI:
|
||||
cfg.Providers.OpenAI.AuthMethod = authMethod
|
||||
case oauthProviderAnthropic:
|
||||
cfg.Providers.Anthropic.AuthMethod = authMethod
|
||||
case oauthProviderGoogleAntigravity:
|
||||
cfg.Providers.Antigravity.AuthMethod = authMethod
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider %q", provider)
|
||||
}
|
||||
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
|
||||
@@ -787,28 +776,28 @@ func modelBelongsToProvider(provider, model string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig {
|
||||
func defaultModelConfigForProvider(provider, authMethod string) *config.ModelConfig {
|
||||
switch provider {
|
||||
case oauthProviderOpenAI:
|
||||
return config.ModelConfig{
|
||||
return &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
case oauthProviderAnthropic:
|
||||
return config.ModelConfig{
|
||||
return &config.ModelConfig{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
case oauthProviderGoogleAntigravity:
|
||||
return config.ModelConfig{
|
||||
return &config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
default:
|
||||
return config.ModelConfig{}
|
||||
return &config.ModelConfig{}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,8 +166,7 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig error: %v", err)
|
||||
}
|
||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
@@ -208,9 +207,6 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig error: %v", err)
|
||||
}
|
||||
if updated.Providers.OpenAI.AuthMethod != "" {
|
||||
t.Fatalf("providers.openai.auth_method = %q, want empty", updated.Providers.OpenAI.AuthMethod)
|
||||
}
|
||||
for _, m := range updated.ModelList {
|
||||
if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" {
|
||||
t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod)
|
||||
@@ -233,12 +229,18 @@ func setupOAuthTestEnv(t *testing.T) (string, func()) {
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.ModelList = []config.ModelConfig{{
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "custom-default",
|
||||
Model: "openai/gpt-4o",
|
||||
APIKey: "sk-default",
|
||||
}}
|
||||
cfg.Agents.Defaults.ModelName = "custom-default"
|
||||
cfg.WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"custom-default": {
|
||||
APIKeys: []string{"sk-default"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
configPath := filepath.Join(tmp, "config.json")
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
|
||||
@@ -57,7 +57,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"token": cfg.Channels.Pico.Token,
|
||||
"token": cfg.Channels.Pico.Token(),
|
||||
"ws_url": wsURL,
|
||||
"enabled": cfg.Channels.Pico.Enabled,
|
||||
})
|
||||
@@ -74,7 +74,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
token := generateSecureToken()
|
||||
cfg.Channels.Pico.Token = token
|
||||
cfg.Channels.Pico.SetToken(token)
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
@@ -110,8 +110,8 @@ func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
if cfg.Channels.Pico.Token == "" {
|
||||
cfg.Channels.Pico.Token = generateSecureToken()
|
||||
if cfg.Channels.Pico.Token() == "" {
|
||||
cfg.Channels.Pico.SetToken(generateSecureToken())
|
||||
changed = true
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"token": cfg.Channels.Pico.Token,
|
||||
"token": cfg.Channels.Pico.Token(),
|
||||
"ws_url": wsURL,
|
||||
"enabled": true,
|
||||
"changed": changed,
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
|
||||
if !cfg.Channels.Pico.Enabled {
|
||||
t.Error("expected Pico to be enabled after setup")
|
||||
}
|
||||
if cfg.Channels.Pico.Token == "" {
|
||||
if cfg.Channels.Pico.Token() == "" {
|
||||
t.Error("expected a non-empty token after setup")
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
|
||||
// Pre-configure with custom user settings
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channels.Pico.Enabled = true
|
||||
cfg.Channels.Pico.Token = "user-custom-token"
|
||||
cfg.Channels.Pico.SetToken("user-custom-token")
|
||||
cfg.Channels.Pico.AllowTokenQuery = true
|
||||
cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
@@ -143,8 +143,8 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Channels.Pico.Token != "user-custom-token" {
|
||||
t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token, "user-custom-token")
|
||||
if cfg.Channels.Pico.Token() != "user-custom-token" {
|
||||
t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token(), "user-custom-token")
|
||||
}
|
||||
if !cfg.Channels.Pico.AllowTokenQuery {
|
||||
t.Error("user's allow_token_query=true must be preserved")
|
||||
@@ -166,7 +166,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg1, _ := config.LoadConfig(configPath)
|
||||
token1 := cfg1.Channels.Pico.Token
|
||||
token1 := cfg1.Channels.Pico.Token()
|
||||
|
||||
// Second call should be a no-op
|
||||
changed, err := h.ensurePicoChannel(origin)
|
||||
@@ -178,7 +178,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg2, _ := config.LoadConfig(configPath)
|
||||
if cfg2.Channels.Pico.Token != token1 {
|
||||
if cfg2.Channels.Pico.Token() != token1 {
|
||||
t.Error("token should not change on subsequent calls")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user