diff --git a/README.md b/README.md index 0401c2b82..3ec420b8d 100644 --- a/README.md +++ b/README.md @@ -209,18 +209,24 @@ picoclaw onboard "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model": "gpt4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, - "providers": { - "openrouter": { - "api_key": "xxx", - "api_base": "https://openrouter.ai/api/v1" + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-4o", + "api_key": "your-api-key" + }, + { + "model_name": "claude3", + "model": "anthropic/claude-3-sonnet", + "api_key": "your-anthropic-key" } - }, + ], "tools": { "web": { "brave": { @@ -237,6 +243,8 @@ picoclaw onboard } ``` +> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#-model-configuration) for details. + **3. Get API Keys** * **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) @@ -681,6 +689,63 @@ The subagent has access to tools (message, web_search, etc.) and can communicate | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +### Model Configuration (model_list) + +The new `model_list` configuration allows you to add providers with zero code changes. Use protocol prefixes to specify the provider type: + +| Prefix | Provider | Example | +|--------|----------|---------| +| `openai/` | OpenAI (default) | `openai/gpt-4o` | +| `anthropic/` | Anthropic | `anthropic/claude-3-sonnet` | +| `antigravity/` | Google via OAuth | `antigravity/gemini-2.0-flash` | +| `deepseek/` | DeepSeek | `deepseek/deepseek-chat` | +| `qwen/` | Alibaba Qwen | `qwen/qwen-max` | +| `groq/` | Groq | `groq/llama-3.1-70b` | +| `cerebras/` | Cerebras | `cerebras/llama-3.3-70b` | + +**Example:** + +```json +{ + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-4o", + "api_key": "your-openai-key" + }, + { + "model_name": "claude3", + "model": "anthropic/claude-3-sonnet", + "api_key": "your-anthropic-key" + }, + { + "model_name": "custom", + "model": "openai/your-model", + "api_base": "https://your-api.com/v1", + "api_key": "your-key" + } + ], + "agents": { + "defaults": { + "model": "gpt4" + } + } +} +``` + +**Load Balancing:** Configure multiple endpoints for the same model: + +```json +{ + "model_list": [ + {"model_name": "gpt4", "model": "openai/gpt-4o", "api_base": "https://api1.example.com/v1"}, + {"model_name": "gpt4", "model": "openai/gpt-4o", "api_base": "https://api2.example.com/v1"} + ] +} +``` + +> **Note**: The legacy `providers` configuration is deprecated. See [migration guide](docs/migration/model-list-migration.md) for details. +
Zhipu diff --git a/README.zh.md b/README.zh.md index bd44b5011..630524dac 100644 --- a/README.zh.md +++ b/README.zh.md @@ -218,18 +218,24 @@ picoclaw onboard "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model": "gpt4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, - "providers": { - "openrouter": { - "api_key": "xxx", - "api_base": "https://openrouter.ai/api/v1" + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-4o", + "api_key": "your-api-key" + }, + { + "model_name": "claude3", + "model": "anthropic/claude-3-sonnet", + "api_key": "your-anthropic-key" } - }, + ], "tools": { "web": { "search": { @@ -245,6 +251,8 @@ picoclaw onboard ``` +> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#-模型配置-model_list)章节。 + **3. 获取 API Key** * **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) @@ -558,6 +566,63 @@ Agent 读取 HEARTBEAT.md | `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | +### 模型配置 (model_list) + +新的 `model_list` 配置格式支持零代码添加 provider。使用协议前缀指定提供商类型: + +| 前缀 | 提供商 | 示例 | +|------|--------|------| +| `openai/` | OpenAI (默认) | `openai/gpt-4o` | +| `anthropic/` | Anthropic | `anthropic/claude-3-sonnet` | +| `antigravity/` | Google via OAuth | `antigravity/gemini-2.0-flash` | +| `deepseek/` | DeepSeek | `deepseek/deepseek-chat` | +| `qwen/` | 通义千问 | `qwen/qwen-max` | +| `groq/` | Groq | `groq/llama-3.1-70b` | +| `cerebras/` | Cerebras | `cerebras/llama-3.3-70b` | + +**示例:** + +```json +{ + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-4o", + "api_key": "your-openai-key" + }, + { + "model_name": "claude3", + "model": "anthropic/claude-3-sonnet", + "api_key": "your-anthropic-key" + }, + { + "model_name": "custom", + "model": "openai/your-model", + "api_base": "https://your-api.com/v1", + "api_key": "your-key" + } + ], + "agents": { + "defaults": { + "model": "gpt4" + } + } +} +``` + +**负载均衡:** 为同一模型配置多个端点: + +```json +{ + "model_list": [ + {"model_name": "gpt4", "model": "openai/gpt-4o", "api_base": "https://api1.example.com/v1"}, + {"model_name": "gpt4", "model": "openai/gpt-4o", "api_base": "https://api2.example.com/v1"} + ] +} +``` + +> **注意**: 旧的 `providers` 配置格式已弃用。详见[迁移指南](docs/migration/model-list-migration.md)。 +
智谱 (Zhipu) 配置示例 diff --git a/config/config.example.json b/config/config.example.json index 33ef237e5..a8b709c77 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -3,12 +3,48 @@ "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true, - "model": "glm-4.7", + "model": "gpt4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 } }, + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-4o", + "api_key": "sk-your-openai-key", + "api_base": "https://api.openai.com/v1" + }, + { + "model_name": "claude3", + "model": "anthropic/claude-3-sonnet", + "api_key": "sk-ant-your-key", + "api_base": "https://api.anthropic.com/v1" + }, + { + "model_name": "gemini", + "model": "antigravity/gemini-2.0-flash", + "auth_method": "oauth" + }, + { + "model_name": "deepseek", + "model": "deepseek/deepseek-chat", + "api_key": "sk-your-deepseek-key" + }, + { + "model_name": "loadbalanced-gpt4", + "model": "openai/gpt-4o", + "api_key": "sk-key1", + "api_base": "https://api1.example.com/v1" + }, + { + "model_name": "loadbalanced-gpt4", + "model": "openai/gpt-4o", + "api_key": "sk-key2", + "api_base": "https://api2.example.com/v1" + } + ], "channels": { "telegram": { "enabled": false, @@ -73,6 +109,7 @@ } }, "providers": { + "_comment": "DEPRECATED: Use model_list instead. This will be removed in v2.0", "anthropic": { "api_key": "", "api_base": "" diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md new file mode 100644 index 000000000..160fbb209 --- /dev/null +++ b/docs/migration/model-list-migration.md @@ -0,0 +1,211 @@ +# Migration Guide: From `providers` to `model_list` + +This guide explains how to migrate from the legacy `providers` configuration to the new `model_list` format. + +## Why Migrate? + +The new `model_list` configuration offers several advantages: + +- **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only +- **Load balancing**: Configure multiple endpoints for the same model +- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc. +- **Cleaner configuration**: Model-centric instead of vendor-centric + +## Timeline + +| Version | Status | +|---------|--------| +| v1.x | `model_list` introduced, `providers` deprecated but functional | +| v1.x+1 | Prominent deprecation warnings, migration tool available | +| v2.0 | `providers` configuration removed | + +## Before and After + +### Before: Legacy `providers` Configuration + +```json +{ + "providers": { + "openai": { + "api_key": "sk-your-openai-key", + "api_base": "https://api.openai.com/v1" + }, + "anthropic": { + "api_key": "sk-ant-your-key" + }, + "deepseek": { + "api_key": "sk-your-deepseek-key" + } + }, + "agents": { + "defaults": { + "provider": "openai", + "model": "gpt-4o" + } + } +} +``` + +### After: New `model_list` Configuration + +```json +{ + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-4o", + "api_key": "sk-your-openai-key", + "api_base": "https://api.openai.com/v1" + }, + { + "model_name": "claude3", + "model": "anthropic/claude-3-sonnet", + "api_key": "sk-ant-your-key" + }, + { + "model_name": "deepseek", + "model": "deepseek/deepseek-chat", + "api_key": "sk-your-deepseek-key" + } + ], + "agents": { + "defaults": { + "model": "gpt4" + } + } +} +``` + +## Protocol Prefixes + +The `model` field uses a protocol prefix format: `[protocol/]model-identifier` + +| Prefix | Description | Example | +|--------|-------------|---------| +| `openai/` | OpenAI API (default) | `openai/gpt-4o` | +| `anthropic/` | Anthropic API | `anthropic/claude-3-opus` | +| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` | +| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-3-sonnet` | +| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` | +| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` | +| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-3` | +| `groq/` | Groq API | `groq/llama-3.1-70b` | +| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` | +| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` | +| `qwen/` | Alibaba Qwen | `qwen/qwen-max` | + +**Note**: If no prefix is specified, `openai/` is used as the default. + +## ModelConfig Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `model_name` | Yes | User-facing alias for the model | +| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-4o`) | +| `api_base` | No | API endpoint URL | +| `api_key` | No* | API authentication key | +| `proxy` | No | HTTP proxy URL | +| `auth_method` | No | Authentication method: `oauth`, `token` | +| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` | +| `rpm` | No | Requests per minute limit | +| `max_tokens_field` | No | Field name for max tokens | + +*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server. + +## Load Balancing + +Configure multiple endpoints for the same model to distribute load: + +```json +{ + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-4o", + "api_key": "sk-key1", + "api_base": "https://api1.example.com/v1" + }, + { + "model_name": "gpt4", + "model": "openai/gpt-4o", + "api_key": "sk-key2", + "api_base": "https://api2.example.com/v1" + }, + { + "model_name": "gpt4", + "model": "openai/gpt-4o", + "api_key": "sk-key3", + "api_base": "https://api3.example.com/v1" + } + ] +} +``` + +When you request model `gpt4`, requests will be distributed across all three endpoints using round-robin selection. + +## Adding a New OpenAI-Compatible Provider + +With `model_list`, adding a new provider requires zero code changes: + +```json +{ + "model_list": [ + { + "model_name": "my-custom-llm", + "model": "openai/my-model-v1", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL. + +## Backward Compatibility + +During the migration period, your existing `providers` configuration will continue to work: + +1. If `model_list` is empty and `providers` has data, the system auto-converts internally +2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"` +3. All existing functionality remains unchanged + +## Migration Checklist + +- [ ] Identify all providers you're currently using +- [ ] Create `model_list` entries for each provider +- [ ] Use appropriate protocol prefixes +- [ ] Update `agents.defaults.model` to reference the new `model_name` +- [ ] Test that all models work correctly +- [ ] Remove or comment out the old `providers` section + +## Troubleshooting + +### Model not found error + +``` +model "xxx" not found in model_list or providers +``` + +**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`. + +### Unknown protocol error + +``` +unknown protocol "xxx" in model "xxx/model-name" +``` + +**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above. + +### Missing API key error + +``` +api_key or api_base is required for HTTP-based protocol "xxx" +``` + +**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers. + +## Need Help? + +- [GitHub Issues](https://github.com/sipeed/picoclaw/issues) +- [Discussion #122](https://github.com/sipeed/picoclaw/discussions/122): Original proposal diff --git a/pkg/config/config.go b/pkg/config/config.go index 2547b863c..4f37d9cea 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sync" + "sync/atomic" "github.com/caarlos0/env/v11" ) @@ -47,11 +48,13 @@ type Config struct { Agents AgentsConfig `json:"agents"` Channels ChannelsConfig `json:"channels"` Providers ProvidersConfig `json:"providers"` + 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 + mu sync.RWMutex + rrCounters map[string]*atomic.Uint64 // Round-robin counters for load balancing } type AgentsConfig struct { @@ -194,6 +197,58 @@ type ProviderConfig struct { ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` } +// 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 +// Default protocol is "openai" if no prefix is specified. +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") + + // HTTP-based providers + APIBase string `json:"api_base,omitempty"` // API endpoint URL + APIKey string `json:"api_key,omitempty"` // API authentication key + Proxy string `json:"proxy,omitempty"` // HTTP proxy URL + + // Special providers (CLI-based, OAuth, etc.) + AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token + ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc + + // Optional optimizations + RPM int `json:"rpm,omitempty"` // Requests per minute limit + MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") +} + +// Validate checks if the ModelConfig has all required fields. +func (c *ModelConfig) Validate() error { + if c.ModelName == "" { + return fmt.Errorf("model_name is required") + } + if c.Model == "" { + return fmt.Errorf("model is required") + } + return nil +} + +// ParseProtocol extracts the protocol prefix and model identifier from the Model field. +// If no prefix is specified, it defaults to "openai". +// Examples: +// - "openai/gpt-4o" -> ("openai", "gpt-4o") +// - "anthropic/claude-3" -> ("anthropic", "claude-3") +// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol +func (c *ModelConfig) ParseProtocol() (protocol, modelID string) { + model := c.Model + for i := 0; i < len(model); i++ { + if model[i] == '/' { + return model[:i], model[i+1:] + } + } + // No prefix found, default to openai + return "openai", model +} + type GatewayConfig struct { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` @@ -469,3 +524,278 @@ func expandHome(path string) string { } return path } + +// 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. +func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { + c.mu.Lock() + defer c.mu.Unlock() + + // Find all configs with matching model_name + var matches []ModelConfig + for i := range c.ModelList { + if c.ModelList[i].ModelName == modelName { + matches = append(matches, c.ModelList[i]) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) + } + + // Single config - return directly + if len(matches) == 1 { + return &matches[0], nil + } + + // Multiple configs - use round-robin for load balancing + if c.rrCounters == nil { + c.rrCounters = make(map[string]*atomic.Uint64) + } + + counter, ok := c.rrCounters[modelName] + if !ok { + counter = &atomic.Uint64{} + c.rrCounters[modelName] = counter + } + + idx := counter.Add(1) % uint64(len(matches)) + return &matches[idx], nil +} + +// 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 != "" || + v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" || + v.Groq.APIKey != "" || v.Groq.APIBase != "" || + v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" || + v.VLLM.APIKey != "" || v.VLLM.APIBase != "" || + v.Gemini.APIKey != "" || v.Gemini.APIBase != "" || + v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" || + v.Ollama.APIKey != "" || v.Ollama.APIBase != "" || + v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" || + v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" || + v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" || + v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" || + v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" || + v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" || + v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" || + v.Qwen.APIKey != "" || v.Qwen.APIBase != "" +} + +// ValidateModelList validates all ModelConfig entries in the model_list. +// It checks that each model_name/model combination is valid. +func (c *Config) ValidateModelList() error { + for i := range c.ModelList { + if err := c.ModelList[i].Validate(); err != nil { + return fmt.Errorf("model_list[%d]: %w", i, err) + } + } + return nil +} + +// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig. +// This enables backward compatibility with existing configurations. +func ConvertProvidersToModelList(cfg *Config) []ModelConfig { + if cfg == nil { + return nil + } + + var result []ModelConfig + p := cfg.Providers + + // OpenAI + if p.OpenAI.APIKey != "" || p.OpenAI.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "openai", + Model: "openai/gpt-4o", + APIKey: p.OpenAI.APIKey, + APIBase: p.OpenAI.APIBase, + Proxy: p.OpenAI.Proxy, + AuthMethod: p.OpenAI.AuthMethod, + }) + } + + // Anthropic + if p.Anthropic.APIKey != "" || p.Anthropic.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "anthropic", + Model: "anthropic/claude-3-sonnet", + APIKey: p.Anthropic.APIKey, + APIBase: p.Anthropic.APIBase, + Proxy: p.Anthropic.Proxy, + AuthMethod: p.Anthropic.AuthMethod, + }) + } + + // OpenRouter + if p.OpenRouter.APIKey != "" || p.OpenRouter.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "openrouter", + Model: "openrouter/auto", + APIKey: p.OpenRouter.APIKey, + APIBase: p.OpenRouter.APIBase, + Proxy: p.OpenRouter.Proxy, + }) + } + + // Groq + if p.Groq.APIKey != "" || p.Groq.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "groq", + Model: "groq/llama-3.1-70b-versatile", + APIKey: p.Groq.APIKey, + APIBase: p.Groq.APIBase, + Proxy: p.Groq.Proxy, + }) + } + + // Zhipu + if p.Zhipu.APIKey != "" || p.Zhipu.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "zhipu", + Model: "openai/glm-4", + APIKey: p.Zhipu.APIKey, + APIBase: p.Zhipu.APIBase, + Proxy: p.Zhipu.Proxy, + }) + } + + // VLLM + if p.VLLM.APIKey != "" || p.VLLM.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "vllm", + Model: "openai/auto", + APIKey: p.VLLM.APIKey, + APIBase: p.VLLM.APIBase, + Proxy: p.VLLM.Proxy, + }) + } + + // Gemini + if p.Gemini.APIKey != "" || p.Gemini.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "gemini", + Model: "openai/gemini-pro", + APIKey: p.Gemini.APIKey, + APIBase: p.Gemini.APIBase, + Proxy: p.Gemini.Proxy, + }) + } + + // Nvidia + if p.Nvidia.APIKey != "" || p.Nvidia.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "nvidia", + Model: "nvidia/meta/llama-3.1-8b-instruct", + APIKey: p.Nvidia.APIKey, + APIBase: p.Nvidia.APIBase, + Proxy: p.Nvidia.Proxy, + }) + } + + // Ollama + if p.Ollama.APIKey != "" || p.Ollama.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "ollama", + Model: "ollama/llama3", + APIKey: p.Ollama.APIKey, + APIBase: p.Ollama.APIBase, + Proxy: p.Ollama.Proxy, + }) + } + + // Moonshot + if p.Moonshot.APIKey != "" || p.Moonshot.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "moonshot", + Model: "moonshot/kimi", + APIKey: p.Moonshot.APIKey, + APIBase: p.Moonshot.APIBase, + Proxy: p.Moonshot.Proxy, + }) + } + + // ShengSuanYun + if p.ShengSuanYun.APIKey != "" || p.ShengSuanYun.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "shengsuanyun", + Model: "openai/auto", + APIKey: p.ShengSuanYun.APIKey, + APIBase: p.ShengSuanYun.APIBase, + Proxy: p.ShengSuanYun.Proxy, + }) + } + + // DeepSeek + if p.DeepSeek.APIKey != "" || p.DeepSeek.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "deepseek", + Model: "openai/deepseek-chat", + APIKey: p.DeepSeek.APIKey, + APIBase: p.DeepSeek.APIBase, + Proxy: p.DeepSeek.Proxy, + }) + } + + // Cerebras + if p.Cerebras.APIKey != "" || p.Cerebras.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "cerebras", + Model: "cerebras/llama-3.3-70b", + APIKey: p.Cerebras.APIKey, + APIBase: p.Cerebras.APIBase, + Proxy: p.Cerebras.Proxy, + }) + } + + // VolcEngine (Doubao) + if p.VolcEngine.APIKey != "" || p.VolcEngine.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "volcengine", + Model: "openai/doubao-pro", + APIKey: p.VolcEngine.APIKey, + APIBase: p.VolcEngine.APIBase, + Proxy: p.VolcEngine.Proxy, + }) + } + + // GitHub Copilot + if p.GitHubCopilot.APIKey != "" || p.GitHubCopilot.APIBase != "" || p.GitHubCopilot.ConnectMode != "" { + result = append(result, ModelConfig{ + ModelName: "github-copilot", + Model: "github-copilot/gpt-4o", + APIBase: p.GitHubCopilot.APIBase, + ConnectMode: p.GitHubCopilot.ConnectMode, + }) + } + + // Antigravity + if p.Antigravity.APIKey != "" || p.Antigravity.AuthMethod != "" { + result = append(result, ModelConfig{ + ModelName: "antigravity", + Model: "antigravity/gemini-2.0-flash", + APIKey: p.Antigravity.APIKey, + AuthMethod: p.Antigravity.AuthMethod, + }) + } + + // Qwen + if p.Qwen.APIKey != "" || p.Qwen.APIBase != "" { + result = append(result, ModelConfig{ + ModelName: "qwen", + Model: "qwen/qwen-max", + APIKey: p.Qwen.APIKey, + APIBase: p.Qwen.APIBase, + Proxy: p.Qwen.Proxy, + }) + } + + return result +} diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go new file mode 100644 index 000000000..ff9a4ef20 --- /dev/null +++ b/pkg/providers/factory_provider.go @@ -0,0 +1,131 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package providers + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// ExtractProtocol extracts the protocol prefix and model identifier from a model string. +// If no prefix is specified, it defaults to "openai". +// Examples: +// - "openai/gpt-4o" -> ("openai", "gpt-4o") +// - "anthropic/claude-3" -> ("anthropic", "claude-3") +// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol +func ExtractProtocol(model string) (protocol, modelID string) { + model = strings.TrimSpace(model) + for i := 0; i < len(model); i++ { + if model[i] == '/' { + return model[:i], model[i+1:] + } + } + // No prefix found, default to openai + return "openai", model +} + +// CreateProviderFromConfig creates a provider based on the ModelConfig. +// It uses the protocol prefix in the Model field to determine which provider to create. +// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot +func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, error) { + if cfg == nil { + return nil, fmt.Errorf("config is nil") + } + + if cfg.Model == "" { + return nil, fmt.Errorf("model is required") + } + + protocol, modelID := ExtractProtocol(cfg.Model) + + switch protocol { + case "openai", "openrouter", "groq", "zhipu", "gemini", "nvidia", + "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", + "volcengine", "vllm", "qwen": + // All OpenAI-compatible HTTP providers + 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 + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + return NewHTTPProvider(cfg.APIKey, apiBase, cfg.Proxy), nil + + case "anthropic": + if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + // Use Claude SDK with token + return NewClaudeProvider(cfg.APIKey), nil + } + // Use HTTP API + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = "https://api.anthropic.com/v1" + } + return NewHTTPProvider(cfg.APIKey, apiBase, cfg.Proxy), nil + + case "antigravity": + return NewAntigravityProvider(), nil + + case "claude-cli", "claudecli": + workspace := "." + return NewClaudeCliProvider(workspace), nil + + case "codex-cli", "codexcli": + workspace := "." + return NewCodexCliProvider(workspace), nil + + case "github-copilot", "copilot": + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = "localhost:4321" + } + connectMode := cfg.ConnectMode + if connectMode == "" { + connectMode = "grpc" + } + return NewGitHubCopilotProvider(apiBase, connectMode, modelID) + + default: + return nil, fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model) + } +} + +// getDefaultAPIBase returns the default API base URL for a given protocol. +func getDefaultAPIBase(protocol string) string { + switch protocol { + case "openai": + return "https://api.openai.com/v1" + case "openrouter": + return "https://openrouter.ai/api/v1" + case "groq": + return "https://api.groq.com/openai/v1" + case "zhipu": + return "https://open.bigmodel.cn/api/paas/v4" + case "gemini": + return "https://generativelanguage.googleapis.com/v1beta" + case "nvidia": + return "https://integrate.api.nvidia.com/v1" + case "ollama": + return "http://localhost:11434/v1" + case "moonshot": + return "https://api.moonshot.cn/v1" + case "shengsuanyun": + return "https://router.shengsuanyun.com/api/v1" + case "deepseek": + return "https://api.deepseek.com/v1" + case "cerebras": + return "https://api.cerebras.ai/v1" + case "volcengine": + return "https://ark.cn-beijing.volces.com/api/v3" + case "qwen": + return "https://dashscope.aliyuncs.com/compatible-mode/v1" + default: + return "" + } +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index acc457b50..d264ae3a3 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -221,6 +221,27 @@ func createCodexAuthProvider() (LLMProvider, error) { func CreateProvider(cfg *config.Config) (LLMProvider, error) { model := cfg.Agents.Defaults.Model + + // First, try to use model_list configuration + if len(cfg.ModelList) > 0 { + // Try to get config by model name first + modelCfg, err := cfg.GetModelConfig(model) + if err == nil { + // Found in model_list, use factory to create provider + provider, err := CreateProviderFromConfig(modelCfg) + if err != nil { + return nil, fmt.Errorf("failed to create provider from model_list: %w", err) + } + return provider, nil + } + // Model not found in model_list, fall through to providers config + } + + // Log deprecation warning if using old providers config + if cfg.HasProvidersConfig() && len(cfg.ModelList) == 0 { + fmt.Println("WARNING: providers config is deprecated, please migrate to model_list") + } + providerName := strings.ToLower(cfg.Agents.Defaults.Provider) var apiKey, apiBase, proxy string diff --git a/pkg/providers/registry.go b/pkg/providers/registry.go new file mode 100644 index 000000000..b9adef5d5 --- /dev/null +++ b/pkg/providers/registry.go @@ -0,0 +1,113 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package providers + +import ( + "fmt" + "sync" + "sync/atomic" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// ModelRegistry manages model configurations with thread-safe round-robin load balancing. +// It allows multiple configurations for the same model_name to distribute load across endpoints. +type ModelRegistry struct { + configs map[string][]config.ModelConfig // model_name -> []ModelConfig + counters map[string]*atomic.Uint64 // model_name -> round-robin counter + mu sync.RWMutex +} + +// NewModelRegistry creates a new ModelRegistry from a slice of ModelConfig. +func NewModelRegistry(modelList []config.ModelConfig) *ModelRegistry { + r := &ModelRegistry{ + configs: make(map[string][]config.ModelConfig), + counters: make(map[string]*atomic.Uint64), + } + + for _, cfg := range modelList { + r.configs[cfg.ModelName] = append(r.configs[cfg.ModelName], cfg) + } + + // Initialize counters for models with multiple configs + for name, cfgs := range r.configs { + if len(cfgs) > 1 { + r.counters[name] = &atomic.Uint64{} + } + } + + return r +} + +// GetModelConfig returns a ModelConfig for the given model name. +// If multiple configs exist for the same model_name, it uses round-robin selection. +// Returns an error if the model is not found. +func (r *ModelRegistry) GetModelConfig(modelName string) (*config.ModelConfig, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + configs, ok := r.configs[modelName] + if !ok || len(configs) == 0 { + return nil, fmt.Errorf("model %q not found", modelName) + } + + // Single config - return directly + if len(configs) == 1 { + return &configs[0], nil + } + + // Multiple configs - use round-robin for load balancing + counter, ok := r.counters[modelName] + if !ok { + // Should not happen, but handle gracefully + return &configs[0], nil + } + + idx := counter.Add(1) % uint64(len(configs)) + return &configs[idx], nil +} + +// AddConfig adds a new ModelConfig to the registry. +func (r *ModelRegistry) AddConfig(cfg config.ModelConfig) { + r.mu.Lock() + defer r.mu.Unlock() + + r.configs[cfg.ModelName] = append(r.configs[cfg.ModelName], cfg) + + // Initialize counter if we now have multiple configs + if len(r.configs[cfg.ModelName]) > 1 && r.counters[cfg.ModelName] == nil { + r.counters[cfg.ModelName] = &atomic.Uint64{} + } +} + +// RemoveConfig removes all configs with the given model_name. +func (r *ModelRegistry) RemoveConfig(modelName string) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.configs, modelName) + delete(r.counters, modelName) +} + +// ListModels returns all unique model names in the registry. +func (r *ModelRegistry) ListModels() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + names := make([]string, 0, len(r.configs)) + for name := range r.configs { + names = append(names, name) + } + return names +} + +// ConfigCount returns the number of configurations for a given model name. +func (r *ModelRegistry) ConfigCount(modelName string) int { + r.mu.RLock() + defer r.mu.RUnlock() + + return len(r.configs[modelName]) +}