diff --git a/README.fr.md b/README.fr.md index c442ffc63..248ebe44f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -838,8 +838,8 @@ Cette conception permet également le **support multi-agent** avec une sélectio "api_key": "sk-your-openai-key" }, { - "model_name": "claude-3-sonnet", - "model": "anthropic/claude-3-5-sonnet-20241022", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4-20250514", "api_key": "sk-ant-your-key" }, { diff --git a/README.ja.md b/README.ja.md index bcc821703..4404c4b7c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -774,8 +774,8 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る "api_key": "sk-your-openai-key" }, { - "model_name": "claude-3-sonnet", - "model": "anthropic/claude-3-5-sonnet-20241022", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4-20250514", "api_key": "sk-ant-your-key" }, { diff --git a/README.md b/README.md index b6379a999..f921bd17c 100644 --- a/README.md +++ b/README.md @@ -222,8 +222,8 @@ picoclaw onboard "api_key": "your-api-key" }, { - "model_name": "claude3", - "model": "anthropic/claude-3-sonnet", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4", "api_key": "your-anthropic-key" } ], @@ -733,8 +733,8 @@ This design also enables **multi-agent support** with flexible provider selectio "api_key": "sk-your-openai-key" }, { - "model_name": "claude-3-sonnet", - "model": "anthropic/claude-3-5-sonnet-20241022", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4-20250514", "api_key": "sk-ant-your-key" }, { diff --git a/README.pt-br.md b/README.pt-br.md index 47efc3d58..b31264731 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -839,8 +839,8 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve "api_key": "sk-your-openai-key" }, { - "model_name": "claude-3-sonnet", - "model": "anthropic/claude-3-5-sonnet-20241022", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4-20250514", "api_key": "sk-ant-your-key" }, { diff --git a/README.vi.md b/README.vi.md index 7e5ac5abc..ed0dcfa5f 100644 --- a/README.vi.md +++ b/README.vi.md @@ -816,8 +816,8 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch "api_key": "sk-your-openai-key" }, { - "model_name": "claude-3-sonnet", - "model": "anthropic/claude-3-5-sonnet-20241022", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4-20250514", "api_key": "sk-ant-your-key" }, { diff --git a/README.zh.md b/README.zh.md index 1030fde49..87ccbd6b4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -231,8 +231,8 @@ picoclaw onboard "api_key": "your-api-key" }, { - "model_name": "claude3", - "model": "anthropic/claude-3-sonnet", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4", "api_key": "your-anthropic-key" } ], @@ -610,8 +610,8 @@ Agent 读取 HEARTBEAT.md "api_key": "sk-your-openai-key" }, { - "model_name": "claude-3-sonnet", - "model": "anthropic/claude-3-5-sonnet-20241022", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4-20250514", "api_key": "sk-ant-your-key" }, { diff --git a/config/config.example.json b/config/config.example.json index 8632e76c1..3526c266c 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -17,8 +17,8 @@ "api_base": "https://api.openai.com/v1" }, { - "model_name": "claude3", - "model": "anthropic/claude-3-sonnet", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4", "api_key": "sk-ant-your-key", "api_base": "https://api.anthropic.com/v1" }, diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md index 91df87f34..20a927159 100644 --- a/docs/design/provider-refactoring.md +++ b/docs/design/provider-refactoring.md @@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity. Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design: 1. **Model-centric**: Users care about models, not providers -2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-3-sonnet` +2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4` 3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes ### 2.2 New Configuration Structure @@ -86,8 +86,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design: "api_key": "sk-xxx" }, { - "model_name": "claude-3-sonnet", - "model": "anthropic/claude-3-5-sonnet-20241022", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4-20250514", "api_key": "sk-xxx" }, { @@ -184,7 +184,7 @@ Identify protocol via prefix in `model` field: "system_prompt": "You are a coding assistant..." }, "translator": { - "model": "claude-3-sonnet" + "model": "claude-sonnet-4" } } } diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md index 3e4140357..03765ca03 100644 --- a/docs/migration/model-list-migration.md +++ b/docs/migration/model-list-migration.md @@ -58,8 +58,8 @@ The new `model_list` configuration offers several advantages: "api_base": "https://api.openai.com/v1" }, { - "model_name": "claude3", - "model": "anthropic/claude-3-sonnet", + "model_name": "claude-sonnet-4", + "model": "anthropic/claude-sonnet-4", "api_key": "sk-ant-your-key" }, { @@ -83,12 +83,12 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | Prefix | Description | Example | |--------|-------------|---------| | `openai/` | OpenAI API (default) | `openai/gpt-5.2` | -| `anthropic/` | Anthropic API | `anthropic/claude-3-opus` | +| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` | | `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` | -| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-3-sonnet` | +| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4` | | `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` | | `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` | -| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-3` | +| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4` | | `groq/` | Groq API | `groq/llama-3.1-70b` | | `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` | | `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` | diff --git a/pkg/config/config.go b/pkg/config/config.go index 386b77da2..a33bd81e3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,12 +5,14 @@ import ( "fmt" "os" "path/filepath" - "sync" "sync/atomic" "github.com/caarlos0/env/v11" ) +// rrCounter is a global counter for round-robin load balancing across models. +var rrCounter atomic.Uint64 + // FlexibleStringSlice is a []string that also accepts JSON numbers, // so allow_from can contain both "123" and 123. type FlexibleStringSlice []string @@ -45,18 +47,16 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { } type Config struct { - Agents AgentsConfig `json:"agents"` - Bindings []AgentBinding `json:"bindings,omitempty"` - Session SessionConfig `json:"session,omitempty"` - Channels ChannelsConfig `json:"channels"` - Providers ProvidersConfig `json:"providers,omitempty"` - ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration - Gateway GatewayConfig `json:"gateway"` - Tools ToolsConfig `json:"tools"` - Heartbeat HeartbeatConfig `json:"heartbeat"` - Devices DevicesConfig `json:"devices"` - mu sync.RWMutex - rrCounters map[string]*atomic.Uint64 // Round-robin counters for load balancing + Agents AgentsConfig `json:"agents"` + Bindings []AgentBinding `json:"bindings,omitempty"` + Session SessionConfig `json:"session,omitempty"` + Channels ChannelsConfig `json:"channels"` + Providers ProvidersConfig `json:"providers,omitempty"` + ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration + Gateway GatewayConfig `json:"gateway"` + Tools ToolsConfig `json:"tools"` + Heartbeat HeartbeatConfig `json:"heartbeat"` + Devices DevicesConfig `json:"devices"` } // MarshalJSON implements custom JSON marshaling for Config @@ -350,7 +350,7 @@ type OpenAIProviderConfig struct { type ModelConfig struct { // Required fields ModelName string `json:"model_name"` // User-facing alias for the model - Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-3") + Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4") // HTTP-based providers APIBase string `json:"api_base,omitempty"` // API endpoint URL @@ -454,9 +454,6 @@ func LoadConfig(path string) (*Config, error) { } func SaveConfig(path string, cfg *Config) error { - cfg.mu.RLock() - defer cfg.mu.RUnlock() - data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err @@ -471,14 +468,10 @@ func SaveConfig(path string, cfg *Config) error { } func (c *Config) WorkspacePath() string { - c.mu.RLock() - defer c.mu.RUnlock() return expandHome(c.Agents.Defaults.Workspace) } func (c *Config) GetAPIKey() string { - c.mu.RLock() - defer c.mu.RUnlock() if c.Providers.OpenRouter.APIKey != "" { return c.Providers.OpenRouter.APIKey } @@ -510,8 +503,6 @@ func (c *Config) GetAPIKey() string { } func (c *Config) GetAPIBase() string { - c.mu.RLock() - defer c.mu.RUnlock() if c.Providers.OpenRouter.APIKey != "" { if c.Providers.OpenRouter.APIBase != "" { return c.Providers.OpenRouter.APIBase @@ -544,54 +535,22 @@ func expandHome(path string) string { // GetModelConfig returns the ModelConfig for the given model name. // If multiple configs exist with the same model_name, it uses round-robin // selection for load balancing. Returns an error if the model is not found. -// Uses double-check locking for optimal read performance. func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { - // First pass: use read lock to find matches - c.mu.RLock() - matches := c.findMatchesLocked(modelName) + matches := c.findMatches(modelName) if len(matches) == 0 { - c.mu.RUnlock() return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) } if len(matches) == 1 { - c.mu.RUnlock() return &matches[0], nil } - // Multiple configs - check if counter exists - counter, ok := c.rrCounters[modelName] - c.mu.RUnlock() - - // Double-check locking: only acquire write lock if counter needs initialization - if !ok { - c.mu.Lock() - // Re-check after acquiring write lock - if c.rrCounters == nil { - c.rrCounters = make(map[string]*atomic.Uint64) - } - if c.rrCounters[modelName] == nil { - c.rrCounters[modelName] = &atomic.Uint64{} - } - counter = c.rrCounters[modelName] - c.mu.Unlock() - } - - // Re-fetch matches to ensure consistency (ModelList could have changed) - c.mu.RLock() - matches = c.findMatchesLocked(modelName) - c.mu.RUnlock() - - if len(matches) == 0 { - return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) - } - - idx := counter.Add(1) % uint64(len(matches)) + // Multiple configs - use round-robin for load balancing + idx := rrCounter.Add(1) % uint64(len(matches)) return &matches[idx], nil } -// findMatchesLocked finds all ModelConfig entries with the given model_name. -// Must be called with c.mu locked (read or write). -func (c *Config) findMatchesLocked(modelName string) []ModelConfig { +// findMatches finds all ModelConfig entries with the given model_name. +func (c *Config) findMatches(modelName string) []ModelConfig { var matches []ModelConfig for i := range c.ModelList { if c.ModelList[i].ModelName == modelName { @@ -603,9 +562,6 @@ func (c *Config) findMatchesLocked(modelName string) []ModelConfig { // HasProvidersConfig checks if any provider in the old providers config has configuration. func (c *Config) HasProvidersConfig() bool { - c.mu.RLock() - defer c.mu.RUnlock() - v := c.Providers return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" || v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" || diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index b3102a446..0ce950298 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -160,6 +160,12 @@ func DefaultConfig() *Config { }, // OpenRouter (100+ models) - https://openrouter.ai/keys + { + ModelName: "openrouter-auto", + Model: "openrouter/auto", + APIBase: "https://openrouter.ai/api/v1", + APIKey: "", + }, { ModelName: "openrouter-gpt-5.2", Model: "openrouter/openai/gpt-5.2", diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 543f2676b..2e0323cd6 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -10,6 +10,17 @@ import ( "strings" ) +// 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. +func buildModelWithProtocol(protocol, model string) string { + if strings.Contains(model, "/") { + // Model already has a protocol prefix, return as-is + return model + } + 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 @@ -67,7 +78,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { } return ModelConfig{ ModelName: "anthropic", - Model: "anthropic/claude-3-sonnet", + Model: "anthropic/claude-sonnet-4", APIKey: p.Anthropic.APIKey, APIBase: p.Anthropic.APIBase, Proxy: p.Anthropic.Proxy, @@ -325,13 +336,13 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { // Check if this is the user's configured provider if slices.Contains(m.providerNames, userProvider) && userModel != "" { // Use the user's configured model instead of default - mc.Model = m.protocol + "/" + userModel + mc.Model = buildModelWithProtocol(m.protocol, userModel) } else if userProvider == "" && userModel != "" && !legacyModelNameApplied { // Legacy config: no explicit provider field but model is specified // Use userModel as ModelName for the FIRST provider so GetModelConfig(model) can find it // This maintains backward compatibility with old configs that relied on implicit provider selection mc.ModelName = userModel - mc.Model = m.protocol + "/" + userModel + mc.Model = buildModelWithProtocol(m.protocol, userModel) legacyModelNameApplied = true } diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index c65775118..6e128d221 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -58,8 +58,8 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) { if result[0].ModelName != "anthropic" { t.Errorf("ModelName = %q, want %q", result[0].ModelName, "anthropic") } - if result[0].Model != "anthropic/claude-3-sonnet" { - t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-3-sonnet") + if result[0].Model != "anthropic/claude-sonnet-4" { + t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-sonnet-4") } } @@ -239,7 +239,7 @@ func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) Agents: AgentsConfig{ Defaults: AgentDefaults{ Provider: "claude", // alternative name - Model: "claude-3-opus-20240229", + Model: "claude-opus-4-20250514", }, }, Providers: ProvidersConfig{ @@ -253,8 +253,8 @@ func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) t.Fatalf("len(result) = %d, want 1", len(result)) } - if result[0].Model != "anthropic/claude-3-opus-20240229" { - t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-3-opus-20240229") + if result[0].Model != "anthropic/claude-opus-4-20250514" { + t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-opus-4-20250514") } } @@ -495,3 +495,57 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) { t.Errorf("ModelName = %q, want %q", result[0].ModelName, "zhipu") } } + +// 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") + } +} + +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") + if result != "openrouter/claude-sonnet-4" { + t.Errorf("buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4) = %q, want %q", result, "openrouter/claude-sonnet-4") + } +} + +// Test for legacy config with protocol prefix in model name +func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) { + cfg := &Config{ + Agents: AgentsConfig{ + Defaults: AgentDefaults{ + Provider: "", // No explicit provider + Model: "openrouter/auto", // Model already has protocol prefix + }, + }, + Providers: ProvidersConfig{ + OpenRouter: ProviderConfig{APIKey: "sk-or-test"}, + }, + } + + result := ConvertProvidersToModelList(cfg) + + if len(result) < 1 { + t.Fatalf("len(result) = %d, want at least 1", len(result)) + } + + // First provider should use userModel as ModelName for backward compatibility + if result[0].ModelName != "openrouter/auto" { + t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openrouter/auto") + } + + // Model should NOT have duplicated prefix + if result[0].Model != "openrouter/auto" { + t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto") + } +} diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go index b368f063b..c676e40ec 100644 --- a/pkg/providers/factory_test.go +++ b/pkg/providers/factory_test.go @@ -196,8 +196,15 @@ func TestResolveProviderSelection(t *testing.T) { func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) { cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "openrouter/auto" - cfg.Providers.OpenRouter.APIKey = "sk-or-test" + 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", + }, + } provider, _, err := CreateProvider(cfg) if err != nil {