mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-05-25 16:00:35 +00:00
667fc85d54
add new field type to Channel struct config.channels refactor to channel_list update config version to 3 update the docs
398 lines
12 KiB
Go
398 lines
12 KiB
Go
// PicoClaw - Ultra-lightweight personal AI agent
|
|
// License: MIT
|
|
//
|
|
// Copyright (c) 2026 PicoClaw contributors
|
|
|
|
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Tests for buildModelWithProtocol helper function.
|
|
|
|
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
|
result := buildModelWithProtocol("openai", "gpt-5.4")
|
|
if result != "openai/gpt-5.4" {
|
|
t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
|
|
}
|
|
}
|
|
|
|
func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
|
|
result := buildModelWithProtocol("openrouter", "openrouter/auto")
|
|
if result != "openrouter/auto" {
|
|
t.Errorf("buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", result, "openrouter/auto")
|
|
}
|
|
}
|
|
|
|
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
|
|
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
|
|
if result != "openrouter/claude-sonnet-4.6" {
|
|
t.Errorf(
|
|
"buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q",
|
|
result,
|
|
"openrouter/claude-sonnet-4.6",
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// V0/V1/V2 → V3 migration tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestLoadConfig_V0MigrateProducesV2 verifies that V0→V3 migration produces
|
|
// correct Enabled fields and version.
|
|
func TestLoadConfig_V0MigrateProducesV2(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "config.json")
|
|
|
|
v0Config := `{
|
|
"model_list": [
|
|
{
|
|
"model_name": "gpt-4",
|
|
"model": "openai/gpt-4",
|
|
"api_key": "sk-test"
|
|
},
|
|
{
|
|
"model_name": "claude",
|
|
"model": "anthropic/claude"
|
|
},
|
|
{
|
|
"model_name": "local-model",
|
|
"model": "vllm/custom-model"
|
|
}
|
|
],
|
|
"gateway": {"host": "127.0.0.1", "port": 18790}
|
|
}`
|
|
|
|
if err := os.WriteFile(configPath, []byte(v0Config), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
cfg, err := LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
}
|
|
|
|
if cfg.Version != CurrentVersion {
|
|
t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
|
|
}
|
|
|
|
// Check enabled status
|
|
modelEnabled := func(name string) bool {
|
|
m, err := cfg.GetModelConfig(name)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return m.Enabled
|
|
}
|
|
|
|
if !modelEnabled("gpt-4") {
|
|
t.Error("gpt-4 with API key from V0 should be enabled")
|
|
}
|
|
if modelEnabled("claude") {
|
|
t.Error("claude without API key from V0 should be disabled")
|
|
}
|
|
if !modelEnabled("local-model") {
|
|
t.Error("local-model from V0 should be enabled")
|
|
}
|
|
}
|
|
|
|
// TestLoadConfig_UnsupportedVersion verifies that unsupported versions return an error.
|
|
func TestLoadConfig_UnsupportedVersion(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "config.json")
|
|
|
|
badConfig := `{"version": 99, "gateway": {"host": "127.0.0.1", "port": 18790}}`
|
|
if err := os.WriteFile(configPath, []byte(badConfig), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
_, err := LoadConfig(configPath)
|
|
if err == nil {
|
|
t.Fatal("LoadConfig should return error for unsupported version")
|
|
}
|
|
if !containsString(err.Error(), "unsupported config version") {
|
|
t.Errorf("error = %q, want 'unsupported config version'", err.Error())
|
|
}
|
|
}
|
|
|
|
func containsString(s, substr string) bool {
|
|
return len(s) >= len(substr) && searchString(s, substr)
|
|
}
|
|
|
|
func searchString(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestMigrateV0ToV3 verifies V0 (legacy, no version) → V3 migration.
|
|
// V0 configs use the old providers format without model_list.
|
|
func TestMigrateV0ToV3(t *testing.T) {
|
|
// V0 config: no version field, uses legacy providers
|
|
v0Config := `{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "openai",
|
|
"model": "gpt-4"
|
|
}
|
|
},
|
|
"providers": {
|
|
"openai": {
|
|
"api_key": "sk-test123",
|
|
"api_base": "https://api.openai.com/v1"
|
|
}
|
|
},
|
|
"channels": {
|
|
"telegram": {
|
|
"token": "bot-token"
|
|
},
|
|
"discord": {
|
|
"mention_only": true
|
|
}
|
|
}
|
|
}`
|
|
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "config.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(v0Config), 0o600))
|
|
m, err := loadConfigMap(configPath)
|
|
require.NoError(t, err)
|
|
|
|
err = migrateV0ToV1(m)
|
|
require.NoError(t, err)
|
|
err = migrateV1ToV2(m)
|
|
require.NoError(t, err)
|
|
err = migrateV2ToV3(m)
|
|
require.NoError(t, err)
|
|
|
|
// Version should be set to CurrentVersion
|
|
require.Equal(t, CurrentVersion, m["version"])
|
|
|
|
// Providers should be converted to model_list
|
|
modelList, ok := m["model_list"].([]any)
|
|
require.True(t, ok, "model_list should exist")
|
|
require.NotEmpty(t, modelList, "model_list should not be empty")
|
|
|
|
t.Logf("modelList: %+v", modelList)
|
|
// First model should be the user's configured provider with user's model
|
|
firstModel := modelList[0].(map[string]any)
|
|
require.Equal(t, "openai", firstModel["model_name"])
|
|
require.Equal(t, "openai/gpt-4", firstModel["model"])
|
|
// api_key is converted to api_keys during migration
|
|
require.Contains(t, firstModel, "api_keys", "api_keys should exist")
|
|
|
|
// Channels should be converted to nested format with channel_list
|
|
channelList, ok := m["channel_list"].(map[string]any)
|
|
require.True(t, ok, "channel_list should exist")
|
|
require.NotContains(t, m, "channels", "old 'channels' key should be removed")
|
|
|
|
// telegram channel should have settings
|
|
telegram := channelList["telegram"].(map[string]any)
|
|
require.Equal(t, "telegram", telegram["type"])
|
|
require.Contains(t, telegram, "settings", "telegram should have settings")
|
|
settings := telegram["settings"].(map[string]any)
|
|
require.Equal(t, "bot-token", settings["token"])
|
|
|
|
// discord channel should have group_trigger and mention_only in group_trigger
|
|
discord := channelList["discord"].(map[string]any)
|
|
require.Equal(t, "discord", discord["type"])
|
|
discordGroupTrigger := discord["group_trigger"].(map[string]any)
|
|
require.Equal(t, true, discordGroupTrigger["mention_only"])
|
|
}
|
|
|
|
// TestMigrateV0ToV3_WithExistingModelList preserves existing model_list when present.
|
|
func TestMigrateV0ToV3_WithExistingModelList(t *testing.T) {
|
|
v0Config := `{
|
|
"model_list": [
|
|
{"model_name": "custom", "model": "openai/custom-model", "api_key": "sk-existing"}
|
|
],
|
|
"channels": {
|
|
"telegram": {"token": "bot123"}
|
|
}
|
|
}`
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "config.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(v0Config), 0o600))
|
|
m, err := loadConfigMap(configPath)
|
|
require.NoError(t, err)
|
|
|
|
err = migrateV0ToV1(m)
|
|
require.NoError(t, err)
|
|
err = migrateV1ToV2(m)
|
|
require.NoError(t, err)
|
|
err = migrateV2ToV3(m)
|
|
require.NoError(t, err)
|
|
|
|
// Existing model_list should be preserved (not overridden by providers)
|
|
modelList := m["model_list"].([]any)
|
|
require.Len(t, modelList, 1)
|
|
firstModel := modelList[0].(map[string]any)
|
|
require.Equal(t, "custom", firstModel["model_name"])
|
|
}
|
|
|
|
// TestMigrateV1ToV3 verifies V1 → V3 migration.
|
|
// V1 uses flat channel format without "settings" wrapper.
|
|
func TestMigrateV1ToV3(t *testing.T) {
|
|
v1Config := `{
|
|
"version": 1,
|
|
"model_list": [
|
|
{"model_name": "gpt-4", "model": "openai/gpt-4", "api_key": "sk-test"}
|
|
],
|
|
"channels": {
|
|
"telegram": {
|
|
"token": "bot-token",
|
|
"base_url": "https://custom.api.com"
|
|
},
|
|
"discord": {
|
|
"mention_only": true,
|
|
"proxy": "socks5://localhost:1080"
|
|
},
|
|
"onebot": {
|
|
"ws_url": "ws://localhost:3001",
|
|
"group_trigger_prefix": ["/"]
|
|
}
|
|
}
|
|
}`
|
|
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "config.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600))
|
|
m, err := loadConfigMap(configPath)
|
|
require.NoError(t, err)
|
|
|
|
err = migrateV1ToV2(m)
|
|
require.NoError(t, err)
|
|
err = migrateV2ToV3(m)
|
|
require.NoError(t, err)
|
|
|
|
// Version should be set to CurrentVersion
|
|
require.Equal(t, CurrentVersion, m["version"])
|
|
|
|
// Channels should be converted to nested format
|
|
channelList, ok := m["channel_list"].(map[string]any)
|
|
require.True(t, ok, "channel_list should exist")
|
|
require.NotContains(t, m, "channels", "old 'channels' key should be removed")
|
|
|
|
// telegram: flat fields moved to settings
|
|
telegram := channelList["telegram"].(map[string]any)
|
|
require.Equal(t, "telegram", telegram["type"])
|
|
tgSettings := telegram["settings"].(map[string]any)
|
|
require.Equal(t, "bot-token", tgSettings["token"])
|
|
require.Equal(t, "https://custom.api.com", tgSettings["base_url"])
|
|
|
|
// discord: mention_only should be moved to group_trigger
|
|
discord := channelList["discord"].(map[string]any)
|
|
require.Equal(t, "discord", discord["type"])
|
|
require.Contains(t, discord, "group_trigger", "mention_only should be migrated to group_trigger")
|
|
gt := discord["group_trigger"].(map[string]any)
|
|
require.Equal(t, true, gt["mention_only"])
|
|
discordSettings := discord["settings"].(map[string]any)
|
|
require.Equal(t, "socks5://localhost:1080", discordSettings["proxy"])
|
|
|
|
// onebot: group_trigger_prefix should be moved to group_trigger.prefixes
|
|
onebot := channelList["onebot"].(map[string]any)
|
|
require.Equal(t, "onebot", onebot["type"])
|
|
obGroupTrigger := onebot["group_trigger"].(map[string]any)
|
|
require.Equal(
|
|
t,
|
|
[]any{"/"},
|
|
obGroupTrigger["prefixes"],
|
|
"group_trigger_prefix should be moved to group_trigger.prefixes",
|
|
)
|
|
obSettings := onebot["settings"].(map[string]any)
|
|
require.Equal(t, "ws://localhost:3001", obSettings["ws_url"])
|
|
}
|
|
|
|
// TestMigrateV1ToV3_ApiKeyConversion verifies api_key → api_keys conversion.
|
|
func TestMigrateV1ToV3_ApiKeyConversion(t *testing.T) {
|
|
v1Config := `{
|
|
"version": 1,
|
|
"model_list": [
|
|
{"model_name": "gpt-4", "model": "openai/gpt-4", "api_key": "sk-single"},
|
|
{"model_name": "no-key", "model": "openai/no-key"}
|
|
],
|
|
"channels": {
|
|
"telegram": {"token": "bot"}
|
|
}
|
|
}`
|
|
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "config.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600))
|
|
m, err := loadConfigMap(configPath)
|
|
require.NoError(t, err)
|
|
|
|
err = migrateV1ToV2(m)
|
|
require.NoError(t, err)
|
|
err = migrateV2ToV3(m)
|
|
require.NoError(t, err)
|
|
|
|
// api_key should be converted to api_keys array
|
|
modelList := m["model_list"].([]any)
|
|
firstModel := modelList[0].(map[string]any)
|
|
require.NotContains(t, firstModel, "api_key", "api_key should be removed")
|
|
require.Contains(t, firstModel, "api_keys", "api_keys should exist")
|
|
// api_keys can be []string or []any depending on how it was set
|
|
if apiKeys, ok := firstModel["api_keys"].([]string); ok {
|
|
require.Len(t, apiKeys, 1)
|
|
require.Equal(t, "sk-single", apiKeys[0])
|
|
} else if apiKeys, ok := firstModel["api_keys"].([]any); ok {
|
|
require.Len(t, apiKeys, 1)
|
|
require.Equal(t, "sk-single", apiKeys[0])
|
|
} else {
|
|
t.Fatalf("api_keys has unexpected type: %T", firstModel["api_keys"])
|
|
}
|
|
|
|
// Model without api_key should not have api_keys added
|
|
secondModel := modelList[1].(map[string]any)
|
|
require.NotContains(t, secondModel, "api_key")
|
|
require.NotContains(t, secondModel, "api_keys")
|
|
}
|
|
|
|
// TestMigrateV1ToV3_AlreadyNestedFormat leaves already-nested channels unchanged.
|
|
func TestMigrateV1ToV3_AlreadyNestedFormat(t *testing.T) {
|
|
v1Config := `{
|
|
"version": 1,
|
|
"model_list": [
|
|
{"model_name": "gpt-4", "model": "openai/gpt-4"}
|
|
],
|
|
"channels": {
|
|
"telegram": {
|
|
"type": "telegram",
|
|
"settings": {
|
|
"token": "bot-token"
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "config.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600))
|
|
m, err := loadConfigMap(configPath)
|
|
require.NoError(t, err)
|
|
|
|
err = migrateV1ToV2(m)
|
|
require.NoError(t, err)
|
|
err = migrateV2ToV3(m)
|
|
require.NoError(t, err)
|
|
|
|
channelList := m["channel_list"].(map[string]any)
|
|
telegram := channelList["telegram"].(map[string]any)
|
|
// Should not be double-wrapped
|
|
require.Equal(t, "telegram", telegram["type"])
|
|
settings := telegram["settings"].(map[string]any)
|
|
require.Equal(t, "bot-token", settings["token"])
|
|
// Should NOT have nested settings inside settings
|
|
require.NotContains(t, settings, "settings")
|
|
}
|