mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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": ""
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user