feat: add model_list configuration for zero-code provider addition

- Add ModelConfig struct with protocol prefix support (openai/, anthropic/, etc.)
- Implement GetModelConfig with round-robin load balancing
- Add CreateProviderFromConfig factory for protocol-based routing
- Add ModelRegistry for thread-safe endpoint selection
- Maintain full backward compatibility with legacy providers config
- Update README.md and README.zh.md with model_list documentation
- Add migration guide at docs/migration/model-list-migration.md

Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli,
github-copilot, openrouter, groq, deepseek, cerebras, qwen, zhipu, gemini

Closes #283

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yinwm
2026-02-18 23:26:00 +08:00
parent 5d1669ecc4
commit a73d8e1a16
8 changed files with 987 additions and 14 deletions
+71 -6
View File
@@ -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.
<details>
<summary><b>Zhipu</b></summary>
+71 -6
View File
@@ -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)。
<details>
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
+38 -1
View File
@@ -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": ""
+211
View File
@@ -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
+331 -1
View File
@@ -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
}
+131
View File
@@ -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 ""
}
}
+21
View File
@@ -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
+113
View File
@@ -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])
}