Merge pull request #1352 from cytown/version

refactor Config to add Version and migratable
This commit is contained in:
daming大铭
2026-03-23 15:06:44 +08:00
committed by GitHub
90 changed files with 6254 additions and 2628 deletions
+5 -28
View File
@@ -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)
}
+3 -2
View File
@@ -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 {
+4 -2
View File
@@ -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()
+2 -12
View File
@@ -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 {
+111 -90
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+24 -2
View File
@@ -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
View File
@@ -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:")
+230
View File
@@ -0,0 +1,230 @@
# Config Schema Versioning Guide
## Overview
PicoClaw uses a schema versioning system for `config.json` to ensure smooth upgrades as the configuration format evolves.
## Version History
### Version 1
- **Introduction**: Initial version with version field support
- **Changes**: Added `version` field to Config struct
- **Migration**: No structural changes needed for existing configs
## How It Works
### Automatic Migration
When you load a config file:
1. The system first reads the `version` field from the JSON
2. Based on the detected version, it loads the appropriate config struct (`ConfigV0`, `ConfigV1`, etc.)
3. If the loaded version is less than the latest, migrations are applied incrementally
4. The version number is updated automatically
5. The migrated config is automatically saved back to disk
### Version Field
The `version` field in `config.json` indicates the schema version:
- `0` or missing: Legacy config (no version field)
- `1`: Current version with versioning support
```json
{
"version": 1,
"agents": {...},
...
}
```
## Adding a New Migration
When making breaking changes to the config schema:
### Step 1: Define the New Version Struct
Create a new struct for the new version if the structure changes significantly:
```go
// ConfigV2 represents version 2 config structure
type ConfigV2 struct {
Version int `json:"version"`
Agents AgentsConfig `json:"agents"`
// ... other fields with new structure
}
```
### Step 2: Update Current Config Version
```go
const CurrentConfigVersion = 2 // Increment this
```
### Step 3: Add a Loader Function
```go
// loadConfigV2 loads a version 2 config
func loadConfigV2(data []byte) (*Config, error) {
cfg := DefaultConfig()
// Parse to ConfigV2 struct
var v2 ConfigV2
if err := json.Unmarshal(data, &v2); err != nil {
return nil, err
}
// Convert to current Config
cfg.Version = v2.Version
cfg.Agents = v2.Agents
// ... map other fields
return cfg, nil
}
```
### Step 4: Add Migration Logic
```go
// applyMigration applies a single migration step from fromVersion to toVersion
func applyMigration(cfg *Config, fromVersion, toVersion int) (*Config, error) {
switch toVersion {
case 1:
// Migration from version 0 to 1
return &Config{
Version: 1,
Agents: cfg.Agents,
// ... copy all fields
}, nil
case 2:
// Migration from version 1 to 2
// Example: Move or rename fields
migrated := *cfg
migrated.Version = 2
// Apply structural changes
if cfg.SomeOldField != "" {
migrated.SomeNewField = cfg.SomeOldField
}
return &migrated, nil
default:
return nil, fmt.Errorf("unsupported migration target version: %d", toVersion)
}
}
```
### Step 5: Update LoadConfig Switch
```go
func LoadConfig(path string) (*Config, error) {
// ... read file ...
switch versionInfo.Version {
case 0:
cfg, err = loadConfigV0(data)
case 1:
cfg, err = loadConfigV1(data)
case 2:
cfg, err = loadConfigV2(data)
default:
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
}
// ... migrate and validate ...
}
```
### Step 6: Test Your Migration
Create a test in `config_migration_test.go`:
```go
func TestMigrateV1ToV2(t *testing.T) {
// Create a version 1 config
v1Config := Config{
Version: 1,
// ... set up test data
}
// Apply migration
migrated, err := applyMigration(&v1Config, 1, 2)
if err != nil {
t.Fatalf("Migration failed: %v", err)
}
// Verify version is updated
if migrated.Version != 2 {
t.Errorf("Expected version 2, got %d", migrated.Version)
}
// Verify data is preserved/transformed correctly
// ...
}
```
## Migration Best Practices
1. **Version-Specific Structs**: Define a separate struct for each version that has structural changes
2. **Backward Compatibility**: Ensure old configs can still be loaded with their specific structs
3. **No Data Loss**: Migrations should preserve all user settings
4. **Idempotent**: Running the same migration multiple times should be safe
5. **Auto-Save**: Migrated configs are automatically saved to update the user's file
6. **Test Thoroughly**: Test with real user config files
7. **Update Defaults**: Keep `defaults.go` in sync with the latest schema
## Example Migration
### Scenario: Adding a new field with default value
Old config (version 1):
```json
{
"version": 1,
"agents": {
"defaults": {
"max_tokens": 32768
}
}
}
```
Migration to version 2:
```go
case 2:
migrated := *cfg
migrated.Version = 2
// Add new field with default value if not set
if migrated.Agents.Defaults.NewFeatureEnabled == false {
// Use default value
}
return &migrated, nil
```
New config (version 2):
```json
{
"version": 2,
"agents": {
"defaults": {
"max_tokens": 32768,
"new_feature_enabled": false
}
}
}
```
## Troubleshooting
### Config Not Upgrading
- Check that `CurrentConfigVersion` is incremented
- Verify migration logic in `applyMigration()` handles the target version
- Ensure `migrateConfig()` is called in `LoadConfig()`
### Migration Errors
- Check error messages for specific migration failures
- Review migration logic for edge cases
- Ensure all required fields are properly initialized
- Verify the loader function for the source version
### Data Loss After Migration
- Ensure all fields are copied during migration
- Check that the migration doesn't overwrite values with defaults unnecessarily
- Review the conversion logic in the loader functions
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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 {
+5 -5
View File
@@ -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,
},
+1 -1
View File
@@ -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,
},
+1 -1
View File
@@ -28,7 +28,7 @@ func newHookTestLoop(
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+4 -4
View File
@@ -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),
)
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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")
}
+100
View File
@@ -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())
}
}
+3 -3
View File
@@ -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)
}
+1 -1
View File
@@ -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")
}
+2 -2
View File
@@ -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)
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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)
+4 -4
View File
@@ -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(),
},
})
}
+10 -14
View File
@@ -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")
+1 -1
View File
@@ -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
View File
@@ -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
}
+58 -57
View File
@@ -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)
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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)
+8 -8
View File
@@ -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 {
+87 -96
View File
@@ -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) {
+5 -5
View File
@@ -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(),
+64 -80
View File
@@ -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)
+1 -1
View File
@@ -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])
+1 -1
View File
@@ -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)
}
+4 -3
View File
@@ -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))
}
+551
View File
@@ -0,0 +1,551 @@
# Security Configuration Refactoring
## Overview
This refactoring introduces a `.security.yml` file to store all sensitive data (API keys, tokens, secrets, passwords) separately from the main configuration. This improves security by:
1. **Separation of concerns**: Configuration settings and secrets are in separate files
2. **Easier sharing**: The main config can be shared without exposing sensitive data
3. **Better version control**: `.security.yml` can be added to `.gitignore`
4. **Flexible deployment**: Different environments can use different security files
## File Structure
```
~/.picoclaw/
├── config.json # Main configuration (safe to share)
└── .security.yml # Security data (never share)
```
## Usage
### Basic Configuration
In your `config.json`, use `ref:` references to point to values in `.security.yml`:
```json
{
"version": 1,
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api.openai.com/v1",
"api_key": "ref:model_list.gpt-5.4.api_key"
}
],
"channels": {
"telegram": {
"enabled": true,
"token": "ref:channels.telegram.token"
}
}
}
```
### Security Configuration
In your `.security.yml`, store the actual values:
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-your-actual-api-key-1"
- "sk-your-actual-api-key-2" # Optional: Multiple keys for failover
claude-sonnet-4.6:
api_keys:
- "sk-your-actual-anthropic-key" # Single key in array format
channels:
telegram:
token: "your-telegram-bot-token"
web:
brave:
api_keys:
- "BSAyour-brave-api-key-1"
- "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover
tavily:
api_keys:
- "tvly-your-tavily-api-key" # Single key in array format
glm_search:
api_key: "your-glm-search-api-key" # GLMSearch uses single key format
```
## Reference Format
### Model API Keys
Format: `ref:model_list.<model_name>.api_key`
Example: `ref:model_list.gpt-5.4.api_key`
### Channel Tokens/Secrets
Format: `ref:channels.<channel_name>.<field>`
Examples:
- `ref:channels.telegram.token`
- `ref:channels.feishu.app_secret`
- `ref:channels.feishu.encrypt_key`
- `ref:channels.feishu.verification_token`
- `ref:channels.discord.token`
- `ref:channels.qq.app_secret`
- `ref:channels.dingtalk.client_secret`
- `ref:channels.slack.bot_token`
- `ref:channels.slack.app_token`
- `ref:channels.matrix.access_token`
- `ref:channels.line.channel_secret`
- `ref:channels.line.channel_access_token`
- `ref:channels.onebot.access_token`
- `ref:channels.wecom.token`
- `ref:channels.wecom.encoding_aes_key`
- `ref:channels.wecom_app.corp_secret`
- `ref:channels.wecom_app.token`
- `ref:channels.wecom_app.encoding_aes_key`
- `ref:channels.wecom_aibot.token`
- `ref:channels.wecom_aibot.encoding_aes_key`
- `ref:channels.pico.token`
- `ref:channels.irc.password`
- `ref:channels.irc.nickserv_password`
- `ref:channels.irc.sasl_password`
### Web Tool API Keys
Format: `ref:web.<provider>.<field>`
Examples:
- `ref:web.brave.api_key`
- `ref:web.tavily.api_key`
- `ref:web.perplexity.api_key`
- `ref:web.glm_search.api_key`
### Skills Registry Tokens
Format: `ref:skills.<registry>.<field>`
Examples:
- `ref:skills.github.token`
- `ref:skills.clawhub.auth_token`
## Backward Compatibility
The refactoring maintains full backward compatibility:
1. **Direct values**: You can still use direct values in `config.json` (not recommended for production)
2. **Mixed usage**: You can mix `ref:` references and direct values
3. **Optional security file**: If `.security.yml` doesn't exist, all references will fail (but direct values still work)
### API Key Formats in .security.yml
**Models (gpt-5.4, claude-sonnet-4.6, etc.):**
- Must use `api_keys` (array) format
- Both single and multiple keys use array format
**Web Tools (Brave, Tavily, Perplexity):**
- Must use `api_keys` (array) format
- Both single and multiple keys use array format
**Web Tools (GLMSearch):**
- Must use `api_key` (single string) format
- Does NOT support array format
**Channels (Telegram, Discord, etc.):**
- Use single field names (e.g., `token`, `app_secret`)
- Each channel uses its specific field names
### Single Key (Models)
Use array format with one element:
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-your-key"
```
In `config.json`:
```json
{
"api_key": "ref:model_list.gpt-5.4.api_key"
}
```
### Single Key (GLMSearch)
Use single string format:
```yaml
web:
glm_search:
api_key: "your-glm-key"
```
In `config.json`:
```json
{
"api_key": "ref:web.glm_search.api_key"
}
```
## Migration Guide
### Step 1: Create .security.yml
Copy the example template:
```bash
cp security.example.yml ~/.picoclaw/.security.yml
```
### Step 2: Fill in your actual values
Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual API keys and tokens.
### Step 3: Update config.json
Replace sensitive values in `~/.picoclaw/config.json` with `ref:` references:
**Before:**
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-actual-api-key-here"
}
]
}
```
**After:**
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "ref:model_list.gpt-5.4.api_key"
}
]
}
```
### Step 4: Verify
Restart PicoClaw and verify it loads correctly:
```bash
picoclaw --version
```
## Security Best Practices
1. **Never commit `.security.yml`** to version control
2. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml`
3. **Use different keys** for different environments (dev, staging, production)
4. **Rotate keys regularly** and update `.security.yml`
5. **Backup securely**: Encrypt backups containing `.security.yml`
## API
### LoadSecurityConfig
```go
func LoadSecurityConfig(securityPath string) (*SecurityConfig, error)
```
Loads the security configuration from `.security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist.
### SaveSecurityConfig
```go
func SaveSecurityConfig(securityPath string, sec *SecurityConfig) error
```
Saves the security configuration to `.security.yml` with `0o600` permissions.
### ResolveReference
```go
func (sec *SecurityConfig) ResolveReference(ref string) (string, error)
```
Resolves a reference string (e.g., `"ref:model_list.test.api_key"`) and returns the actual value.
### SecurityPath
```go
func SecurityPath(configPath string) string
```
Returns the path to `.security.yml` relative to the config file.
## Example: Complete Configuration
### config.json
```json
{
"version": 1,
"agents": {
"defaults": {
"workspace": "~/picoclaw-workspace",
"model_name": "gpt-5.4"
}
},
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api.openai.com/v1",
"api_key": "ref:model_list.gpt-5.4.api_key"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_base": "https://api.anthropic.com/v1",
"api_key": "ref:model_list.claude-sonnet-4.6.api_key"
}
],
"channels": {
"telegram": {
"enabled": true,
"token": "ref:channels.telegram.token"
}
},
"tools": {
"web": {
"brave": {
"enabled": true,
"api_key": "ref:web.brave.api_key"
}
}
}
}
```
### .security.yml
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-proj-actual-openai-key-1"
- "sk-proj-actual-openai-key-2"
claude-sonnet-4.6:
api_keys:
- "sk-ant-actual-anthropic-key" # Single key in array format
channels:
telegram:
token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
web:
brave:
api_keys:
- "BSAactualbravekey-1"
- "BSAactualbravekey-2"
tavily:
api_keys:
- "tvly-your-tavily-key" # Single key in array format
glm_search:
api_key: "your-glm-key" # GLMSearch uses single key format
```
## Testing
The refactoring includes comprehensive tests:
```bash
go test ./pkg/config -run TestSecurityConfig
```
## Troubleshooting
### Error: "model security entry not found"
- Ensure the model name in your reference matches exactly in `.security.yml`
- Check that the `model_list` section exists in `.security.yml`
- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index
### Error: "failed to load security config"
- Verify `.security.yml` exists in the same directory as `config.json`
- Check the YAML syntax is valid (use a YAML validator)
- Ensure file permissions allow reading
### Error: "unknown reference path"
- Verify the reference format is correct
- Check the path structure matches the examples above
- Ensure all required sections exist in `.security.yml`
## Advanced Features
### Multiple API Keys (Load Balancing & Failover)
Both models and web tools support multiple API keys for improved reliability:
**Benefits:**
- **Load balancing**: Requests are distributed across multiple keys
- **Failover**: Automatic switching to another key if one fails
- **Rate limit management**: Distribute usage across multiple keys
- **High availability**: Reduce downtime during API provider issues
#### Example: Model with Multiple Keys
**.security.yml:**
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-proj-key-1"
- "sk-proj-key-2"
- "sk-proj-key-3"
```
**config.json:**
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "ref:model_list.gpt-5.4.api_key"
}
]
}
```
#### Example: Web Tool with Multiple Keys
**.security.yml:**
```yaml
web:
brave:
api_keys:
- "BSA-key-1"
- "BSA-key-2"
tavily:
api_keys:
- "tvly-your-key" # Single key in array format
glm_search:
api_key: "your-glm-key" # GLMSearch uses single key format
```
**config.json:**
```json
{
"tools": {
"web": {
"brave": {
"enabled": true,
"api_key": "ref:web.brave.api_key"
},
"tavily": {
"enabled": true,
"api_key": "ref:web.tavily.api_key"
}
}
}
}
```
#### Supported Formats
**Models - Single key:**
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-your-key" # Array with one element
```
**Models - Multiple keys:**
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-your-key-1"
- "sk-your-key-2"
- "sk-your-key-3"
```
**Web Tools (Brave/Tavily/Perplexity) - Single key:**
```yaml
web:
brave:
api_keys:
- "BSA-your-key" # Array with one element
```
**Web Tools (Brave/Tavily/Perplexity) - Multiple keys:**
```yaml
web:
brave:
api_keys:
- "BSA-key-1"
- "BSA-key-2"
```
**Web Tool (GLMSearch) - Single key only:**
```yaml
web:
glm_search:
api_key: "your-glm-key" # Single string (NOT array)
```
All formats work identically in `config.json` - you always use the same reference format:
```json
{
"api_key": "ref:model_list.gpt-5.4.api_key"
}
```
### Model Indexing for Load Balancing
When you have multiple models with the same base name but different API keys, you can use indexed names:
**.security.yml:**
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-proj-key-1"
- "sk-proj-key-2"
```
The system will automatically expand this into multiple model entries with fallback support.
### Environment Variables
You can override any security value using environment variables:
**For models:**
```bash
export PICOCLAW_MODEL_LIST_GPT-5.4_API_KEY="sk-from-env"
```
**For channels:**
```bash
export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env"
```
**For web tools:**
```bash
export PICOCLAW_WEB_BRAVE_API_KEY="key-from-env"
```
Environment variables follow this pattern: `PICOCLAW_<SECTION>_<KEY1>_<KEY2>_<FIELD>` with dots replaced by underscores and converted to uppercase.
### Multiple API Keys Not Working
- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch)
- Check that the array format is correct in YAML (proper indentation)
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
- GLMSearch MUST use `api_key` (single string format)
- The reference in `config.json` is the same regardless of single or multiple keys
### Load Balancing/Failover Issues
- Verify all API keys in the `api_keys` array are valid
- Check that all keys have the same rate limits and permissions
- Monitor logs to see which keys are being used and failing
+1100 -315
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+117 -121
View File
@@ -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
View File
@@ -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{},
},
}
}
+423
View File
@@ -0,0 +1,423 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
// This file demonstrates how to use the security configuration feature
// It's not meant to be compiled, just for documentation purposes
/*
Package config
# Example: Using Security Configuration
## 1. Create security.yml
File: ~/.picoclaw/security.yml
```yaml
# Model API Keys
# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
# Single key should be provided as an array with one element
model_list:
gpt-5.4:
api_keys:
- "sk-proj-your-actual-openai-key-1"
- "sk-proj-your-actual-openai-key-2" # Failover key
claude-sonnet-4.6:
api_keys:
- "sk-ant-your-actual-anthropic-key" # Single key in array format
# Channel Tokens
channels:
telegram:
token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
discord:
token: "your-discord-bot-token"
# Web Tool Keys
# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
# For GLMSearch, use 'api_key' (single string)
web:
brave:
api_keys:
- "BSAyour-brave-api-key-1"
- "BSAyour-brave-api-key-2" # Failover key
tavily:
api_keys:
- "tvly-your-tavily-api-key" # Single key in array format
glm_search:
api_key: "your-glm-search-api-key" # Single key (not array)
```
## 2. Update config.json to use references
File: ~/.picoclaw/config.json
```json
{
"version": 1,
"agents": {
"defaults": {
"workspace": "~/picoclaw-workspace",
"model_name": "gpt-5.4"
}
},
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api.openai.com/v1",
"api_key": "ref:model_list.gpt-5.4.api_key"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_base": "https://api.anthropic.com/v1",
"api_key": "ref:model_list.claude-sonnet-4.6.api_key"
}
],
"channels": {
"telegram": {
"enabled": true,
"token": "ref:channels.telegram.token"
},
"discord": {
"enabled": true,
"token": "ref:channels.discord.token"
}
},
"tools": {
"web": {
"brave": {
"enabled": true,
"api_key": "ref:web.brave.api_key"
},
"tavily": {
"enabled": true,
"api_key": "ref:web.tavily.api_key"
}
}
}
}
```
## 3. Set proper permissions
```bash
chmod 600 ~/.picoclaw/security.yml
```
## 4. Add to .gitignore
```gitignore
# Security configuration
.security.yml
```
## 5. Verify it works
```bash
picoclaw --version
```
# Available Reference Paths
## Model API Keys
- ref:model_list.<model_name>.api_key
Examples:
- ref:model_list.gpt-5.4.api_key
- ref:model_list.claude-sonnet-4.6.api_key
**Note:** In .security.yml, use `api_keys` (array) format for models.
Both single and multiple keys should use the array format.
## Channel Tokens/Secrets
- ref:channels.telegram.token
- ref:channels.feishu.app_secret
- ref:channels.feishu.encrypt_key
- ref:channels.feishu.verification_token
- ref:channels.discord.token
- ref:channels.qq.app_secret
- ref:channels.dingtalk.client_secret
- ref:channels.slack.bot_token
- ref:channels.slack.app_token
- ref:channels.matrix.access_token
- ref:channels.line.channel_secret
- ref:channels.line.channel_access_token
- ref:channels.onebot.access_token
- ref:channels.wecom.token
- ref:channels.wecom.encoding_aes_key
- ref:channels.wecom_app.corp_secret
- ref:channels.wecom_app.token
- ref:channels.wecom_app.encoding_aes_key
- ref:channels.wecom_aibot.token
- ref:channels.wecom_aibot.encoding_aes_key
- ref:channels.pico.token
- ref:channels.irc.password
- ref:channels.irc.nickserv_password
- ref:channels.irc.sasl_password
## Web Tool API Keys
- ref:web.brave.api_key
- ref:web.tavily.api_key
- ref:web.perplexity.api_key
- ref:web.glm_search.api_key
**Note:**
- Brave, Tavily, Perplexity: Use `api_keys` (array) format in .security.yml
- GLMSearch: Use `api_key` (single string) format in .security.yml
## Skills Registry Tokens
- ref:skills.github.token
- ref:skills.clawhub.auth_token
# Backward Compatibility
You can still use direct values in config.json if needed:
```json
{
"model_list": [
{
"model_name": "local-model",
"model": "ollama/llama3",
"api_base": "http://localhost:11434/v1",
"api_key": "ollama" // Direct value (no reference)
}
]
}
```
You can also mix references and direct values:
```json
{
"model_list": [
{
"model_name": "cloud-model",
"api_key": "ref:model_list.cloud-model.api_key" // From .security.yml
},
{
"model_name": "local-model",
"api_key": "ollama" // Direct value
}
]
}
```
# Migration from Old Config
## Step 1: Backup your config
```bash
cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup
```
## Step 2: Copy the example security file
```bash
cp security.example.yml ~/.picoclaw/.security.yml
```
## Step 3: Fill in your API keys
Edit ~/.picoclaw/.security.yml and replace placeholders with your actual keys.
## Step 4: Update config.json references
Replace sensitive values in ~/.picoclaw/config.json with ref: references.
## Step 5: Test
```bash
picoclaw --version
```
If everything works, you can delete the backup:
```bash
rm ~/.picoclaw/config.json.backup
```
# Advanced Features
## Multiple API Keys (Load Balancing & Failover)
You can configure multiple API keys for both models and web tools to enable:
- **Load balancing**: Requests are distributed across multiple keys
- **Failover**: If a key fails, the system automatically switches to another key
### Example: Model with Multiple Keys
**.security.yml:**
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-proj-key-1"
- "sk-proj-key-2"
- "sk-proj-key-3"
```
**config.json:**
```json
{
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "ref:model_list.gpt-5.4.api_key"
}
]
}
```
### Example: Web Tool with Multiple Keys
**.security.yml:**
```yaml
web:
brave:
api_keys:
- "BSA-key-1"
- "BSA-key-2"
tavily:
api_keys:
- "tvly-your-key" # Single key in array format
glm_search:
api_key: "your-glm-key" # GLMSearch uses single key format
```
**config.json:**
```json
{
"tools": {
"web": {
"brave": {
"enabled": true,
"api_key": "ref:web.brave.api_key"
}
}
}
}
```
### Single Key
Use array format with one element:
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-proj-your-key" # Single key in array format
```
### Multiple Keys (Load Balancing & Failover)
Use array format with multiple elements:
```yaml
model_list:
gpt-5.4:
api_keys:
- "sk-proj-key-1"
- "sk-proj-key-2"
- "sk-proj-key-3"
```
**Important:** All model keys in .security.yml must use the `api_keys` (plural) array format.
The single `api_key` (singular) format is NOT supported for models.
### Model Index Matching
The system supports intelligent model name matching in .security.yml:
**Example 1: Exact Match**
```yaml
# config.json
{
"model_name": "gpt-5.4:0"
}
# .security.yml (exact match with index)
model_list:
gpt-5.4:0:
api_keys: ["key-1"]
```
**Example 2: Base Name Match**
```yaml
# config.json
{
"model_name": "gpt-5.4:0"
}
# .security.yml (base name without index)
model_list:
gpt-5.4:
api_keys: ["key-1"]
```
Both methods work. The base name match allows you to use simpler keys in .security.yml
even when your config uses indexed model names for load balancing.
### Security File Permissions
The security file should have restricted permissions:
```bash
chmod 600 ~/.picoclaw/.security.yml
```
This ensures only the owner can read and write the file.
# Security Best Practices
1. Never commit .security.yml to version control
2. Set file permissions: chmod 600 ~/.picoclaw/.security.yml
3. Use different keys for different environments
4. Rotate keys regularly and update .security.yml
5. Encrypt backups containing .security.yml
# Troubleshooting
## Error: "model security entry not found"
- Check that the model name in config.json matches exactly in .security.yml
- Verify the model_list section exists in .security.yml
## Error: "failed to load security config"
- Ensure .security.yml exists in the same directory as config.json
- Check YAML syntax is valid
- Verify file permissions allow reading
## Error: "unknown reference path"
- Verify the reference format is correct
- Check the path structure matches the examples above
- Ensure all required sections exist in .security.yml
*/
package config
// This file is documentation only
+141 -156
View File
@@ -6,10 +6,15 @@
package config
import (
"encoding/json"
"slices"
"strings"
)
type migratable interface {
Migrate() (*Config, error)
}
// buildModelWithProtocol constructs a model string with protocol prefix.
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
// Otherwise, the protocol prefix is added.
@@ -21,31 +26,31 @@ func buildModelWithProtocol(protocol, model string) string {
return protocol + "/" + model
}
// providerMigrationConfig defines how to migrate a provider from old config to new format.
type providerMigrationConfig struct {
// providerNames are the possible names used in agents.defaults.provider
providerNames []string
// protocol is the protocol prefix for the model field
protocol string
// buildConfig creates the ModelConfig from ProviderConfig
buildConfig func(p ProvidersConfig) (ModelConfig, bool)
}
// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.
// v0ConvertProvidersToModelList converts the old providersConfigV0 to a slice of ModelConfig.
// This enables backward compatibility with existing configurations.
// It preserves the user's configured model from agents.defaults.model when possible.
func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
func v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 {
if cfg == nil {
return nil
}
// providerMigrationConfig defines how to migrate a provider from old config to new format.
type providerMigrationConfig struct {
// providerNames are the possible names used in agents.defaults.provider
providerNames []string
// protocol is the protocol prefix for the model field
protocol string
// buildConfig creates the ModelConfig from ProviderConfig
buildConfig func(p providersConfigV0) (modelConfigV0, bool)
}
// Get user's configured provider and model
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
userModel := cfg.Agents.Defaults.GetModelName()
p := cfg.Providers
var result []ModelConfig
var result []modelConfigV0
// Track if we've applied the legacy model name fix (only for first provider)
legacyModelNameApplied := false
@@ -55,11 +60,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"openai", "gpt"},
protocol: "openai",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "openai",
Model: "openai/gpt-5.4",
APIKey: p.OpenAI.APIKey,
@@ -73,11 +78,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"anthropic", "claude"},
protocol: "anthropic",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "anthropic",
Model: "anthropic/claude-sonnet-4.6",
APIKey: p.Anthropic.APIKey,
@@ -91,11 +96,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"litellm"},
protocol: "litellm",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "litellm",
Model: "litellm/auto",
APIKey: p.LiteLLM.APIKey,
@@ -108,11 +113,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"openrouter"},
protocol: "openrouter",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "openrouter",
Model: "openrouter/auto",
APIKey: p.OpenRouter.APIKey,
@@ -125,11 +130,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"groq"},
protocol: "groq",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Groq.APIKey == "" && p.Groq.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "groq",
Model: "groq/llama-3.1-70b-versatile",
APIKey: p.Groq.APIKey,
@@ -142,11 +147,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"zhipu", "glm"},
protocol: "zhipu",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "zhipu",
Model: "zhipu/glm-4",
APIKey: p.Zhipu.APIKey,
@@ -159,11 +164,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"vllm"},
protocol: "vllm",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "vllm",
Model: "vllm/auto",
APIKey: p.VLLM.APIKey,
@@ -176,11 +181,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"gemini", "google"},
protocol: "gemini",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "gemini",
Model: "gemini/gemini-pro",
APIKey: p.Gemini.APIKey,
@@ -193,11 +198,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"nvidia"},
protocol: "nvidia",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "nvidia",
Model: "nvidia/meta/llama-3.1-8b-instruct",
APIKey: p.Nvidia.APIKey,
@@ -210,11 +215,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"ollama"},
protocol: "ollama",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "ollama",
Model: "ollama/llama3",
APIKey: p.Ollama.APIKey,
@@ -227,11 +232,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"moonshot", "kimi"},
protocol: "moonshot",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "moonshot",
Model: "moonshot/kimi",
APIKey: p.Moonshot.APIKey,
@@ -244,11 +249,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"shengsuanyun"},
protocol: "shengsuanyun",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "shengsuanyun",
Model: "shengsuanyun/auto",
APIKey: p.ShengSuanYun.APIKey,
@@ -261,11 +266,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"deepseek"},
protocol: "deepseek",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "deepseek",
Model: "deepseek/deepseek-chat",
APIKey: p.DeepSeek.APIKey,
@@ -278,11 +283,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"cerebras"},
protocol: "cerebras",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "cerebras",
Model: "cerebras/llama-3.3-70b",
APIKey: p.Cerebras.APIKey,
@@ -295,11 +300,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"vivgrid"},
protocol: "vivgrid",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "vivgrid",
Model: "vivgrid/auto",
APIKey: p.Vivgrid.APIKey,
@@ -312,11 +317,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"volcengine", "doubao"},
protocol: "volcengine",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "volcengine",
Model: "volcengine/doubao-pro",
APIKey: p.VolcEngine.APIKey,
@@ -329,11 +334,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"github_copilot", "copilot"},
protocol: "github-copilot",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "github-copilot",
Model: "github-copilot/gpt-5.4",
APIBase: p.GitHubCopilot.APIBase,
@@ -344,11 +349,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"antigravity"},
protocol: "antigravity",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "antigravity",
Model: "antigravity/gemini-2.0-flash",
APIKey: p.Antigravity.APIKey,
@@ -359,11 +364,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"qwen", "tongyi"},
protocol: "qwen",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "qwen",
Model: "qwen/qwen-max",
APIKey: p.Qwen.APIKey,
@@ -376,11 +381,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"mistral"},
protocol: "mistral",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "mistral",
Model: "mistral/mistral-small-latest",
APIKey: p.Mistral.APIKey,
@@ -393,11 +398,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"avian"},
protocol: "avian",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Avian.APIKey == "" && p.Avian.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "avian",
Model: "avian/deepseek/deepseek-v3.2",
APIKey: p.Avian.APIKey,
@@ -410,11 +415,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"longcat"},
protocol: "longcat",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: p.LongCat.APIKey,
@@ -427,11 +432,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"modelscope"},
protocol: "modelscope",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" {
return ModelConfig{}, false
return modelConfigV0{}, false
}
return ModelConfig{
return modelConfigV0{
ModelName: "modelscope",
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
APIKey: p.ModelScope.APIKey,
@@ -469,83 +474,63 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return result
}
// protocolProviderMapping maps a model protocol prefix (the part before "/" in
// the Model field) to a function that extracts the corresponding ProviderConfig
// from the legacy ProvidersConfig. Used by InheritProviderCredentials.
var protocolProviderMapping = map[string]func(p ProvidersConfig) ProviderConfig{
"openai": func(p ProvidersConfig) ProviderConfig { return p.OpenAI.ProviderConfig },
"anthropic": func(p ProvidersConfig) ProviderConfig { return p.Anthropic },
"litellm": func(p ProvidersConfig) ProviderConfig { return p.LiteLLM },
"openrouter": func(p ProvidersConfig) ProviderConfig { return p.OpenRouter },
"groq": func(p ProvidersConfig) ProviderConfig { return p.Groq },
"zhipu": func(p ProvidersConfig) ProviderConfig { return p.Zhipu },
"vllm": func(p ProvidersConfig) ProviderConfig { return p.VLLM },
"gemini": func(p ProvidersConfig) ProviderConfig { return p.Gemini },
"nvidia": func(p ProvidersConfig) ProviderConfig { return p.Nvidia },
"ollama": func(p ProvidersConfig) ProviderConfig { return p.Ollama },
"moonshot": func(p ProvidersConfig) ProviderConfig { return p.Moonshot },
"shengsuanyun": func(p ProvidersConfig) ProviderConfig { return p.ShengSuanYun },
"deepseek": func(p ProvidersConfig) ProviderConfig { return p.DeepSeek },
"cerebras": func(p ProvidersConfig) ProviderConfig { return p.Cerebras },
"vivgrid": func(p ProvidersConfig) ProviderConfig { return p.Vivgrid },
"volcengine": func(p ProvidersConfig) ProviderConfig { return p.VolcEngine },
"github-copilot": func(p ProvidersConfig) ProviderConfig { return p.GitHubCopilot },
"antigravity": func(p ProvidersConfig) ProviderConfig { return p.Antigravity },
"qwen": func(p ProvidersConfig) ProviderConfig { return p.Qwen },
"mistral": func(p ProvidersConfig) ProviderConfig { return p.Mistral },
"avian": func(p ProvidersConfig) ProviderConfig { return p.Avian },
"minimax": func(p ProvidersConfig) ProviderConfig { return p.Minimax },
"longcat": func(p ProvidersConfig) ProviderConfig { return p.LongCat },
"modelscope": func(p ProvidersConfig) ProviderConfig { return p.ModelScope },
"novita": func(p ProvidersConfig) ProviderConfig { return p.Novita },
}
// InheritProviderCredentials fills in missing api_key, api_base, proxy, and
// request_timeout on model_list entries from the matching legacy providers
// configuration. The match is determined by the protocol prefix in the Model
// field (e.g. "deepseek/deepseek-chat" matches providers.deepseek).
//
// Only empty fields are filled — any value explicitly set on a model_list entry
// takes precedence. This function modifies the slice in place.
//
// This bridges the gap described in issue #1635: users who configure
// credentials once in the providers section expect model_list entries using
// the same protocol to "just work" without duplicating credentials.
func InheritProviderCredentials(models []ModelConfig, providers ProvidersConfig) {
if providers.IsEmpty() {
return
// loadConfigV0 loads a legacy config (no version field)
func loadConfigV0(data []byte) (migratable, error) {
var v0 configV0
if err := json.Unmarshal(data, &v0); err != nil {
return nil, err
}
for i := range models {
m := &models[i]
v0.migrateChannelConfigs()
// Extract protocol prefix from Model field
protocol := ""
if idx := strings.Index(m.Model, "/"); idx > 0 {
protocol = strings.ToLower(m.Model[:idx])
}
if protocol == "" {
continue
}
getProvider, ok := protocolProviderMapping[protocol]
if !ok {
continue
}
pc := getProvider(providers)
// Only fill empty fields — explicit model_list values win
if m.APIKey == "" && pc.APIKey != "" {
m.APIKey = pc.APIKey
}
if m.APIBase == "" && pc.APIBase != "" {
m.APIBase = pc.APIBase
}
if m.Proxy == "" && pc.Proxy != "" {
m.Proxy = pc.Proxy
}
if m.RequestTimeout == 0 && pc.RequestTimeout != 0 {
m.RequestTimeout = pc.RequestTimeout
// Auto-migrate: if only legacy providers config exists, convert to model_list
if len(v0.ModelList) == 0 && !v0.Providers.IsEmpty() {
newModelList := v0ConvertProvidersToModelList(&v0)
// Convert []ModelConfig to []modelConfigV0
v0.ModelList = make([]modelConfigV0, len(newModelList))
for i, m := range newModelList {
v0.ModelList[i] = modelConfigV0{
ModelName: m.ModelName,
Model: m.Model,
APIBase: m.APIBase,
Proxy: m.Proxy,
Fallbacks: m.Fallbacks,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
APIKey: m.APIKey,
APIKeys: m.APIKeys,
}
}
}
return &v0, nil
}
// loadConfigV1 loads a version 1 config (current schema)
func loadConfig(data []byte) (*Config, error) {
cfg := DefaultConfig()
// Pre-scan the JSON to check how many model_list entries the user provided.
// Go's JSON decoder reuses existing slice backing-array elements rather than
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
// would silently inherit values from the DefaultConfig template at the same
// index position. We only reset cfg.ModelList when the user actually provides
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
var tmp Config
if err := json.Unmarshal(data, &tmp); err != nil {
return nil, err
}
if len(tmp.ModelList) > 0 {
cfg.ModelList = nil
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
return cfg, nil
}
+568
View File
@@ -0,0 +1,568 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// TestMigration_Integration_LegacyConfigWithoutWorkspace tests the issue reported:
// User configured Model and Provider but no Workspace - settings should not be lost
func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) {
// Create a temporary directory for test config files
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
// Create a legacy config (version 0) with Model and Provider but NO Workspace
// This simulates the real-world scenario where user settings would be lost
legacyConfig := `{
"agents": {
"defaults": {
"provider": "openai",
"model": "gpt-4o",
"max_tokens": 8192,
"temperature": 0.7
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "test-token"
}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"tools": {
"web": {
"enabled": true
}
},
"heartbeat": {
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false
}
}`
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
t.Fatalf("Failed to write legacy config: %v", err)
}
// Load the config - this should trigger migration
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
// Verify version is updated
if cfg.Version != CurrentVersion {
t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
}
// CRITICAL: Verify that user's settings are preserved
// This was the bug - these settings were lost when Workspace was empty
if cfg.Agents.Defaults.Provider != "openai" {
t.Errorf("Provider = %q, want %q (user's setting should be preserved)", cfg.Agents.Defaults.Provider, "openai")
}
// Old "model" field is migrated to "model_name" field
if cfg.Agents.Defaults.ModelName != "gpt-4o" {
t.Errorf(
"ModelName = %q, want %q (user's setting should be preserved)",
cfg.Agents.Defaults.ModelName, "gpt-4o",
)
}
// GetModelName() should also return the migrated value
if cfg.Agents.Defaults.GetModelName() != "gpt-4o" {
t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "gpt-4o")
}
if cfg.Agents.Defaults.MaxTokens != 8192 {
t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 8192)
}
if cfg.Agents.Defaults.Temperature == nil {
t.Error("Temperature should not be nil")
} else if *cfg.Agents.Defaults.Temperature != 0.7 {
t.Errorf("Temperature = %v, want %v", *cfg.Agents.Defaults.Temperature, 0.7)
}
// Verify Workspace has a default value (should not be empty)
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should have a default value, not be empty")
}
// Verify other config sections are preserved
if !cfg.Channels.Telegram.Enabled {
t.Error("Telegram.Enabled should be true")
}
if cfg.Channels.Telegram.Token() != "test-token" {
t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token(), "test-token")
}
if cfg.Gateway.Port != 18790 {
t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 18790)
}
}
// TestMigration_Integration_LegacyConfigWithWorkspace tests migration with Workspace set
func TestMigration_Integration_LegacyConfigWithWorkspace(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
legacyConfig := `{
"agents": {
"defaults": {
"workspace": "/custom/workspace",
"provider": "deepseek",
"model": "deepseek-chat",
"max_tokens": 16384
}
},
"channels": {
"telegram": {
"enabled": false
}
},
"gateway": {
"host": "0.0.0.0",
"port": 8080
},
"tools": {
"web": {
"enabled": false
}
},
"heartbeat": {
"enabled": false
},
"devices": {
"enabled": true
}
}`
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
t.Fatalf("Failed to write legacy config: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
// All user settings should be preserved
if cfg.Agents.Defaults.Workspace != "/custom/workspace" {
t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "/custom/workspace")
}
if cfg.Agents.Defaults.Provider != "deepseek" {
t.Errorf("Provider = %q, want %q", cfg.Agents.Defaults.Provider, "deepseek")
}
if cfg.Agents.Defaults.ModelName != "deepseek-chat" {
t.Errorf("ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "deepseek-chat")
}
if cfg.Agents.Defaults.MaxTokens != 16384 {
t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 16384)
}
// Verify other settings
if cfg.Gateway.Port != 8080 {
t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 8080)
}
if !cfg.Devices.Enabled {
t.Error("Devices.Enabled should be true")
}
}
// TestMigration_Integration_PreservesAllAgentsFields tests that ALL Agents fields are preserved
func TestMigration_Integration_PreservesAllAgentsFields(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
legacyConfig := `{
"agents": {
"defaults": {
"workspace": "",
"restrict_to_workspace": false,
"allow_read_outside_workspace": true,
"provider": "anthropic",
"model": "claude-opus-4",
"model_fallbacks": ["claude-sonnet-4", "claude-haiku-4"],
"image_model": "claude-opus-4-vision",
"image_model_fallbacks": ["claude-sonnet-4-vision"],
"max_tokens": 4096,
"temperature": 0.5,
"max_tool_iterations": 100,
"summarize_message_threshold": 30,
"summarize_token_percent": 80,
"max_media_size": 10485760
},
"list": [
{
"id": "special-agent",
"default": false,
"name": "Special Agent",
"workspace": "/special/workspace"
}
]
},
"channels": {
"telegram": {"enabled": false}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"tools": {
"web": {"enabled": true}
},
"heartbeat": {
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false
}
}`
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
t.Fatalf("Failed to write legacy config: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
// Verify ALL defaults fields are preserved
d := cfg.Agents.Defaults
if d.RestrictToWorkspace != false {
t.Errorf("RestrictToWorkspace = %v, want false", d.RestrictToWorkspace)
}
if d.AllowReadOutsideWorkspace != true {
t.Errorf("AllowReadOutsideWorkspace = %v, want true", d.AllowReadOutsideWorkspace)
}
if d.Provider != "anthropic" {
t.Errorf("Provider = %q, want %q", d.Provider, "anthropic")
}
if d.ModelName != "claude-opus-4" {
t.Errorf("ModelName = %q, want %q", d.ModelName, "claude-opus-4")
}
if len(d.ModelFallbacks) != 2 {
t.Errorf("len(ModelFallbacks) = %d, want 2", len(d.ModelFallbacks))
} else {
if d.ModelFallbacks[0] != "claude-sonnet-4" {
t.Errorf("ModelFallbacks[0] = %q, want %q", d.ModelFallbacks[0], "claude-sonnet-4")
}
if d.ModelFallbacks[1] != "claude-haiku-4" {
t.Errorf("ModelFallbacks[1] = %q, want %q", d.ModelFallbacks[1], "claude-haiku-4")
}
}
if d.ImageModel != "claude-opus-4-vision" {
t.Errorf("ImageModel = %q, want %q", d.ImageModel, "claude-opus-4-vision")
}
if len(d.ImageModelFallbacks) != 1 {
t.Errorf("len(ImageModelFallbacks) = %d, want 1", len(d.ImageModelFallbacks))
} else if d.ImageModelFallbacks[0] != "claude-sonnet-4-vision" {
t.Errorf("ImageModelFallbacks[0] = %q, want %q", d.ImageModelFallbacks[0], "claude-sonnet-4-vision")
}
if d.MaxTokens != 4096 {
t.Errorf("MaxTokens = %d, want %d", d.MaxTokens, 4096)
}
if d.Temperature == nil || *d.Temperature != 0.5 {
t.Errorf("Temperature = %v, want 0.5", d.Temperature)
}
if d.MaxToolIterations != 100 {
t.Errorf("MaxToolIterations = %d, want %d", d.MaxToolIterations, 100)
}
if d.SummarizeMessageThreshold != 30 {
t.Errorf("SummarizeMessageThreshold = %d, want %d", d.SummarizeMessageThreshold, 30)
}
if d.SummarizeTokenPercent != 80 {
t.Errorf("SummarizeTokenPercent = %d, want %d", d.SummarizeTokenPercent, 80)
}
if d.MaxMediaSize != 10485760 {
t.Errorf("MaxMediaSize = %d, want %d", d.MaxMediaSize, 10485760)
}
// Verify agent list is preserved
if len(cfg.Agents.List) != 1 {
t.Fatalf("len(Agents.List) = %d, want 1", len(cfg.Agents.List))
}
if cfg.Agents.List[0].ID != "special-agent" {
t.Errorf("Agent.ID = %q, want %q", cfg.Agents.List[0].ID, "special-agent")
}
if cfg.Agents.List[0].Workspace != "/special/workspace" {
t.Errorf("Agent.Workspace = %q, want %q", cfg.Agents.List[0].Workspace, "/special/workspace")
}
// Workspace should have default since it was empty in legacy config
if d.Workspace == "" {
t.Error("Workspace should have a default value, not be empty")
}
}
// TestMigration_Integration_ChannelsConfigMigrated tests channel config migration
func TestMigration_Integration_ChannelsConfigMigrated(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
// Legacy config with old channel field formats
legacyConfig := `{
"agents": {
"defaults": {}
},
"channels": {
"discord": {
"enabled": true,
"token": "discord-token",
"mention_only": true
},
"onebot": {
"enabled": true,
"ws_url": "ws://127.0.0.1:3001",
"group_trigger_prefix": ["/", "!"]
}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"tools": {
"web": {"enabled": true}
},
"heartbeat": {
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false
}
}`
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
t.Fatalf("Failed to write legacy config: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
// Discord: mention_only should be migrated to group_trigger.mention_only
if cfg.Channels.Discord.GroupTrigger.MentionOnly != true {
t.Error("Discord.GroupTrigger.MentionOnly should be true after migration")
}
// OneBot: group_trigger_prefix should be migrated to group_trigger.prefixes
if len(cfg.Channels.OneBot.GroupTrigger.Prefixes) != 2 {
t.Errorf("len(OneBot.GroupTrigger.Prefixes) = %d, want 2", len(cfg.Channels.OneBot.GroupTrigger.Prefixes))
} else {
if cfg.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" {
t.Errorf("Prefixes[0] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[0], "/")
}
if cfg.Channels.OneBot.GroupTrigger.Prefixes[1] != "!" {
t.Errorf("Prefixes[1] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[1], "!")
}
}
}
// TestMigration_Integration_RoundTrip_SerializeAndLoad tests that migrated config can be saved and reloaded
func TestMigration_Integration_RoundTrip_SerializeAndLoad(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
legacyConfig := `{
"agents": {
"defaults": {
"provider": "openai",
"model": "gpt-4o",
"max_tokens": 8192
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "test-token"
}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"tools": {
"web": {"enabled": true}
},
"heartbeat": {
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false
}
}`
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
t.Fatalf("Failed to write legacy config: %v", err)
}
// First load - triggers migration and saves
cfg1, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("First LoadConfig failed: %v", err)
}
// Read the migrated config from disk
migratedData, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read migrated config: %v", err)
}
// Verify it has the current version
var versionCheck struct {
Version int `json:"version"`
}
if err = json.Unmarshal(migratedData, &versionCheck); err != nil {
t.Fatalf("Failed to parse migrated config version: %v", err)
}
if versionCheck.Version != CurrentVersion {
t.Errorf("Migrated config version = %d, want %d", versionCheck.Version, CurrentVersion)
}
// Second load - should load the migrated config without changes
cfg2, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("Second LoadConfig failed: %v", err)
}
// Verify configs are identical
if cfg2.Agents.Defaults.Provider != cfg1.Agents.Defaults.Provider {
t.Errorf("Provider changed from %q to %q", cfg1.Agents.Defaults.Provider, cfg2.Agents.Defaults.Provider)
}
if cfg2.Agents.Defaults.ModelName != cfg1.Agents.Defaults.ModelName {
t.Errorf("ModelName changed from %q to %q", cfg1.Agents.Defaults.ModelName, cfg2.Agents.Defaults.ModelName)
}
if cfg2.Agents.Defaults.MaxTokens != cfg1.Agents.Defaults.MaxTokens {
t.Errorf("MaxTokens changed from %d to %d", cfg1.Agents.Defaults.MaxTokens, cfg2.Agents.Defaults.MaxTokens)
}
}
// TestMigration_Integration_EmptyAgentsDefaults tests migration with completely empty agents config
func TestMigration_Integration_EmptyAgentsDefaults(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
// Legacy config with empty agents defaults
legacyConfig := `{
"agents": {
"defaults": {}
},
"channels": {
"telegram": {"enabled": false}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"tools": {
"web": {"enabled": true}
},
"heartbeat": {
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false
}
}`
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
t.Fatalf("Failed to write legacy config: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
// Workspace should have default value
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should have a default value")
}
// Note: When fields are explicitly set in config (even to zero values),
// they override defaults. This is correct JSON unmarshaling behavior.
// Users should set values they want; defaults are for unspecified fields.
if cfg.Agents.Defaults.MaxTokens == 0 {
// This is expected when users don't set max_tokens in their config
// The zero value (0) from the legacy config is preserved
}
if cfg.Agents.Defaults.MaxToolIterations == 0 {
// Same as above - zero value is preserved if it was in the config
}
}
// TestMigration_Integration_ModelNameField tests migration using new model_name field
func TestMigration_Integration_ModelNameField(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
// Legacy config using the new model_name field
legacyConfig := `{
"agents": {
"defaults": {
"provider": "deepseek",
"model_name": "deepseek-reasoner",
"model_fallbacks": ["deepseek-chat"]
}
},
"channels": {
"telegram": {"enabled": false}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"tools": {
"web": {"enabled": true}
},
"heartbeat": {
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false
}
}`
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
t.Fatalf("Failed to write legacy config: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
// model_name field should be preserved
if cfg.Agents.Defaults.ModelName != "deepseek-reasoner" {
t.Errorf("ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "deepseek-reasoner")
}
// GetModelName() should return model_name, not model (deprecated)
if cfg.Agents.Defaults.GetModelName() != "deepseek-reasoner" {
t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "deepseek-reasoner")
}
if len(cfg.Agents.Defaults.ModelFallbacks) != 1 {
t.Errorf("len(ModelFallbacks) = %d, want 1", len(cfg.Agents.Defaults.ModelFallbacks))
} else if cfg.Agents.Defaults.ModelFallbacks[0] != "deepseek-chat" {
t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat")
}
}
+143 -280
View File
@@ -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
View File
@@ -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
View File
@@ -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"},
+220
View File
@@ -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)
}
+472
View File
@@ -0,0 +1,472 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Test JSON unmarshal of private fields
func TestJSONUnmarshalPrivateFields(t *testing.T) {
//nolint: govet
type testStruct struct {
PublicField string `json:"public"`
privateField string `json:"private"`
}
data := `{"public": "pub", "private": "priv"}`
var s testStruct
if err := json.Unmarshal([]byte(data), &s); err != nil {
t.Fatalf("JSON unmarshal failed: %v", err)
}
t.Logf("PublicField: %s", s.PublicField)
t.Logf("privateField: %s", s.privateField)
if s.PublicField != "pub" {
t.Errorf("PublicField = %q, want 'pub'", s.PublicField)
}
// This should fail because privateField is unexported
if s.privateField != "priv" {
t.Logf("privateField = %q, want 'priv' - THIS IS EXPECTED TO FAIL", s.privateField)
}
}
func TestSecurityConfigIntegration(t *testing.T) {
t.Run("Full workflow with security references", func(t *testing.T) {
tmpDir := t.TempDir()
// Create config.json with references
configPath := filepath.Join(tmpDir, "config.json")
configContent := `{
"version": 1,
"model_list": [
{
"model_name": "test-model",
"model": "openai/test-model",
"api_base": "https://api.openai.com/v1",
"api_key": "ref:model_list.test-model.api_key"
}
],
"channels": {
"telegram": {
"enabled": true,
"token": "ref:channels.telegram.token"
}
},
"tools": {
"web": {
"brave": {
"enabled": true,
"api_key": "ref:web.brave.api_key"
}
},
"skills": {
"github": {
"token": "ref:skills.github.token"
}
}
}
}`
err := os.WriteFile(configPath, []byte(configContent), 0o644)
require.NoError(t, err)
// Create .security.yml with actual values
securityPath := filepath.Join(tmpDir, SecurityConfigFile)
securityContent := `model_list:
test-model:
api_keys:
- "sk-test-api-key-12345"
channels:
telegram:
token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
web:
brave:
api_keys:
- "BSAbrave-api-key-67890"
skills:
github:
token: "ghp_github-token-abc123"`
err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
require.NoError(t, err)
// Load config and verify references are resolved
cfg, err := LoadConfig(configPath)
require.NoError(t, err)
require.NotNil(t, cfg)
// Verify model API key is resolved
assert.Equal(t, 1, len(cfg.ModelList))
assert.Equal(t, "test-model", cfg.ModelList[0].ModelName)
assert.Equal(t, "sk-test-api-key-12345", cfg.ModelList[0].apiKeys[0])
// Verify channel token is resolved
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.token)
// Verify web tool API key is resolved
assert.Equal(t, "BSAbrave-api-key-67890", cfg.Tools.Web.Brave.APIKey())
// Verify skills token is resolved
assert.Equal(t, "ghp_github-token-abc123", cfg.Tools.Skills.Github.token)
})
}
func TestSecurityConfigWithAPIKeysArray(t *testing.T) {
t.Run("Multiple API keys via security", func(t *testing.T) {
tmpDir := t.TempDir()
// Create config with APIKeys array
configPath := filepath.Join(tmpDir, "config.json")
configContent := `{
"version": 1,
"model_list": [
{
"model_name": "multi-key-model",
"model": "openai/multi-key-model"
}
]
}`
err := os.WriteFile(configPath, []byte(configContent), 0o644)
require.NoError(t, err)
// Create .security.yml
securityPath := filepath.Join(tmpDir, SecurityConfigFile)
securityContent := `model_list:
multi-key-model:0:
api_key: "sk-key-1"
api_keys:
- "sk-key-1"
- "sk-key-2"
- "sk-key-3"
`
err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
require.NoError(t, err)
// Load config
cfg, err := LoadConfig(configPath)
require.NoError(t, err)
t.Logf("Config: %+v", cfg.ModelList)
for _, m := range cfg.ModelList {
t.Logf("Model: %+v", m)
}
// Verify multi-key expansion works
assert.Equal(t, 3, len(cfg.ModelList))
assert.Equal(t, "multi-key-model", cfg.ModelList[2].ModelName)
})
}
func TestAllSecurityKeysAccessible(t *testing.T) {
t.Run("All security keys accessible via Key() methods including file://", func(t *testing.T) {
tmpDir := t.TempDir()
// Create test files for file:// references
modelAPIKeyFile := filepath.Join(tmpDir, "model_api_key.txt")
err := os.WriteFile(modelAPIKeyFile, []byte("sk-model-from-file-12345"), 0o600)
require.NoError(t, err)
braveAPIKeyFile := filepath.Join(tmpDir, "brave_api_key.txt")
err = os.WriteFile(braveAPIKeyFile, []byte("BSA-brave-from-file-67890"), 0o600)
require.NoError(t, err)
tavilyAPIKeyFile := filepath.Join(tmpDir, "tavily_api_key.txt")
err = os.WriteFile(tavilyAPIKeyFile, []byte("tvly-tavily-from-file-11111"), 0o600)
require.NoError(t, err)
perplexityAPIKeyFile := filepath.Join(tmpDir, "perplexity_api_key.txt")
err = os.WriteFile(perplexityAPIKeyFile, []byte("pplx-perplexity-from-file-22222"), 0o600)
require.NoError(t, err)
githubTokenFile := filepath.Join(tmpDir, "github_token.txt")
err = os.WriteFile(githubTokenFile, []byte("ghp-github-from-file-abc123"), 0o600)
require.NoError(t, err)
clawhubAuthTokenFile := filepath.Join(tmpDir, "clawhub_auth_token.txt")
err = os.WriteFile(clawhubAuthTokenFile, []byte("clawhub-auth-token-from-file"), 0o600)
require.NoError(t, err)
// Create config.json without sensitive values (they'll be in .security.yml)
configPath := filepath.Join(tmpDir, "config.json")
configContent := `{
"version": 1,
"model_list": [
{
"model_name": "test-model-1",
"model": "openai/test-model-1"
}
],
"channels": {
"telegram": {
"enabled": true
},
"feishu": {
"enabled": true,
"app_id": "test_app_id"
},
"discord": {
"enabled": true
},
"dingtalk": {
"enabled": true,
"client_id": "test_client_id"
},
"slack": {
"enabled": true
},
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"user_id": "@test:matrix.org"
},
"line": {
"enabled": true,
"webhook_host": "localhost",
"webhook_port": 8080,
"webhook_path": "/webhook"
},
"onebot": {
"enabled": true,
"ws_url": "ws://localhost:8080"
},
"wecom": {
"enabled": true,
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook"
},
"wecom_app": {
"enabled": true,
"corp_id": "test_corp_id",
"agent_id": 123456
},
"wecom_aibot": {
"enabled": true
},
"pico": {
"enabled": true
},
"irc": {
"enabled": true,
"server": "irc.example.com",
"nick": "testbot"
},
"qq": {
"enabled": true,
"app_id": "test_qq_app_id"
}
},
"tools": {
"web": {
"brave": {
"enabled": true
},
"tavily": {
"enabled": true
},
"perplexity": {
"enabled": true
},
"glm_search": {
"enabled": true
}
},
"skills": {
"github": {}
}
}
}`
err = os.WriteFile(configPath, []byte(configContent), 0o644)
require.NoError(t, err)
// Create .security.yml with file:// references and plaintext values
securityPath := filepath.Join(tmpDir, SecurityConfigFile)
securityContent := `model_list:
test-model-1:
api_keys:
- "file://model_api_key.txt"
channels:
telegram:
token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
feishu:
app_secret: "feishu_test_app_secret"
encrypt_key: "feishu_test_encrypt_key"
verification_token: "feishu_test_verification_token"
discord:
token: "discord_test_bot_token_xyz"
dingtalk:
client_secret: "dingtalk_test_client_secret"
slack:
bot_token: "xoxb-slack-bot-token-123"
app_token: "xapp-slack-app-token-456"
matrix:
access_token: "matrix_test_access_token"
line:
channel_secret: "line_test_channel_secret"
channel_access_token: "line_test_channel_access_token"
onebot:
access_token: "onebot_test_access_token"
wecom:
token: "wecom_test_webhook_token"
encoding_aes_key: "wecom_test_aes_key"
wecom_app:
corp_secret: "wecom_app_test_corp_secret"
token: "wecom_app_test_token"
encoding_aes_key: "wecom_app_test_aes_key"
wecom_aibot:
token: "wecom_aibot_test_token"
encoding_aes_key: "wecom_aibot_test_aes_key"
pico:
token: "pico_test_token"
irc:
password: "irc_test_password"
nickserv_password: "irc_test_nickserv_password"
sasl_password: "irc_test_sasl_password"
qq:
app_secret: "qq_test_app_secret"
web:
brave:
api_keys:
- "file://brave_api_key.txt"
tavily:
api_keys:
- "file://tavily_api_key.txt"
perplexity:
api_keys:
- "file://perplexity_api_key.txt"
glm_search:
api_key: "glm-test-glm-search-key"
skills:
github:
token: "file://github_token.txt"
clawhub:
auth_token: "file://clawhub_auth_token.txt"
`
err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
require.NoError(t, err)
// Load config and verify all security keys are accessible
cfg, err := LoadConfig(configPath)
require.NoError(t, err)
require.NotNil(t, cfg)
// Verify Model API keys
assert.Equal(t, 1, len(cfg.ModelList))
assert.Equal(t, "test-model-1", cfg.ModelList[0].ModelName)
// file:// reference should be resolved
assert.Equal(t, "sk-model-from-file-12345", cfg.ModelList[0].APIKey())
t.Logf("Model APIKey(): %s", cfg.ModelList[0].APIKey())
// Verify Channel tokens via Key() methods
// Telegram
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token())
t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token())
// Feishu
assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret())
assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey())
assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken())
t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret())
t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey())
t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken())
// Discord
assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token())
t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token())
// DingTalk
assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret())
t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret())
// Slack
assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken())
assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken())
t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken())
t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken())
// Matrix
assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken())
t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken())
// LINE
assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret())
assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken())
t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret())
t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken())
// OneBot
assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken())
t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken())
// WeCom
assert.Equal(t, "wecom_test_webhook_token", cfg.Channels.WeCom.Token())
assert.Equal(t, "wecom_test_aes_key", cfg.Channels.WeCom.EncodingAESKey())
t.Logf("WeCom Token(): %s", cfg.Channels.WeCom.Token())
t.Logf("WeCom EncodingAESKey(): %s", cfg.Channels.WeCom.EncodingAESKey())
// WeCom App
assert.Equal(t, "wecom_app_test_corp_secret", cfg.Channels.WeComApp.CorpSecret())
assert.Equal(t, "wecom_app_test_token", cfg.Channels.WeComApp.Token())
assert.Equal(t, "wecom_app_test_aes_key", cfg.Channels.WeComApp.EncodingAESKey())
t.Logf("WeComApp CorpSecret(): %s", cfg.Channels.WeComApp.CorpSecret())
t.Logf("WeComApp Token(): %s", cfg.Channels.WeComApp.Token())
t.Logf("WeComApp EncodingAESKey(): %s", cfg.Channels.WeComApp.EncodingAESKey())
// WeCom AI Bot
assert.Equal(t, "wecom_aibot_test_token", cfg.Channels.WeComAIBot.Token())
assert.Equal(t, "wecom_aibot_test_aes_key", cfg.Channels.WeComAIBot.EncodingAESKey())
t.Logf("WeComAIBot Token(): %s", cfg.Channels.WeComAIBot.Token())
t.Logf("WeComAIBot EncodingAESKey(): %s", cfg.Channels.WeComAIBot.EncodingAESKey())
// Pico
assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token())
t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token())
// IRC
assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password())
assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword())
assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword())
t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password())
t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword())
t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword())
// QQ
assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret())
t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret())
// Verify Web tool API keys
assert.Equal(t, "BSA-brave-from-file-67890", cfg.Tools.Web.Brave.APIKey())
t.Logf("Brave APIKey(): %s", cfg.Tools.Web.Brave.APIKey())
assert.Equal(t, "tvly-tavily-from-file-11111", cfg.Tools.Web.Tavily.APIKey())
t.Logf("Tavily APIKey(): %s", cfg.Tools.Web.Tavily.APIKey())
assert.Equal(t, "pplx-perplexity-from-file-22222", cfg.Tools.Web.Perplexity.APIKey())
t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey())
// GLM Search - Note: GLM uses SetAPIKey (lowercase) internally
t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey())
assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey())
// Verify Skills tokens
assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token())
t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token())
assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken())
t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken())
t.Log("All security keys are successfully accessible via their respective Key() methods")
})
}
+90
View File
@@ -0,0 +1,90 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSecurityConfig(t *testing.T) {
t.Run("LoadNonExistent", func(t *testing.T) {
sec, err := loadSecurityConfig("/nonexistent/.security.yml")
require.NoError(t, err)
assert.NotNil(t, sec)
assert.Empty(t, sec.ModelList)
})
}
func TestSecurityPath(t *testing.T) {
tests := []struct {
name string
configDir string
want string
}{
{
name: "standard path",
configDir: "/home/user/.picoclaw/config.json",
want: "/home/user/.picoclaw/.security.yml",
},
{
name: "nested path",
configDir: "/path/to/config/myconfig.json",
want: "/path/to/config/.security.yml",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := securityPath(tt.configDir)
assert.Equal(t, tt.want, got)
})
}
}
func TestSaveAndLoadSecurityConfig(t *testing.T) {
tmpDir := t.TempDir()
secPath := filepath.Join(tmpDir, SecurityConfigFile)
original := &SecurityConfig{
ModelList: map[string]ModelSecurityEntry{
"model1:0": {
APIKeys: []string{"key1", "key2"},
},
},
Channels: ChannelsSecurity{
Telegram: &TelegramSecurity{
Token: "telegram-token",
},
},
Web: WebToolsSecurity{
Brave: &BraveSecurity{
APIKeys: []string{"brave-api-key"},
},
},
}
// Save
err := saveSecurityConfig(secPath, original)
require.NoError(t, err)
// Verify file was created with correct permissions
info, err := os.Stat(secPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode())
// Load
loaded, err := loadSecurityConfig(secPath)
require.NoError(t, err)
assert.Equal(t, original.ModelList, loaded.ModelList)
assert.Equal(t, original.Channels.Telegram.Token, loaded.Channels.Telegram.Token)
assert.EqualValues(t, original.Web.Brave.APIKeys, loaded.Web.Brave.APIKeys)
}
+12
View File
@@ -0,0 +1,12 @@
// all environment variables including default values put here
package pkg
const (
Logo = "🦞"
// AppName is the name of the app
AppName = "PicoClaw"
DefaultPicoClawHome = ".picoclaw"
WorkspaceName = "workspace"
)
-3
View File
@@ -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)
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -20,7 +21,7 @@ func ResolveTargetHome(override string) (string, error) {
if err != nil {
return "", fmt.Errorf("resolving home directory: %w", err)
}
return filepath.Join(home, ".picoclaw"), nil
return filepath.Join(home, pkg.DefaultPicoClawHome), nil
}
func ExpandHome(path string) string {
+134 -69
View File
@@ -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 {
+8 -8
View File
@@ -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)
-393
View File
@@ -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
}
+12 -12
View File
@@ -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
+15 -14
View File
@@ -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
View File
@@ -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",
-17
View File
@@ -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")
+1 -1
View File
@@ -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,
},
+1 -1
View File
@@ -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,
})
+2 -6
View File
@@ -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
+74 -42
View File
@@ -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,
},
}
+15 -4
View File
@@ -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")
}
+2 -1
View File
@@ -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"]
}
]
}`))
+2 -2
View File
@@ -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
}
+15 -15
View File
@@ -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)
}
+8 -8
View File
@@ -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)
}
+2 -2
View File
@@ -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")
+6 -9
View File
@@ -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)
+9 -5
View File
@@ -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",
+5 -16
View File
@@ -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{}
}
}
+9 -7
View File
@@ -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 {
+5 -5
View File
@@ -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,
+6 -6
View File
@@ -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")
}
}