mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge branch 'main' into version
This commit is contained in:
+226
-19
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/caarlos0/env/v11"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg"
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
@@ -224,7 +225,7 @@ type AgentDefaults struct {
|
||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
|
||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||
ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||
@@ -307,6 +308,7 @@ type TelegramConfig struct {
|
||||
Typing TypingConfig `json:"typing,omitempty"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||
}
|
||||
|
||||
type FeishuConfig struct {
|
||||
@@ -320,6 +322,7 @@ type FeishuConfig struct {
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
|
||||
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
|
||||
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
@@ -526,6 +529,8 @@ type ProvidersConfig struct {
|
||||
Avian ProviderConfig `json:"avian"`
|
||||
Minimax ProviderConfig `json:"minimax"`
|
||||
LongCat ProviderConfig `json:"longcat"`
|
||||
ModelScope ProviderConfig `json:"modelscope"`
|
||||
Novita ProviderConfig `json:"novita"`
|
||||
}
|
||||
|
||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||
@@ -553,7 +558,9 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
|
||||
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
|
||||
p.LongCat.APIKey == "" && p.LongCat.APIBase == ""
|
||||
p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
|
||||
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" &&
|
||||
p.Novita.APIKey == "" && p.Novita.APIBase == ""
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||
@@ -583,7 +590,9 @@ type OpenAIProviderConfig struct {
|
||||
// ModelConfig represents a model-centric provider configuration.
|
||||
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
|
||||
// The model field uses protocol prefix format: [protocol/]model-identifier
|
||||
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
|
||||
// Supported protocols include openai, anthropic, antigravity, claude-cli,
|
||||
// codex-cli, github-copilot, and named OpenAI-compatible protocols such as
|
||||
// groq, deepseek, modelscope, and novita.
|
||||
// Default protocol is "openai" if no prefix is specified.
|
||||
type ModelConfig struct {
|
||||
// Required fields
|
||||
@@ -591,9 +600,11 @@ type ModelConfig struct {
|
||||
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
|
||||
|
||||
// HTTP-based providers
|
||||
APIBase string `json:"api_base,omitempty"` // API endpoint URL
|
||||
APIKey string `json:"api_key"` // API authentication key
|
||||
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
|
||||
APIBase string `json:"api_base,omitempty"` // API endpoint URL
|
||||
APIKey string `json:"api_key"` // API authentication key (single key)
|
||||
APIKeys []string `json:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
|
||||
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
|
||||
Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover
|
||||
|
||||
// Special providers (CLI-based, OAuth, etc.)
|
||||
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
|
||||
@@ -619,8 +630,9 @@ func (c *ModelConfig) Validate() error {
|
||||
}
|
||||
|
||||
type GatewayConfig struct {
|
||||
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
|
||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
|
||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
|
||||
}
|
||||
|
||||
type ToolDiscoveryConfig struct {
|
||||
@@ -686,15 +698,24 @@ type WebToolsConfig struct {
|
||||
Perplexity PerplexityConfig ` json:"perplexity"`
|
||||
SearXNG SearXNGConfig ` json:"searxng"`
|
||||
GLMSearch GLMSearchConfig ` json:"glm_search"`
|
||||
// PreferNative controls whether to use provider-native web search when
|
||||
// the active LLM supports it (e.g. OpenAI web_search_preview). When true,
|
||||
// the client-side web_search tool is hidden to avoid duplicate search surfaces,
|
||||
// and the provider's built-in search is used instead. Falls back to client-side
|
||||
// search when the provider does not support native search.
|
||||
PreferNative bool `json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
|
||||
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
||||
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
||||
Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
|
||||
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
|
||||
}
|
||||
|
||||
type CronToolsConfig struct {
|
||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
||||
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
|
||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
||||
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
|
||||
AllowCommand bool ` env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND" json:"allow_command"`
|
||||
}
|
||||
|
||||
type ExecConfig struct {
|
||||
@@ -709,6 +730,7 @@ type ExecConfig struct {
|
||||
type SkillsToolsConfig struct {
|
||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
||||
Registries SkillsRegistriesConfig ` json:"registries"`
|
||||
Github SkillsGithubConfig ` json:"github"`
|
||||
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
||||
SearchCache SearchCacheConfig ` json:"search_cache"`
|
||||
}
|
||||
@@ -743,6 +765,7 @@ type ToolsConfig struct {
|
||||
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
|
||||
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
||||
@@ -758,6 +781,11 @@ type SkillsRegistriesConfig struct {
|
||||
ClawHub ClawHubRegistryConfig `json:"clawhub"`
|
||||
}
|
||||
|
||||
type SkillsGithubConfig struct {
|
||||
Token string `json:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN"`
|
||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
|
||||
}
|
||||
|
||||
type ClawHubRegistryConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
|
||||
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
|
||||
@@ -843,11 +871,30 @@ func LoadConfig(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
||||
}
|
||||
|
||||
// Apply environment variables
|
||||
if e := env.Parse(cfg); e != nil {
|
||||
return nil, e
|
||||
if passphrase := credential.PassphraseProvider(); passphrase != "" {
|
||||
for _, m := range cfg.ModelList {
|
||||
if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") && !strings.HasPrefix(m.APIKey, "file://") {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n",
|
||||
m.ModelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Expand multi-key configs into separate entries for key-level failover
|
||||
cfg.ModelList = ExpandMultiKeyModels(cfg.ModelList)
|
||||
|
||||
// Migrate legacy channel config fields to new unified structures
|
||||
cfg.migrateChannelConfigs()
|
||||
|
||||
// Validate model_list for uniqueness and required fields
|
||||
if err := cfg.ValidateModelList(); err != nil {
|
||||
return nil, err
|
||||
@@ -856,7 +903,7 @@ func LoadConfig(path string) (*Config, error) {
|
||||
// Ensure Workspace has a default if not set
|
||||
if cfg.Agents.Defaults.Workspace == "" {
|
||||
homePath, _ := os.UserHomeDir()
|
||||
if picoclawHome := os.Getenv(pkg.PicoClawHome); picoclawHome != "" {
|
||||
if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" {
|
||||
homePath = picoclawHome
|
||||
} else if homePath != "" {
|
||||
homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome)
|
||||
@@ -867,18 +914,93 @@ func LoadConfig(path string) (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values
|
||||
// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or
|
||||
// empty). Returns (nil, error) if any key fails to encrypt — callers must treat
|
||||
// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk.
|
||||
// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig
|
||||
// and leave JSON marshaling to the caller.
|
||||
func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) {
|
||||
sealed := make([]ModelConfig, len(models))
|
||||
copy(sealed, models)
|
||||
changed := false
|
||||
for i := range sealed {
|
||||
m := &sealed[i]
|
||||
if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") {
|
||||
continue
|
||||
}
|
||||
encrypted, err := credential.Encrypt(passphrase, "", m.APIKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err)
|
||||
}
|
||||
m.APIKey = encrypted
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return nil, nil
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
// resolveAPIKeys decrypts or dereferences each api_key in models in-place.
|
||||
// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt).
|
||||
// Also resolves api_keys array if present.
|
||||
func resolveAPIKeys(models []ModelConfig, configDir string) error {
|
||||
cr := credential.NewResolver(configDir)
|
||||
for i := range models {
|
||||
// Resolve single APIKey
|
||||
resolved, err := cr.Resolve(models[i].APIKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err)
|
||||
}
|
||||
models[i].APIKey = resolved
|
||||
|
||||
// Resolve APIKeys array
|
||||
for j, key := range models[i].APIKeys {
|
||||
resolved, err := cr.Resolve(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model_list[%d] (%s): api_keys[%d]: %w", i, models[i].ModelName, j, err)
|
||||
}
|
||||
models[i].APIKeys[j] = resolved
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) migrateChannelConfigs() {
|
||||
// Discord: mention_only -> group_trigger.mention_only
|
||||
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
|
||||
c.Channels.Discord.GroupTrigger.MentionOnly = true
|
||||
}
|
||||
|
||||
// OneBot: group_trigger_prefix -> group_trigger.prefixes
|
||||
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
|
||||
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
|
||||
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
|
||||
}
|
||||
}
|
||||
|
||||
func SaveConfig(path string, cfg *Config) error {
|
||||
// Ensure version is always set when saving
|
||||
if cfg.Version == 0 {
|
||||
cfg.Version = CurrentVersion
|
||||
}
|
||||
if passphrase := credential.PassphraseProvider(); passphrase != "" {
|
||||
sealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sealed != nil {
|
||||
tmp := *cfg
|
||||
tmp.ModelList = sealed
|
||||
cfg = &tmp
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||
}
|
||||
|
||||
@@ -913,7 +1035,7 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
|
||||
}
|
||||
|
||||
// Multiple configs - use round-robin for load balancing
|
||||
idx := rrCounter.Add(1) % uint64(len(matches))
|
||||
idx := (rrCounter.Add(1) - 1) % uint64(len(matches))
|
||||
return &matches[idx], nil
|
||||
}
|
||||
|
||||
@@ -963,6 +1085,89 @@ func MergeAPIKeys(apiKey string, apiKeys []string) []string {
|
||||
return all
|
||||
}
|
||||
|
||||
// ExpandMultiKeyModels expands ModelConfig entries with multiple API keys into
|
||||
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
|
||||
// and the original entry's fallbacks are set up to chain through the expanded entries.
|
||||
//
|
||||
// Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]}
|
||||
// Becomes:
|
||||
// - {"model_name": "gpt-4", "api_key": "k1", "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]}
|
||||
// - {"model_name": "gpt-4__key_1", "api_key": "k2"}
|
||||
// - {"model_name": "gpt-4__key_2", "api_key": "k3"}
|
||||
func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig {
|
||||
var expanded []ModelConfig
|
||||
|
||||
for _, m := range models {
|
||||
keys := MergeAPIKeys(m.APIKey, m.APIKeys)
|
||||
|
||||
// Single key or no keys: keep as-is
|
||||
if len(keys) <= 1 {
|
||||
// Ensure APIKey is set from APIKeys if needed
|
||||
if m.APIKey == "" && len(keys) == 1 {
|
||||
m.APIKey = keys[0]
|
||||
}
|
||||
m.APIKeys = nil // Clear APIKeys to avoid confusion
|
||||
expanded = append(expanded, m)
|
||||
continue
|
||||
}
|
||||
|
||||
// Multiple keys: expand
|
||||
originalName := m.ModelName
|
||||
|
||||
// Create entries for additional keys (key_1, key_2, ...)
|
||||
var fallbackNames []string
|
||||
for i := 1; i < len(keys); i++ {
|
||||
suffix := fmt.Sprintf("__key_%d", i)
|
||||
expandedName := originalName + suffix
|
||||
|
||||
// Create a copy for the additional key
|
||||
additionalEntry := ModelConfig{
|
||||
ModelName: expandedName,
|
||||
Model: m.Model,
|
||||
APIBase: m.APIBase,
|
||||
APIKey: keys[i],
|
||||
Proxy: m.Proxy,
|
||||
AuthMethod: m.AuthMethod,
|
||||
ConnectMode: m.ConnectMode,
|
||||
Workspace: m.Workspace,
|
||||
RPM: m.RPM,
|
||||
MaxTokensField: m.MaxTokensField,
|
||||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
}
|
||||
expanded = append(expanded, additionalEntry)
|
||||
fallbackNames = append(fallbackNames, expandedName)
|
||||
}
|
||||
|
||||
// Create the primary entry with first key and fallbacks
|
||||
primaryEntry := ModelConfig{
|
||||
ModelName: originalName,
|
||||
Model: m.Model,
|
||||
APIBase: m.APIBase,
|
||||
APIKey: keys[0],
|
||||
Proxy: m.Proxy,
|
||||
AuthMethod: m.AuthMethod,
|
||||
ConnectMode: m.ConnectMode,
|
||||
Workspace: m.Workspace,
|
||||
RPM: m.RPM,
|
||||
MaxTokensField: m.MaxTokensField,
|
||||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
}
|
||||
|
||||
// Prepend new fallbacks to existing ones
|
||||
if len(fallbackNames) > 0 {
|
||||
primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...)
|
||||
} else if len(m.Fallbacks) > 0 {
|
||||
primaryEntry.Fallbacks = m.Fallbacks
|
||||
}
|
||||
|
||||
expanded = append(expanded, primaryEntry)
|
||||
}
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||
switch name {
|
||||
case "web":
|
||||
@@ -993,6 +1198,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||
return t.ReadFile.Enabled
|
||||
case "spawn":
|
||||
return t.Spawn.Enabled
|
||||
case "spawn_status":
|
||||
return t.SpawnStatus.Enabled
|
||||
case "spi":
|
||||
return t.SPI.Enabled
|
||||
case "subagent":
|
||||
|
||||
+466
-6
@@ -5,9 +5,24 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
)
|
||||
|
||||
// mustSetupSSHKey generates a temporary Ed25519 SSH key in t.TempDir() and sets
|
||||
// PICOCLAW_SSH_KEY_PATH to its path for the duration of the test. This is required
|
||||
// whenever a test exercises encryption/decryption via credential.Encrypt or SaveConfig.
|
||||
func mustSetupSSHKey(t *testing.T) {
|
||||
t.Helper()
|
||||
keyPath := filepath.Join(t.TempDir(), "picoclaw_ed25519.key")
|
||||
if err := credential.GenerateSSHKey(keyPath); err != nil {
|
||||
t.Fatalf("mustSetupSSHKey: %v", err)
|
||||
}
|
||||
t.Setenv("PICOCLAW_SSH_KEY_PATH", keyPath)
|
||||
}
|
||||
|
||||
func TestAgentModelConfig_UnmarshalString(t *testing.T) {
|
||||
var m AgentModelConfig
|
||||
if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil {
|
||||
@@ -62,6 +77,22 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersConfig_IsEmpty(t *testing.T) {
|
||||
var empty ProvidersConfig
|
||||
if !empty.IsEmpty() {
|
||||
t.Fatal("empty ProvidersConfig should report empty")
|
||||
}
|
||||
|
||||
novita := ProvidersConfig{
|
||||
Novita: ProviderConfig{
|
||||
APIKey: "test-key",
|
||||
},
|
||||
}
|
||||
if novita.IsEmpty() {
|
||||
t.Fatal("ProvidersConfig with novita settings should not report empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfig_FullParse(t *testing.T) {
|
||||
jsonData := `{
|
||||
"agents": {
|
||||
@@ -243,6 +274,9 @@ func TestDefaultConfig_Gateway(t *testing.T) {
|
||||
if cfg.Gateway.Port == 0 {
|
||||
t.Error("Gateway port should have default value")
|
||||
}
|
||||
if cfg.Gateway.HotReload {
|
||||
t.Error("Gateway hot reload should be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Channels verifies channels are disabled by default
|
||||
@@ -303,6 +337,25 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
if err := SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), `"model_name": ""`) {
|
||||
t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_Complete verifies all config fields are set
|
||||
func TestConfig_Complete(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
@@ -333,6 +386,45 @@ func TestConfig_Complete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Tools.Web.PreferNative {
|
||||
t.Fatal("DefaultConfig().Tools.Web.PreferNative should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"web":{"enabled":true}}}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if !cfg.Tools.Web.PreferNative {
|
||||
t.Fatal("PreferNative should remain true when unset in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"prefer_native":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.Tools.Web.PreferNative {
|
||||
t.Fatal("PreferNative should be false when disabled in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Tools.Exec.AllowRemote {
|
||||
@@ -340,6 +432,13 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Tools.Cron.AllowCommand {
|
||||
t.Fatal("DefaultConfig().Tools.Cron.AllowCommand should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
@@ -357,12 +456,32 @@ 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(`{"version":1,"tools":{"cron":{"exec_timeout_minutes":5}}}`),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if !cfg.Tools.Cron.AllowCommand {
|
||||
t.Fatal("tools.cron.allow_command should remain true when unset in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
configJSON := `{
|
||||
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
|
||||
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
|
||||
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}],
|
||||
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
||||
@@ -400,13 +519,19 @@ func TestDefaultConfig_DMScope(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
|
||||
// Unset to ensure we test the default
|
||||
t.Setenv("PICOCLAW_HOME", "")
|
||||
// Set a known home for consistent test results
|
||||
t.Setenv("HOME", "/tmp/home")
|
||||
|
||||
var fakeHome string
|
||||
if runtime.GOOS == "windows" {
|
||||
fakeHome = `C:\tmp\home`
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
} else {
|
||||
fakeHome = "/tmp/home"
|
||||
t.Setenv("HOME", fakeHome)
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
want := filepath.Join("/tmp/home", ".picoclaw", "workspace")
|
||||
want := filepath.Join(fakeHome, ".picoclaw", "workspace")
|
||||
|
||||
if cfg.Agents.Defaults.Workspace != want {
|
||||
t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want)
|
||||
@@ -417,7 +542,7 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
want := "/custom/picoclaw/home/workspace"
|
||||
want := filepath.Join("/custom/picoclaw/home", "workspace")
|
||||
|
||||
if cfg.Agents.Defaults.Workspace != want {
|
||||
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
|
||||
@@ -539,3 +664,338 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext
|
||||
// api_key into memory but does NOT rewrite the config file. File writes are the sole
|
||||
// responsibility of SaveConfig.
|
||||
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"}]}`
|
||||
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||
|
||||
cfg, err := LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
// The file on disk must remain unchanged — LoadConfig must not write anything.
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
if string(raw) != original {
|
||||
t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveConfig_EncryptsPlaintextAPIKey verifies that SaveConfig writes enc:// ciphertext
|
||||
// to disk and that a subsequent LoadConfig decrypts it back to the original plaintext.
|
||||
func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||
mustSetupSSHKey(t)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.ModelList = []ModelConfig{
|
||||
{ModelName: "test", Model: "openai/gpt-4", APIKey: "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)
|
||||
if !strings.Contains(string(raw), "enc://") {
|
||||
t.Errorf("saved file should contain enc://, got:\n%s", string(raw))
|
||||
}
|
||||
if strings.Contains(string(raw), "sk-plaintext") {
|
||||
t.Errorf("saved file must not contain the plaintext key")
|
||||
}
|
||||
|
||||
// A fresh load must decrypt back to the original plaintext.
|
||||
cfg2, err := LoadConfig(cfgPath)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_NoSealWithoutPassphrase verifies that api_key values are left
|
||||
// unchanged when PICOCLAW_KEY_PASSPHRASE is not set.
|
||||
func TestLoadConfig_NoSealWithoutPassphrase(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
|
||||
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||
|
||||
if _, err := LoadConfig(cfgPath); err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
if strings.Contains(string(raw), "enc://") {
|
||||
t.Error("config file must not be modified when no passphrase is set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_FileRefNotSealed verifies that file:// api_key references are not
|
||||
// converted to enc:// values (they are resolved at runtime by the Resolver).
|
||||
func TestLoadConfig_FileRefNotSealed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
keyFile := filepath.Join(dir, "openai.key")
|
||||
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"}]}`
|
||||
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||
|
||||
if _, err := LoadConfig(cfgPath); err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
if !strings.Contains(string(raw), "file://openai.key") {
|
||||
t.Error("file:// reference should be preserved unchanged in the config file")
|
||||
}
|
||||
if strings.Contains(string(raw), "enc://") {
|
||||
t.Error("file:// reference must not be converted to enc://")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveConfig_MixedKeys verifies that SaveConfig encrypts only plaintext api_keys
|
||||
// and leaves already-encrypted (enc://) and file:// entries unchanged.
|
||||
func TestSaveConfig_MixedKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||
mustSetupSSHKey(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"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("setup SaveConfig: %v", err)
|
||||
}
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
// Extract the enc:// value from the saved file.
|
||||
var tmp struct {
|
||||
ModelList []struct {
|
||||
APIKey string `json:"api_key"`
|
||||
} `json:"model_list"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {
|
||||
t.Fatalf("setup: could not parse saved config: %v", err)
|
||||
}
|
||||
alreadyEncrypted := tmp.ModelList[0].APIKey
|
||||
if !strings.HasPrefix(alreadyEncrypted, "enc://") {
|
||||
t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted)
|
||||
}
|
||||
|
||||
// Build a config with three models:
|
||||
// 1. plaintext → must be encrypted by SaveConfig
|
||||
// 2. enc:// → must be left unchanged (already encrypted)
|
||||
// 3. file:// → must be left unchanged (file reference)
|
||||
keyFile := filepath.Join(dir, "api.key")
|
||||
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
||||
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"},
|
||||
},
|
||||
}
|
||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
raw, _ = os.ReadFile(cfgPath)
|
||||
s := string(raw)
|
||||
|
||||
// 1. Plaintext must be encrypted.
|
||||
if strings.Contains(s, "sk-new-plaintext") {
|
||||
t.Error("plaintext key must not appear in saved file")
|
||||
}
|
||||
// 2. The pre-existing enc:// value must still be present (byte-for-byte unchanged).
|
||||
if !strings.Contains(s, alreadyEncrypted) {
|
||||
t.Error("pre-existing enc:// entry must be preserved unchanged")
|
||||
}
|
||||
// 3. file:// must be preserved.
|
||||
if !strings.Contains(s, "file://api.key") {
|
||||
t.Error("file:// reference must be preserved unchanged")
|
||||
}
|
||||
|
||||
// Now load and verify all three decrypt/resolve correctly.
|
||||
cfg2, err := LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after SaveConfig: %v", err)
|
||||
}
|
||||
byName := make(map[string]string)
|
||||
for _, m := range cfg2.ModelList {
|
||||
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")
|
||||
}
|
||||
if byName["enc"] != "sk-already-plain" {
|
||||
t.Errorf("enc model api_key = %q, want %q", byName["enc"], "sk-already-plain")
|
||||
}
|
||||
if byName["file"] != "sk-from-file" {
|
||||
t.Errorf("file model api_key = %q, want %q", byName["file"], "sk-from-file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_MixedKeys_NoPassphrase verifies that when PICOCLAW_KEY_PASSPHRASE
|
||||
// is not set, enc:// entries cause LoadConfig to return an error, while plaintext
|
||||
// and file:// entries in the same config are not affected.
|
||||
func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
// First encrypt a key so we have a real enc:// value.
|
||||
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"},
|
||||
},
|
||||
}); 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
|
||||
|
||||
// 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 {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
mixed, _ := json.Marshal(map[string]any{
|
||||
"model_list": []map[string]any{
|
||||
{"model_name": "enc", "model": "openai/gpt-4", "api_key": encValue},
|
||||
{"model_name": "plain", "model": "openai/gpt-4", "api_key": "sk-plain"},
|
||||
{"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"},
|
||||
},
|
||||
})
|
||||
if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil {
|
||||
t.Fatalf("setup write: %v", err)
|
||||
}
|
||||
|
||||
// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||
|
||||
_, err := LoadConfig(cfgPath)
|
||||
if err == nil {
|
||||
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "passphrase required") {
|
||||
t.Errorf("error should mention passphrase required, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveConfig_UsesPassphraseProvider verifies that SaveConfig encrypts plaintext
|
||||
// api_keys using credential.PassphraseProvider() rather than os.Getenv directly.
|
||||
// This matters for the launcher, which clears the environment variable and redirects
|
||||
// PassphraseProvider to an in-memory SecureStore.
|
||||
func TestSaveConfig_UsesPassphraseProvider(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
// Ensure the env var is empty — passphrase must come from PassphraseProvider only.
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||
mustSetupSSHKey(t)
|
||||
|
||||
// Replace PassphraseProvider with an in-memory function (simulating SecureStore).
|
||||
const testPassphrase = "provider-passphrase"
|
||||
orig := credential.PassphraseProvider
|
||||
credential.PassphraseProvider = func() string { return testPassphrase }
|
||||
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.ModelList = []ModelConfig{
|
||||
{ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"},
|
||||
}
|
||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
raw, _ := os.ReadFile(cfgPath)
|
||||
if !strings.Contains(string(raw), "enc://") {
|
||||
t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_UsesPassphraseProvider verifies that LoadConfig decrypts enc:// keys
|
||||
// using credential.PassphraseProvider() rather than os.Getenv directly.
|
||||
func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
// Ensure the env var is empty throughout.
|
||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||
mustSetupSSHKey(t)
|
||||
|
||||
const testPassphrase = "provider-passphrase"
|
||||
const plainKey = "sk-secret"
|
||||
|
||||
// First, encrypt the key using the same passphrase.
|
||||
encrypted, err := credential.Encrypt(testPassphrase, "", plainKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"model_list": []map[string]any{
|
||||
{"model_name": "test", "model": "openai/gpt-4", "api_key": encrypted},
|
||||
},
|
||||
})
|
||||
if err = os.WriteFile(cfgPath, raw, 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
// Redirect PassphraseProvider — env var is empty, so without this the load would fail.
|
||||
orig := credential.PassphraseProvider
|
||||
credential.PassphraseProvider = func() string { return testPassphrase }
|
||||
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||
|
||||
cfg, err := LoadConfig(cfgPath)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+40
-9
@@ -17,7 +17,7 @@ func DefaultConfig() *Config {
|
||||
// Determine the base path for the workspace.
|
||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||
var homePath string
|
||||
if picoclawHome := os.Getenv(pkg.PicoClawHome); picoclawHome != "" {
|
||||
if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" {
|
||||
homePath = picoclawHome
|
||||
} else {
|
||||
userHome, _ := os.UserHomeDir()
|
||||
@@ -60,6 +60,7 @@ func DefaultConfig() *Config {
|
||||
Enabled: true,
|
||||
Text: "Thinking... 💭",
|
||||
},
|
||||
UseMarkdownV2: false,
|
||||
},
|
||||
Feishu: FeishuConfig{
|
||||
Enabled: false,
|
||||
@@ -193,8 +194,8 @@ func DefaultConfig() *Config {
|
||||
|
||||
// OpenAI - https://platform.openai.com/api-keys
|
||||
{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
APIBase: "https://api.openai.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
@@ -255,8 +256,8 @@ func DefaultConfig() *Config {
|
||||
APIKey: "",
|
||||
},
|
||||
{
|
||||
ModelName: "openrouter-gpt-5.2",
|
||||
Model: "openrouter/openai/gpt-5.2",
|
||||
ModelName: "openrouter-gpt-5.4",
|
||||
Model: "openrouter/openai/gpt-5.4",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
@@ -286,6 +287,12 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
|
||||
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
||||
{
|
||||
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",
|
||||
@@ -310,8 +317,8 @@ func DefaultConfig() *Config {
|
||||
|
||||
// GitHub Copilot - https://github.com/settings/tokens
|
||||
{
|
||||
ModelName: "copilot-gpt-5.2",
|
||||
Model: "github-copilot/gpt-5.2",
|
||||
ModelName: "copilot-gpt-5.4",
|
||||
Model: "github-copilot/gpt-5.4",
|
||||
APIBase: "http://localhost:4321",
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
@@ -362,6 +369,14 @@ func DefaultConfig() *Config {
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
|
||||
{
|
||||
ModelName: "modelscope-qwen",
|
||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
APIBase: "https://api-inference.modelscope.cn/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// VLLM (local) - http://localhost:8000
|
||||
{
|
||||
ModelName: "local-model",
|
||||
@@ -369,10 +384,20 @@ func DefaultConfig() *Config {
|
||||
APIBase: "http://localhost:8000/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Azure OpenAI - https://portal.azure.com
|
||||
// model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name
|
||||
{
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
APIBase: "https://your-resource.openai.azure.com",
|
||||
APIKey: "",
|
||||
},
|
||||
},
|
||||
Gateway: GatewayConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 18790,
|
||||
Host: "127.0.0.1",
|
||||
Port: 18790,
|
||||
HotReload: false,
|
||||
},
|
||||
Tools: ToolsConfig{
|
||||
MediaCleanup: MediaCleanupConfig{
|
||||
@@ -386,8 +411,10 @@ func DefaultConfig() *Config {
|
||||
ToolConfig: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
PreferNative: true,
|
||||
Proxy: "",
|
||||
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
|
||||
Format: "plaintext",
|
||||
Brave: BraveConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
@@ -428,6 +455,7 @@ func DefaultConfig() *Config {
|
||||
Enabled: true,
|
||||
},
|
||||
ExecTimeoutMinutes: 5,
|
||||
AllowCommand: true,
|
||||
},
|
||||
Exec: ExecConfig{
|
||||
ToolConfig: ToolConfig{
|
||||
@@ -497,6 +525,9 @@ func DefaultConfig() *Config {
|
||||
Spawn: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
SpawnStatus: ToolConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
SPI: ToolConfig{
|
||||
Enabled: false, // Hardware tool - Linux only
|
||||
},
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package config
|
||||
|
||||
// Runtime environment variable keys for the picoclaw process.
|
||||
// These control the location of files and binaries at runtime and are read
|
||||
// directly via os.Getenv / os.LookupEnv. All picoclaw-specific keys use the
|
||||
// PICOCLAW_ prefix. Reference these constants instead of inline string
|
||||
// literals to keep all supported knobs visible in one place and to prevent
|
||||
// typos.
|
||||
const (
|
||||
// EnvHome overrides the base directory for all picoclaw data
|
||||
// (config, workspace, skills, auth store, …).
|
||||
// Default: ~/.picoclaw
|
||||
EnvHome = "PICOCLAW_HOME"
|
||||
|
||||
// EnvConfig overrides the full path to the JSON config file.
|
||||
// Default: $PICOCLAW_HOME/config.json
|
||||
EnvConfig = "PICOCLAW_CONFIG"
|
||||
|
||||
// EnvBuiltinSkills overrides the directory from which built-in
|
||||
// skills are loaded.
|
||||
// Default: <cwd>/skills
|
||||
EnvBuiltinSkills = "PICOCLAW_BUILTIN_SKILLS"
|
||||
|
||||
// EnvBinary overrides the path to the picoclaw executable.
|
||||
// Used by the web launcher when spawning the gateway subprocess.
|
||||
// Default: resolved from the same directory as the current executable.
|
||||
EnvBinary = "PICOCLAW_BINARY"
|
||||
|
||||
// EnvGatewayHost overrides the host address for the gateway server.
|
||||
// Default: "127.0.0.1"
|
||||
EnvGatewayHost = "PICOCLAW_GATEWAY_HOST"
|
||||
)
|
||||
+19
-2
@@ -66,7 +66,7 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "openai",
|
||||
Model: "openai/gpt-5.2",
|
||||
Model: "openai/gpt-5.4",
|
||||
APIKey: p.OpenAI.APIKey,
|
||||
APIBase: p.OpenAI.APIBase,
|
||||
Proxy: p.OpenAI.Proxy,
|
||||
@@ -340,7 +340,7 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "github-copilot",
|
||||
Model: "github-copilot/gpt-5.2",
|
||||
Model: "github-copilot/gpt-5.4",
|
||||
APIBase: p.GitHubCopilot.APIBase,
|
||||
ConnectMode: p.GitHubCopilot.ConnectMode,
|
||||
}, true
|
||||
@@ -429,6 +429,23 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"modelscope"},
|
||||
protocol: "modelscope",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "modelscope",
|
||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
APIKey: p.ModelScope.APIKey,
|
||||
APIBase: p.ModelScope.APIBase,
|
||||
Proxy: p.ModelScope.Proxy,
|
||||
RequestTimeout: p.ModelScope.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Process each provider migration
|
||||
|
||||
@@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
||||
if result[0].ModelName != "openai" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
|
||||
}
|
||||
if result[0].Model != "openai/gpt-5.2" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2")
|
||||
if result[0].Model != "openai/gpt-5.4" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4")
|
||||
}
|
||||
if result[0].APIKey != "sk-test-key" {
|
||||
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
|
||||
@@ -163,14 +163,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||
Mistral: ProviderConfig{APIKey: "key18"},
|
||||
Avian: ProviderConfig{APIKey: "key19"},
|
||||
LongCat: ProviderConfig{APIKey: "key-longcat"},
|
||||
ModelScope: ProviderConfig{APIKey: "key-modelscope"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
// All 22 providers should be converted
|
||||
if len(result) != 22 {
|
||||
t.Errorf("len(result) = %d, want 22", len(result))
|
||||
// All 23 providers should be converted
|
||||
if len(result) != 23 {
|
||||
t.Errorf("len(result) = %d, want 23", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,8 +385,8 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
|
||||
for _, mc := range result {
|
||||
switch mc.ModelName {
|
||||
case "openai":
|
||||
if mc.Model != "openai/gpt-5.2" {
|
||||
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2")
|
||||
if mc.Model != "openai/gpt-5.4" {
|
||||
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4")
|
||||
}
|
||||
case "deepseek":
|
||||
if mc.Model != "deepseek/deepseek-reasoner" {
|
||||
@@ -558,9 +559,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
|
||||
// Tests for buildModelWithProtocol helper function
|
||||
|
||||
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
||||
result := buildModelWithProtocol("openai", "gpt-5.2")
|
||||
if result != "openai/gpt-5.2" {
|
||||
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,36 @@ func TestGetModelConfig_RoundRobin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
},
|
||||
}
|
||||
|
||||
wantOrder := []string{
|
||||
"openai/gpt-4o-1",
|
||||
"openai/gpt-4o-2",
|
||||
"openai/gpt-4o-3",
|
||||
"openai/gpt-4o-1",
|
||||
"openai/gpt-4o-2",
|
||||
}
|
||||
|
||||
for i, want := range wantOrder {
|
||||
result, err := cfg.GetModelConfig("lb-model")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelConfig() call %d error = %v", i, err)
|
||||
}
|
||||
if result.Model != want {
|
||||
t.Fatalf("GetModelConfig() call %d model = %q, want %q", i, result.Model, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelConfig_Concurrent(t *testing.T) {
|
||||
cfg := &Config{
|
||||
ModelList: []ModelConfig{
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
|
||||
models := []ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
APIKey: "single-key",
|
||||
},
|
||||
}
|
||||
|
||||
result := ExpandMultiKeyModels(models)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 model, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].ModelName != "gpt-4" {
|
||||
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 len(result[0].Fallbacks) != 0 {
|
||||
t.Errorf("expected no fallbacks, got %v", result[0].Fallbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
|
||||
models := []ModelConfig{
|
||||
{
|
||||
ModelName: "glm-4.7",
|
||||
Model: "zhipu/glm-4.7",
|
||||
APIBase: "https://api.example.com",
|
||||
APIKeys: []string{"key1", "key2", "key3"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ExpandMultiKeyModels(models)
|
||||
|
||||
// Should expand to 3 models
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 models, got %d", len(result))
|
||||
}
|
||||
|
||||
// First entry should be the primary with key1 and fallbacks
|
||||
primary := result[2] // Primary is added last
|
||||
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 len(primary.Fallbacks) != 2 {
|
||||
t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks))
|
||||
}
|
||||
if primary.Fallbacks[0] != "glm-4.7__key_1" {
|
||||
t.Errorf("expected first fallback 'glm-4.7__key_1', got %q", primary.Fallbacks[0])
|
||||
}
|
||||
if primary.Fallbacks[1] != "glm-4.7__key_2" {
|
||||
t.Errorf("expected second fallback 'glm-4.7__key_2', got %q", primary.Fallbacks[1])
|
||||
}
|
||||
|
||||
// Second entry should be key2
|
||||
second := result[0]
|
||||
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)
|
||||
}
|
||||
|
||||
// Third entry should be key3
|
||||
third := result[1]
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
|
||||
models := []ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
APIKey: "key0",
|
||||
APIKeys: []string{"key1", "key2"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ExpandMultiKeyModels(models)
|
||||
|
||||
// Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 models, got %d", len(result))
|
||||
}
|
||||
|
||||
// Primary should use key0
|
||||
primary := result[2]
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
|
||||
models := []ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
APIKeys: []string{"key1", "key2"},
|
||||
Fallbacks: []string{"claude-3"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ExpandMultiKeyModels(models)
|
||||
|
||||
primary := result[1]
|
||||
// With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total
|
||||
if len(primary.Fallbacks) != 2 {
|
||||
t.Fatalf("expected 2 fallbacks, got %d: %v", len(primary.Fallbacks), primary.Fallbacks)
|
||||
}
|
||||
|
||||
// Key fallbacks should come first, then existing fallbacks
|
||||
if primary.Fallbacks[0] != "gpt-4__key_1" {
|
||||
t.Errorf("expected first fallback 'gpt-4__key_1', got %q", primary.Fallbacks[0])
|
||||
}
|
||||
if primary.Fallbacks[1] != "claude-3" {
|
||||
t.Errorf("expected second fallback 'claude-3', got %q", primary.Fallbacks[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
|
||||
models := []ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
APIKey: "",
|
||||
APIKeys: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
result := ExpandMultiKeyModels(models)
|
||||
|
||||
// Should keep as-is with no changes
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 model, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].ModelName != "gpt-4" {
|
||||
t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
|
||||
models := []ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
APIKey: "key1",
|
||||
APIKeys: []string{"key1", "key2", "key1"}, // Duplicate key1
|
||||
},
|
||||
}
|
||||
|
||||
result := ExpandMultiKeyModels(models)
|
||||
|
||||
// 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 len(primary.Fallbacks) != 1 {
|
||||
t.Errorf("expected 1 fallback, got %d", len(primary.Fallbacks))
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
result := ExpandMultiKeyModels(models)
|
||||
|
||||
// Check primary entry preserves all fields
|
||||
primary := result[1]
|
||||
if primary.APIBase != "https://api.example.com" {
|
||||
t.Errorf("expected api_base preserved, got %q", primary.APIBase)
|
||||
}
|
||||
if primary.Proxy != "http://proxy:8080" {
|
||||
t.Errorf("expected proxy preserved, got %q", primary.Proxy)
|
||||
}
|
||||
if primary.RPM != 60 {
|
||||
t.Errorf("expected rpm preserved, got %d", primary.RPM)
|
||||
}
|
||||
if primary.MaxTokensField != "max_completion_tokens" {
|
||||
t.Errorf("expected max_tokens_field preserved, got %q", primary.MaxTokensField)
|
||||
}
|
||||
if primary.RequestTimeout != 30 {
|
||||
t.Errorf("expected request_timeout preserved, got %d", primary.RequestTimeout)
|
||||
}
|
||||
if primary.ThinkingLevel != "high" {
|
||||
t.Errorf("expected thinking_level preserved, got %q", primary.ThinkingLevel)
|
||||
}
|
||||
|
||||
// Check additional entry also preserves fields
|
||||
additional := result[0]
|
||||
if additional.APIBase != "https://api.example.com" {
|
||||
t.Errorf("expected additional api_base preserved, got %q", additional.APIBase)
|
||||
}
|
||||
if additional.RPM != 60 {
|
||||
t.Errorf("expected additional rpm preserved, got %d", additional.RPM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAPIKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiKey string
|
||||
apiKeys []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "both empty",
|
||||
apiKey: "",
|
||||
apiKeys: nil,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "only apiKey",
|
||||
apiKey: "key1",
|
||||
apiKeys: nil,
|
||||
expected: []string{"key1"},
|
||||
},
|
||||
{
|
||||
name: "only apiKeys",
|
||||
apiKey: "",
|
||||
apiKeys: []string{"key1", "key2"},
|
||||
expected: []string{"key1", "key2"},
|
||||
},
|
||||
{
|
||||
name: "both with overlap",
|
||||
apiKey: "key1",
|
||||
apiKeys: []string{"key1", "key2", "key3"},
|
||||
expected: []string{"key1", "key2", "key3"},
|
||||
},
|
||||
{
|
||||
name: "with whitespace",
|
||||
apiKey: " key1 ",
|
||||
apiKeys: []string{" key2 ", " key1 "},
|
||||
expected: []string{"key1", "key2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := MergeAPIKeys(tt.apiKey, tt.apiKeys)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result))
|
||||
}
|
||||
for i, k := range result {
|
||||
if k != tt.expected[i] {
|
||||
t.Errorf("expected key[%d] = %q, got %q", i, tt.expected[i], k)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user