mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
refactor: support explicit provider field in model list entries (#2609)
* refactor: support explicit model list providers * fix(web): preserve explicit model providers * fix(web): preserve legacy provider prefixes on model updates fix(models): normalize explicit provider-prefixed ids fix(api): preserve legacy model updates across providers fix(agent): preserve config identity for explicit provider refs * fix ci
This commit is contained in:
@@ -59,7 +59,7 @@ func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
|
||||
// Update or add openai in ModelList
|
||||
foundOpenAI := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
foundOpenAI = true
|
||||
break
|
||||
@@ -130,7 +130,7 @@ func authLoginGoogleAntigravity(noBrowser bool) error {
|
||||
// Update or add antigravity in ModelList
|
||||
foundAntigravity := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||
if isAntigravityModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
foundAntigravity = true
|
||||
break
|
||||
@@ -206,7 +206,7 @@ func authLoginAnthropicSetupToken() error {
|
||||
if err == nil {
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
@@ -282,7 +282,7 @@ func authLoginPasteToken(provider string) error {
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
@@ -300,7 +300,7 @@ func authLoginPasteToken(provider string) error {
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
@@ -342,15 +342,15 @@ func authLogoutCmd(provider string) error {
|
||||
for i := range appCfg.ModelList {
|
||||
switch provider {
|
||||
case "openai":
|
||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "anthropic":
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||
if isAntigravityModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
@@ -484,22 +484,20 @@ func authModelsCmd() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAntigravityModel checks if a model string belongs to antigravity provider
|
||||
func isAntigravityModel(model string) bool {
|
||||
return model == "antigravity" ||
|
||||
model == "google-antigravity" ||
|
||||
strings.HasPrefix(model, "antigravity/") ||
|
||||
strings.HasPrefix(model, "google-antigravity/")
|
||||
// isAntigravityModel checks if a model config belongs to an Antigravity provider.
|
||||
func isAntigravityModel(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
return protocol == "antigravity" || protocol == "google-antigravity"
|
||||
}
|
||||
|
||||
// isOpenAIModel checks if a model string belongs to openai provider
|
||||
func isOpenAIModel(model string) bool {
|
||||
return model == "openai" ||
|
||||
strings.HasPrefix(model, "openai/")
|
||||
// isOpenAIModel checks if a model config belongs to the OpenAI provider.
|
||||
func isOpenAIModel(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
return protocol == "openai"
|
||||
}
|
||||
|
||||
// isAnthropicModel checks if a model string belongs to anthropic provider
|
||||
func isAnthropicModel(model string) bool {
|
||||
return model == "anthropic" ||
|
||||
strings.HasPrefix(model, "anthropic/")
|
||||
// isAnthropicModel checks if a model config belongs to the Anthropic provider.
|
||||
func isAnthropicModel(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
return protocol == "anthropic"
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ package status
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func statusCmd() {
|
||||
@@ -44,12 +44,13 @@ func statusCmd() {
|
||||
// not depend on a legacy cfg.Providers field (which may not exist under some
|
||||
// build tags). We infer provider availability from model_list entries.
|
||||
hasProtocolKey := func(protocol string) bool {
|
||||
prefix := protocol + "/"
|
||||
want := providers.NormalizeProvider(protocol)
|
||||
for _, m := range cfg.ModelList {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" {
|
||||
got, _ := providers.ExtractProtocol(m)
|
||||
if got == want && m.APIKey() != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -67,12 +68,13 @@ func statusCmd() {
|
||||
return "", false
|
||||
}
|
||||
findProtocolBase := func(protocol string) (string, bool) {
|
||||
prefix := protocol + "/"
|
||||
want := providers.NormalizeProvider(protocol)
|
||||
for _, m := range cfg.ModelList {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" {
|
||||
got, _ := providers.ExtractProtocol(m)
|
||||
if got == want && m.APIBase != "" {
|
||||
return m.APIBase, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe() error = %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
|
||||
fn()
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = oldStdout
|
||||
defer r.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatalf("io.Copy() error = %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestStatusCmd_RecognizesProviderFieldWithoutModelPrefix(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
workspace := filepath.Join(tmpDir, "workspace")
|
||||
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
||||
t.Fatalf("os.MkdirAll() error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(config.EnvConfig, configPath)
|
||||
t.Setenv(config.EnvHome, tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-5.4",
|
||||
Workspace: workspace,
|
||||
Provider: "openai",
|
||||
MaxTokens: 65536,
|
||||
Temperature: nil,
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-5.4",
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
APIBase: "https://api.openai.com/v1",
|
||||
APIKeys: config.SimpleSecureStrings("test-key"),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ModelName: "qwen-plus",
|
||||
Provider: "qwen",
|
||||
Model: "qwen-plus",
|
||||
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
APIKeys: config.SimpleSecureStrings("test-key"),
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("config.SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
output := captureStdout(t, statusCmd)
|
||||
|
||||
if !strings.Contains(output, "OpenAI API: \u2713") {
|
||||
t.Fatalf("status output missing OpenAI provider: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "Qwen API: \u2713") {
|
||||
t.Fatalf("status output missing Qwen provider: %s", output)
|
||||
}
|
||||
}
|
||||
@@ -494,7 +494,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||
|
||||
### Model Configuration (model_list)
|
||||
|
||||
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers — **zero code changes required!**
|
||||
> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted.
|
||||
|
||||
This design also enables **multi-agent support** with flexible provider selection:
|
||||
|
||||
@@ -547,7 +547,8 @@ chmod 600 ~/.picoclaw/.security.yml
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4"
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4"
|
||||
// api_key loaded from .security.yml
|
||||
}
|
||||
],
|
||||
@@ -571,31 +572,31 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
|
||||
|
||||
#### All Supported Vendors
|
||||
|
||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||
| Vendor | `provider` Value | Default API Base | Protocol | API Key |
|
||||
| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — |
|
||||
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
|
||||
| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||
| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
|
||||
| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — |
|
||||
|
||||
#### Basic Configuration
|
||||
|
||||
@@ -604,22 +605,26 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest",
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_keys": ["sk-ant-your-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-zhipu-key"]
|
||||
}
|
||||
],
|
||||
@@ -635,6 +640,13 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
|
||||
>
|
||||
> **Note**: The `enabled` field can be set to `false` to disable a model entry without removing it. When omitted, it defaults to `true` during migration for models that have API keys.
|
||||
|
||||
Resolution rules:
|
||||
|
||||
- Prefer explicit `"provider": "openai", "model": "gpt-5.4"`.
|
||||
- If `provider` is set, PicoClaw sends `model` unchanged.
|
||||
- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID.
|
||||
- This means `"model": "openrouter/openai/gpt-5.4"` still works as a compatibility form and sends `openai/gpt-5.4` to OpenRouter.
|
||||
|
||||
#### Vendor-Specific Examples
|
||||
|
||||
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
|
||||
@@ -645,7 +657,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4"
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
@@ -658,7 +671,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest"
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
@@ -671,7 +685,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7"
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
@@ -684,7 +699,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat"
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
@@ -697,7 +713,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6"
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
@@ -709,7 +726,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"provider": "anthropic-messages",
|
||||
"model": "claude-opus-4-6",
|
||||
"api_keys": ["sk-ant-your-key"],
|
||||
"api_base": "https://api.anthropic.com"
|
||||
}
|
||||
@@ -725,7 +743,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
"provider": "ollama",
|
||||
"model": "llama3"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -737,12 +756,13 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||
```json
|
||||
{
|
||||
"model_name": "lmstudio-local",
|
||||
"model": "lmstudio/openai/gpt-oss-20b"
|
||||
"provider": "lmstudio",
|
||||
"model": "openai/gpt-oss-20b"
|
||||
}
|
||||
```
|
||||
|
||||
`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.<br/>
|
||||
PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server.
|
||||
With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to LM Studio. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -752,13 +772,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"provider": "openai",
|
||||
"model": "custom-model",
|
||||
"api_base": "https://my-proxy.com/v1"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
|
||||
PicoClaw strips only the outer `litellm/` prefix before sending the request, so `litellm/lite-gpt4` sends `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
|
||||
With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -783,7 +804,8 @@ model_list:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
// api_keys loaded from .security.yml
|
||||
}
|
||||
@@ -798,13 +820,15 @@ model_list:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_keys": ["sk-key1"]
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_keys": ["sk-key2"]
|
||||
}
|
||||
@@ -864,7 +888,7 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
"model_name": "claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
|
||||
@@ -425,7 +425,7 @@ Agent 读取 HEARTBEAT.md
|
||||
|
||||
### 模型配置 (model_list)
|
||||
|
||||
> **新特性:** PicoClaw 现在采用**以模型为中心**的配置方式。只需指定 `vendor/model` 格式(例如 `zhipu/glm-4.7`)即可接入新提供商——**无需修改任何代码!**
|
||||
> **新特性:** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。
|
||||
|
||||
这一设计同时支持**多 Agent**场景,灵活选择提供商:
|
||||
|
||||
@@ -436,31 +436,31 @@ Agent 读取 HEARTBEAT.md
|
||||
|
||||
#### 所有支持的厂商
|
||||
|
||||
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | API Key |
|
||||
| 厂商 | `provider` 值 | 默认 API Base | 协议 | API Key |
|
||||
| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) |
|
||||
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) |
|
||||
| **火山引擎 (豆包)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | 仅 OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — |
|
||||
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) |
|
||||
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
|
||||
| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
|
||||
| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||
| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) |
|
||||
| **火山引擎 (豆包)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
|
||||
| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity` | Google Cloud | Custom | 仅 OAuth |
|
||||
| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — |
|
||||
|
||||
#### 基础配置
|
||||
|
||||
@@ -469,22 +469,26 @@ Agent 读取 HEARTBEAT.md
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest",
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_keys": ["sk-ant-your-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-zhipu-key"]
|
||||
}
|
||||
],
|
||||
@@ -496,6 +500,13 @@ Agent 读取 HEARTBEAT.md
|
||||
}
|
||||
```
|
||||
|
||||
解析规则:
|
||||
|
||||
- 推荐显式写成 `"provider": "openai", "model": "gpt-5.4"`。
|
||||
- 如果设置了 `provider`,PicoClaw 会将 `model` 原样发送。
|
||||
- 如果未设置 `provider`,PicoClaw 会把 `model` 第一个 `/` 之前的字段当作 provider,并把第一个 `/` 之后的全部内容当作最终模型 ID。
|
||||
- 这意味着 `"model": "openrouter/openai/gpt-5.4"` 这样的兼容写法仍然可用,并会把 `openai/gpt-5.4` 发送给 OpenRouter。
|
||||
|
||||
#### 各厂商配置示例
|
||||
|
||||
<details>
|
||||
@@ -504,7 +515,8 @@ Agent 读取 HEARTBEAT.md
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
@@ -517,7 +529,8 @@ Agent 读取 HEARTBEAT.md
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
@@ -530,7 +543,8 @@ Agent 读取 HEARTBEAT.md
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-key"]
|
||||
}
|
||||
```
|
||||
@@ -543,7 +557,8 @@ Agent 读取 HEARTBEAT.md
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
@@ -556,7 +571,8 @@ Agent 读取 HEARTBEAT.md
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_keys": ["sk-ant-your-key"]
|
||||
}
|
||||
```
|
||||
@@ -568,7 +584,8 @@ Agent 读取 HEARTBEAT.md
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"provider": "anthropic-messages",
|
||||
"model": "claude-opus-4-6",
|
||||
"api_keys": ["sk-ant-your-key"],
|
||||
"api_base": "https://api.anthropic.com"
|
||||
}
|
||||
@@ -584,7 +601,8 @@ Agent 读取 HEARTBEAT.md
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
"provider": "ollama",
|
||||
"model": "llama3"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -596,12 +614,13 @@ Agent 读取 HEARTBEAT.md
|
||||
```json
|
||||
{
|
||||
"model_name": "lmstudio-local",
|
||||
"model": "lmstudio/openai/gpt-oss-20b"
|
||||
"provider": "lmstudio",
|
||||
"model": "openai/gpt-oss-20b"
|
||||
}
|
||||
```
|
||||
|
||||
`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。
|
||||
PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。
|
||||
显式设置 `provider` 后,PicoClaw 会把 `openai/gpt-oss-20b` 原样发送给 LM Studio。旧的兼容写法 `"model": "lmstudio/openai/gpt-oss-20b"` 在未设置 `provider` 时也会解析成相同的上游模型 ID。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -611,13 +630,14 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"provider": "openai",
|
||||
"model": "custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
|
||||
PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litellm/lite-gpt4` 发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 发送 `openai/gpt-4o`。
|
||||
显式设置 `provider` 后,PicoClaw 会将 `model` 原样发送。因此 `"provider": "litellm", "model": "lite-gpt4"` 会发送 `lite-gpt4`,而 `"provider": "litellm", "model": "openai/gpt-4o"` 会发送 `openai/gpt-4o`。旧的兼容写法 `litellm/lite-gpt4` 和 `litellm/openai/gpt-4o` 在未设置 `provider` 时也会得到相同结果。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -630,13 +650,15 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_keys": ["sk-key1"]
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_keys": ["sk-key2"]
|
||||
}
|
||||
@@ -691,7 +713,7 @@ PicoClaw 按协议族路由提供商:
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
"model_name": "claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
|
||||
@@ -94,19 +94,22 @@ picoclaw onboard
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest",
|
||||
"api_keys": ["sk-your-api-key"],
|
||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["your-api-key"],
|
||||
"request_timeout": 300
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_keys": ["your-anthropic-key"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -93,19 +93,22 @@ picoclaw onboard
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest",
|
||||
"api_keys": ["sk-your-api-key"],
|
||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["your-api-key"],
|
||||
"request_timeout": 300
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_keys": ["your-anthropic-key"]
|
||||
}
|
||||
],
|
||||
|
||||
+96
-56
@@ -33,7 +33,7 @@
|
||||
|
||||
### Model Configuration (model_list)
|
||||
|
||||
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
|
||||
> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted.
|
||||
|
||||
For agent dispatch and light-model routing examples, see the [Routing Guide](routing-guide.md).
|
||||
|
||||
@@ -46,35 +46,35 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||
|
||||
#### 📋 All Supported Vendors
|
||||
|
||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||
| Vendor | `provider` Value | Default API Base | Protocol | API Key |
|
||||
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||
| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
|
||||
| **Xiaomi MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) |
|
||||
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||
| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
|
||||
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
|
||||
| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
|
||||
| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||
| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
|
||||
| **Xiaomi MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) |
|
||||
| **Azure OpenAI** | `azure` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) |
|
||||
| **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - |
|
||||
|
||||
#### Basic Configuration
|
||||
|
||||
@@ -83,22 +83,26 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest",
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_keys": ["sk-ant-your-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-zhipu-key"]
|
||||
}
|
||||
],
|
||||
@@ -115,7 +119,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `model_name` | string | Yes | Unique name used to reference this model in agent config |
|
||||
| `model` | string | Yes | Vendor/model identifier (e.g., `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||
| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider |
|
||||
| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported |
|
||||
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | No | Override the default API endpoint URL |
|
||||
| `proxy` | string | No | HTTP proxy URL for this model entry |
|
||||
@@ -129,6 +134,22 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||
| `fallbacks` | string[] | No | Fallback model names for automatic failover |
|
||||
| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
|
||||
|
||||
#### Provider / Model Resolution
|
||||
|
||||
PicoClaw resolves `provider` and the runtime model ID using these rules:
|
||||
|
||||
- If `provider` is set, `model` is used as-is.
|
||||
- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID.
|
||||
|
||||
Examples:
|
||||
|
||||
| Config | Resolved Provider | Model Sent Upstream |
|
||||
| --- | --- | --- |
|
||||
| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` |
|
||||
| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` |
|
||||
| `"provider": "openrouter", "model": "openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
|
||||
| `"model": "openrouter/openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
|
||||
|
||||
#### Voice Transcription
|
||||
|
||||
You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq.
|
||||
@@ -140,7 +161,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "voice-gemini",
|
||||
"model": "gemini/gemini-2.5-flash",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-2.5-flash",
|
||||
"api_keys": ["your-gemini-key"]
|
||||
}
|
||||
],
|
||||
@@ -163,7 +185,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
@@ -173,7 +196,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
@@ -183,7 +207,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-key"]
|
||||
}
|
||||
```
|
||||
@@ -193,7 +218,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "openai/glm-4.7",
|
||||
"provider": "openai",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-z.ai-key"],
|
||||
"api_base": "https://api.z.ai/api/coding/paas/v4"
|
||||
}
|
||||
@@ -204,7 +230,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
@@ -214,7 +241,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_keys": ["sk-ant-your-key"]
|
||||
}
|
||||
```
|
||||
@@ -228,7 +256,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"provider": "anthropic-messages",
|
||||
"model": "claude-opus-4-6",
|
||||
"api_keys": ["sk-ant-your-key"],
|
||||
"api_base": "https://api.anthropic.com"
|
||||
}
|
||||
@@ -246,7 +275,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
"provider": "ollama",
|
||||
"model": "llama3"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -255,19 +285,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
||||
```json
|
||||
{
|
||||
"model_name": "lmstudio-local",
|
||||
"model": "lmstudio/openai/gpt-oss-20b"
|
||||
"provider": "lmstudio",
|
||||
"model": "openai/gpt-oss-20b"
|
||||
}
|
||||
```
|
||||
|
||||
`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.<br/>
|
||||
PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server.
|
||||
With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to the LM Studio server. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted.
|
||||
|
||||
**Custom Proxy/API**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"provider": "openai",
|
||||
"model": "custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_keys": ["sk-..."],
|
||||
"user_agent": "MyApp/1.0",
|
||||
@@ -280,13 +312,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
|
||||
```json
|
||||
{
|
||||
"model_name": "lite-gpt4",
|
||||
"model": "litellm/lite-gpt4",
|
||||
"provider": "litellm",
|
||||
"model": "lite-gpt4",
|
||||
"api_base": "http://localhost:4000/v1",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
|
||||
PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
|
||||
With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted.
|
||||
|
||||
**Z.AI Coding Plan**
|
||||
|
||||
@@ -295,7 +328,8 @@ If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "openai/glm-4.7",
|
||||
"provider": "openai",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-zhipu-api-key"],
|
||||
"api_base": "https://api.z.ai/api/coding/paas/v4"
|
||||
}
|
||||
@@ -312,13 +346,15 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_keys": ["sk-key1"]
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_keys": ["sk-key2"]
|
||||
}
|
||||
@@ -337,18 +373,21 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "qwen-main",
|
||||
"model": "openai/qwen3.5:cloud",
|
||||
"provider": "openai",
|
||||
"model": "qwen3.5:cloud",
|
||||
"api_base": "https://api.example.com/v1",
|
||||
"api_keys": ["sk-main"]
|
||||
},
|
||||
{
|
||||
"model_name": "deepseek-backup",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"api_keys": ["sk-backup-1"]
|
||||
},
|
||||
{
|
||||
"model_name": "gemini-backup",
|
||||
"model": "gemini/gemini-2.5-flash",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-2.5-flash",
|
||||
"api_keys": ["sk-backup-2"]
|
||||
}
|
||||
],
|
||||
@@ -396,7 +435,8 @@ The old `providers` configuration is **deprecated** and has been removed in V2.
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-key"]
|
||||
}
|
||||
],
|
||||
@@ -465,7 +505,7 @@ picoclaw agent -m "Hello"
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "anthropic/claude-opus-4-5"
|
||||
"model_name": "claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
|
||||
+90
-52
@@ -32,7 +32,7 @@
|
||||
<a id="模型配置-model_list"></a>
|
||||
### 模型配置 (model_list)
|
||||
|
||||
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
|
||||
> **新功能!** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。
|
||||
|
||||
如果你想看 agent 分发和轻量模型路由的完整示例,请看 [路由使用指南](routing-guide.zh.md)。
|
||||
|
||||
@@ -45,33 +45,33 @@
|
||||
|
||||
#### 📋 所有支持的厂商
|
||||
|
||||
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
||||
| 厂商 | `provider` 值 | 默认 API Base | 协议 | 获取 API Key |
|
||||
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
||||
| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取密钥](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
|
||||
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||
| **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
|
||||
| **小米 MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
||||
| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) |
|
||||
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取密钥](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
|
||||
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
|
||||
| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
|
||||
| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||
| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||
| **火山引擎(Doubao)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
|
||||
| **小米 MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) |
|
||||
| **Antigravity** | `antigravity` | Google Cloud | 自定义 | 仅 OAuth |
|
||||
| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - |
|
||||
|
||||
#### 基础配置示例
|
||||
|
||||
@@ -80,22 +80,26 @@
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest",
|
||||
"api_keys": ["sk-your-api-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_keys": ["sk-ant-your-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-zhipu-key"]
|
||||
}
|
||||
],
|
||||
@@ -112,7 +116,8 @@
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 |
|
||||
| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) |
|
||||
| `provider` | string | 否 | 推荐的 provider 标识。设置后,PicoClaw 会将 `model` 原样发送给该 provider |
|
||||
| `model` | string | 是 | 当设置 `provider` 时,这里填写 provider 原生模型 ID。若未设置 `provider`,仍兼容旧的 `provider/model` 写法 |
|
||||
| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 |
|
||||
| `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
|
||||
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
|
||||
@@ -126,6 +131,22 @@
|
||||
| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 |
|
||||
| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) |
|
||||
|
||||
#### `provider` / `model` 解析规则
|
||||
|
||||
PicoClaw 按下面的规则解析 `provider` 和最终发给上游的模型 ID:
|
||||
|
||||
- 如果设置了 `provider`,则直接使用 `model`。
|
||||
- 如果未设置 `provider`,则把 `model` 中第一个 `/` 之前的字段当作 provider,第一个 `/` 之后的全部内容当作最终模型 ID。
|
||||
|
||||
示例:
|
||||
|
||||
| 配置 | 解析后的 Provider | 实际发送的模型 ID |
|
||||
| --- | --- | --- |
|
||||
| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` |
|
||||
| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` |
|
||||
| `"provider": "openrouter", "model": "openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
|
||||
| `"model": "openrouter/openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
|
||||
|
||||
#### 语音转录
|
||||
|
||||
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
|
||||
@@ -137,7 +158,8 @@
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "voice-gemini",
|
||||
"model": "gemini/gemini-2.5-flash",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-2.5-flash",
|
||||
"api_keys": ["your-gemini-key"]
|
||||
}
|
||||
],
|
||||
@@ -160,7 +182,8 @@
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
@@ -170,7 +193,8 @@
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"provider": "volcengine",
|
||||
"model": "ark-code-latest",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
@@ -180,7 +204,8 @@
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-key"]
|
||||
}
|
||||
```
|
||||
@@ -190,7 +215,8 @@
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
@@ -200,7 +226,8 @@
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
```
|
||||
@@ -214,7 +241,8 @@
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"provider": "anthropic-messages",
|
||||
"model": "claude-opus-4-6",
|
||||
"api_keys": ["sk-ant-your-key"],
|
||||
"api_base": "https://api.anthropic.com"
|
||||
}
|
||||
@@ -232,7 +260,8 @@
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
"provider": "ollama",
|
||||
"model": "llama3"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -241,19 +270,21 @@
|
||||
```json
|
||||
{
|
||||
"model_name": "lmstudio-local",
|
||||
"model": "lmstudio/openai/gpt-oss-20b"
|
||||
"provider": "lmstudio",
|
||||
"model": "openai/gpt-oss-20b"
|
||||
}
|
||||
```
|
||||
|
||||
`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。
|
||||
PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。
|
||||
显式设置 `provider` 后,PicoClaw 会把 `openai/gpt-oss-20b` 原样发送给 LM Studio。旧的兼容写法 `"model": "lmstudio/openai/gpt-oss-20b"` 在未设置 `provider` 时也会解析成相同的上游模型 ID。
|
||||
|
||||
**自定义代理/API**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"provider": "openai",
|
||||
"model": "custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_keys": ["sk-..."],
|
||||
"user_agent": "MyApp/1.0",
|
||||
@@ -266,13 +297,14 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
|
||||
```json
|
||||
{
|
||||
"model_name": "lite-gpt4",
|
||||
"model": "litellm/lite-gpt4",
|
||||
"provider": "litellm",
|
||||
"model": "lite-gpt4",
|
||||
"api_base": "http://localhost:4000/v1",
|
||||
"api_keys": ["sk-..."]
|
||||
}
|
||||
```
|
||||
|
||||
PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/lite-gpt4` 会发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 会发送 `openai/gpt-4o`。
|
||||
显式设置 `provider` 后,PicoClaw 会将 `model` 原样发送。因此 `"provider": "litellm", "model": "lite-gpt4"` 会发送 `lite-gpt4`,而 `"provider": "litellm", "model": "openai/gpt-4o"` 会发送 `openai/gpt-4o`。旧的兼容写法 `litellm/lite-gpt4` 和 `litellm/openai/gpt-4o` 在未设置 `provider` 时也会得到相同结果。
|
||||
|
||||
#### 负载均衡
|
||||
|
||||
@@ -283,13 +315,15 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_keys": ["sk-key1"]
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_keys": ["sk-key2"]
|
||||
}
|
||||
@@ -308,18 +342,21 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "qwen-main",
|
||||
"model": "openai/qwen3.5:cloud",
|
||||
"provider": "openai",
|
||||
"model": "qwen3.5:cloud",
|
||||
"api_base": "https://api.example.com/v1",
|
||||
"api_keys": ["sk-main"]
|
||||
},
|
||||
{
|
||||
"model_name": "deepseek-backup",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"api_keys": ["sk-backup-1"]
|
||||
},
|
||||
{
|
||||
"model_name": "gemini-backup",
|
||||
"model": "gemini/gemini-2.5-flash",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-2.5-flash",
|
||||
"api_keys": ["sk-backup-2"]
|
||||
}
|
||||
],
|
||||
@@ -367,7 +404,8 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7",
|
||||
"api_keys": ["your-key"]
|
||||
}
|
||||
],
|
||||
@@ -436,7 +474,7 @@ picoclaw agent -m "你好"
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "anthropic/claude-opus-4-5"
|
||||
"model_name": "claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
|
||||
@@ -69,12 +69,14 @@ This guide explains how to configure both for real deployments.
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-main",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-main"]
|
||||
},
|
||||
{
|
||||
"model_name": "flash-light",
|
||||
"model": "gemini/gemini-2.0-flash-exp",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"api_keys": ["sk-light"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -69,12 +69,14 @@ PicoClaw 里用户能直接感知到的“路由”主要有两部分:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-main",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-main"]
|
||||
},
|
||||
{
|
||||
"model_name": "flash-light",
|
||||
"model": "gemini/gemini-2.0-flash-exp",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"api_keys": ["sk-light"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@ 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.
|
||||
- **Explicit provider resolution**: Prefer `provider` + native `model`, with legacy `provider/model` compatibility when needed
|
||||
- **Cleaner configuration**: Model-centric instead of vendor-centric
|
||||
|
||||
## Timeline
|
||||
@@ -54,18 +54,21 @@ The new `model_list` configuration offers several advantages:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-your-openai-key"],
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_keys": ["sk-ant-your-key"]
|
||||
},
|
||||
{
|
||||
"model_name": "deepseek",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"api_keys": ["sk-your-deepseek-key"]
|
||||
}
|
||||
],
|
||||
@@ -79,40 +82,46 @@ The new `model_list` configuration offers several advantages:
|
||||
|
||||
> **Note**: The `enabled` field can be omitted — during V1→V2 migration it is auto-inferred (models with API keys or the `local-model` name are enabled by default). For new configs, you can explicitly set `"enabled": false` to disable a model entry without removing it.
|
||||
|
||||
## Protocol Prefixes
|
||||
## Provider / Model Resolution
|
||||
|
||||
The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||
Preferred format:
|
||||
|
||||
| Prefix | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
|
||||
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
||||
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
||||
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
|
||||
| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |
|
||||
| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |
|
||||
| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |
|
||||
| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` |
|
||||
| `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` |
|
||||
| `zhipu/` | Zhipu AI | `zhipu/glm-4` |
|
||||
| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` |
|
||||
| `ollama/` | Ollama (local) | `ollama/llama3` |
|
||||
| `vllm/` | vLLM (local) | `vllm/my-model` |
|
||||
| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` |
|
||||
| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` |
|
||||
| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` |
|
||||
```json
|
||||
{
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4"
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: If no prefix is specified, `openai/` is used as the default.
|
||||
Legacy compatibility format:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "openai/gpt-5.4"
|
||||
}
|
||||
```
|
||||
|
||||
Resolution rules:
|
||||
|
||||
1. If `provider` is set, PicoClaw sends `model` unchanged.
|
||||
2. If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID.
|
||||
|
||||
Examples:
|
||||
|
||||
| Config | Resolved Provider | Model Sent Upstream |
|
||||
|--------|-------------------|---------------------|
|
||||
| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` |
|
||||
| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` |
|
||||
| `"provider": "openrouter", "model": "google/gemini-2.0-flash-exp:free"` | `openrouter` | `google/gemini-2.0-flash-exp:free` |
|
||||
| `"model": "openrouter/google/gemini-2.0-flash-exp:free"` | `openrouter` | `google/gemini-2.0-flash-exp:free` |
|
||||
|
||||
## ModelConfig Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `model_name` | Yes | User-facing alias for the model |
|
||||
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
|
||||
| `provider` | No | Preferred provider identifier. When set, `model` is sent unchanged |
|
||||
| `model` | Yes | Native model ID when `provider` is set, or legacy `provider/model` when `provider` is omitted |
|
||||
| `api_base` | No | API endpoint URL |
|
||||
| `api_keys` | No | API authentication keys (array; supports multiple keys for load balancing) |
|
||||
| `enabled` | No | Whether this model entry is active. Defaults to `true` during migration for models with API keys or named `local-model`. Set to `false` to disable. |
|
||||
@@ -136,7 +145,8 @@ There are two ways to configure load balancing:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-key1", "sk-key2", "sk-key3"],
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -162,19 +172,22 @@ model_list:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-key1"],
|
||||
"api_base": "https://api1.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-key2"],
|
||||
"api_base": "https://api2.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"api_keys": ["sk-key3"],
|
||||
"api_base": "https://api3.example.com/v1"
|
||||
}
|
||||
@@ -193,7 +206,8 @@ With `model_list`, adding a new provider requires zero code changes:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "my-custom-llm",
|
||||
"model": "openai/my-model-v1",
|
||||
"provider": "openai",
|
||||
"model": "my-model-v1",
|
||||
"api_keys": ["your-api-key"],
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
@@ -201,7 +215,7 @@ With `model_list`, adding a new provider requires zero code changes:
|
||||
}
|
||||
```
|
||||
|
||||
Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL.
|
||||
Just set `provider` to `openai` (or another supported provider), and provide your provider's API base URL.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
@@ -216,7 +230,7 @@ During the migration period, your existing V0/V1 config will be auto-migrated to
|
||||
|
||||
- [ ] Identify all providers you're currently using
|
||||
- [ ] Create `model_list` entries for each provider
|
||||
- [ ] Use appropriate protocol prefixes
|
||||
- [ ] Prefer explicit `provider` values and native model IDs
|
||||
- [ ] Update `agents.defaults.model_name` to reference the new `model_name`
|
||||
- [ ] Test that all models work correctly
|
||||
- [ ] Remove or comment out the old `providers` section
|
||||
@@ -234,10 +248,10 @@ model "xxx" not found in model_list or providers
|
||||
### Unknown protocol error
|
||||
|
||||
```
|
||||
unknown protocol "xxx" in model "xxx/model-name"
|
||||
unknown provider "xxx" in model "xxx/model-name"
|
||||
```
|
||||
|
||||
**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above.
|
||||
**Solution**: Use a supported `provider` value, or use the legacy `provider/model` compatibility form correctly. See [Provider / Model Resolution](#provider--model-resolution).
|
||||
|
||||
### Missing API key error
|
||||
|
||||
|
||||
@@ -7,16 +7,22 @@
|
||||
- `Error creating provider: model "openrouter/free" not found in model_list`
|
||||
- OpenRouter returns 400: `"free is not a valid model ID"`
|
||||
|
||||
**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand.
|
||||
**Cause:** PicoClaw now resolves provider/model in two steps:
|
||||
|
||||
- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it.
|
||||
- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing).
|
||||
- If `provider` is set, the `model` field is sent to that provider unchanged.
|
||||
- If `provider` is omitted, PicoClaw infers the provider from the first `/` segment and sends everything after that first `/` as the runtime model ID.
|
||||
|
||||
For OpenRouter free-tier routing, the preferred config is explicit `provider`.
|
||||
|
||||
- **Wrong:** `"model": "free"` → no OpenRouter provider is selected, so `free` is not a valid OpenRouter model route.
|
||||
- **Right:** `"provider": "openrouter", "model": "free"` → OpenRouter receives `free`.
|
||||
- **Also supported:** `"model": "openrouter/free"` → provider resolves to `openrouter`, runtime model ID resolves to `free`.
|
||||
|
||||
**Fix:** In `~/.picoclaw/config.json` (or your config path):
|
||||
|
||||
1. **agents.defaults.model_name** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
|
||||
2. That entry’s **model** must be a valid OpenRouter model ID, for example:
|
||||
- `"openrouter/free"` – auto free-tier
|
||||
2. That entry should preferably set **provider** to `openrouter`, and **model** should be a valid OpenRouter model ID, for example:
|
||||
- `"free"` – auto free-tier
|
||||
- `"google/gemini-2.0-flash-exp:free"`
|
||||
- `"meta-llama/llama-3.1-8b-instruct:free"`
|
||||
|
||||
@@ -32,8 +38,9 @@ Example snippet:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "openrouter-free",
|
||||
"model": "openrouter/free",
|
||||
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
|
||||
"provider": "openrouter",
|
||||
"model": "free",
|
||||
"api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"],
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -9,16 +9,22 @@
|
||||
- `Error creating provider: model "openrouter/free" not found in model_list`
|
||||
- OpenRouter 返回 400:`"free is not a valid model ID"`
|
||||
|
||||
**原因:** `model_list` 条目中的 `model` 字段是发送给 API 的内容。对于 OpenRouter,你必须使用**完整的**模型 ID,而不是简写。
|
||||
**原因:** PicoClaw 现在按两步解析 provider 和 model:
|
||||
|
||||
- **错误:** `"model": "free"` → OpenRouter 收到 `free` 并拒绝。
|
||||
- **正确:** `"model": "openrouter/free"` → OpenRouter 收到 `openrouter/free`(自动免费层路由)。
|
||||
- 如果设置了 `provider`,则会把 `model` 原样发送给该 provider。
|
||||
- 如果未设置 `provider`,则会把 `model` 第一个 `/` 之前的字段当作 provider,并把第一个 `/` 之后的全部内容当作最终发送的模型 ID。
|
||||
|
||||
对于 OpenRouter 免费层路由,推荐显式设置 `provider`。
|
||||
|
||||
- **错误:** `"model": "free"` → 不会选中 OpenRouter,`free` 也不是可直接路由的 OpenRouter 模型配置。
|
||||
- **正确:** `"provider": "openrouter", "model": "free"` → OpenRouter 收到 `free`。
|
||||
- **也兼容:** `"model": "openrouter/free"` → provider 解析为 `openrouter`,最终模型 ID 解析为 `free`。
|
||||
|
||||
**修复方法:** 在 `~/.picoclaw/config.json`(或你的配置路径)中:
|
||||
|
||||
1. **agents.defaults.model_name** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。
|
||||
2. 该条目的 **model** 必须是有效的 OpenRouter 模型 ID,例如:
|
||||
- `"openrouter/free"` – 自动免费层
|
||||
2. 该条目推荐显式设置 **provider** 为 `openrouter`,并在 **model** 中填写有效的 OpenRouter 模型 ID,例如:
|
||||
- `"free"` – 自动免费层
|
||||
- `"google/gemini-2.0-flash-exp:free"`
|
||||
- `"meta-llama/llama-3.1-8b-instruct:free"`
|
||||
|
||||
@@ -34,8 +40,9 @@
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "openrouter-free",
|
||||
"model": "openrouter/free",
|
||||
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
|
||||
"provider": "openrouter",
|
||||
"model": "free",
|
||||
"api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"],
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -39,20 +39,23 @@ Set `rpm` on any model in `model_list`:
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: gpt-4o-free
|
||||
model: openai/gpt-4o
|
||||
provider: openai
|
||||
model: gpt-4o
|
||||
api_base: https://api.openai.com/v1
|
||||
rpm: 3 # max 3 requests per minute
|
||||
api_keys:
|
||||
- sk-...
|
||||
|
||||
- model_name: claude-haiku
|
||||
model: anthropic/claude-haiku-4-5
|
||||
provider: anthropic
|
||||
model: claude-haiku-4-5
|
||||
rpm: 60 # 60 rpm (Anthropic free tier)
|
||||
api_keys:
|
||||
- sk-ant-...
|
||||
|
||||
- model_name: local-llm
|
||||
model: openai/llama3
|
||||
provider: ollama
|
||||
model: llama3
|
||||
api_base: http://localhost:11434/v1
|
||||
# no rpm → unrestricted
|
||||
```
|
||||
@@ -68,7 +71,8 @@ When a model has fallbacks configured, each candidate is rate-limited **independ
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: gpt4-with-fallback
|
||||
model: openai/gpt-4o
|
||||
provider: openai
|
||||
model: gpt-4o
|
||||
rpm: 5
|
||||
fallbacks:
|
||||
- gpt-4o-mini # must also be in model_list; its own rpm applies
|
||||
|
||||
@@ -128,7 +128,7 @@ func useTestSideQuestionProvider(al *AgentLoop, provider providers.LLMProvider)
|
||||
al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) {
|
||||
model := provider.GetDefaultModel()
|
||||
if mc != nil {
|
||||
if _, modelID := providers.ExtractProtocol(mc.Model); modelID != "" {
|
||||
if _, modelID := providers.ExtractProtocol(mc); modelID != "" {
|
||||
model = modelID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,7 +881,11 @@ func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) {
|
||||
resultCh <- result{resp: resp, err: err}
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
select {
|
||||
case <-tool1ExecCh:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for tool execution to start")
|
||||
}
|
||||
|
||||
if err := al.InterruptGraceful("stop now"); err != nil {
|
||||
t.Fatalf("InterruptGraceful failed: %v", err)
|
||||
|
||||
@@ -270,8 +270,8 @@ func populateCandidateProvidersFromNames(
|
||||
map[string]any{"name": name, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
protocol, modelID := providers.ExtractProtocol(strings.TrimSpace(mc.Model))
|
||||
key := providers.ModelKey(providers.NormalizeProvider(protocol), modelID)
|
||||
protocol, modelID := providers.ExtractProtocol(mc)
|
||||
key := providers.ModelKey(protocol, modelID)
|
||||
if _, exists := out[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||
name string
|
||||
aliasName string
|
||||
modelName string
|
||||
provider string
|
||||
apiBase string
|
||||
wantProvider string
|
||||
wantModel string
|
||||
@@ -124,6 +125,15 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||
wantProvider: "openai",
|
||||
wantModel: "glm-5",
|
||||
},
|
||||
{
|
||||
name: "explicit provider overrides model prefix",
|
||||
aliasName: "nvidia-gpt",
|
||||
modelName: "z-ai/glm-5.1",
|
||||
provider: "nvidia",
|
||||
apiBase: "https://integrate.api.nvidia.com/v1",
|
||||
wantProvider: "nvidia",
|
||||
wantModel: "z-ai/glm-5.1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -145,6 +155,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||
{
|
||||
ModelName: tt.aliasName,
|
||||
Model: tt.modelName,
|
||||
Provider: tt.provider,
|
||||
APIBase: tt.apiBase,
|
||||
},
|
||||
},
|
||||
@@ -218,6 +229,43 @@ func TestNewAgentInstance_PreservesDistinctLimiterIdentityForSharedResolvedModel
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentInstance_PreservesConfigIdentityForExplicitProviderModelRef(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "nvidia/z-ai/glm-5.1",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "nvidia-glm",
|
||||
Provider: "nvidia",
|
||||
Model: "z-ai/glm-5.1",
|
||||
RPM: 7,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||
if len(agent.Candidates) != 1 {
|
||||
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||
}
|
||||
|
||||
candidate := agent.Candidates[0]
|
||||
if candidate.Provider != "nvidia" || candidate.Model != "z-ai/glm-5.1" {
|
||||
t.Fatalf("candidate = %s/%s, want nvidia/z-ai/glm-5.1", candidate.Provider, candidate.Model)
|
||||
}
|
||||
if candidate.IdentityKey != "model_name:nvidia-glm" {
|
||||
t.Fatalf("identity key = %q, want %q", candidate.IdentityKey, "model_name:nvidia-glm")
|
||||
}
|
||||
if candidate.RPM != 7 {
|
||||
t.Fatalf("RPM = %d, want 7", candidate.RPM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
mediaDir := media.TempDir()
|
||||
|
||||
@@ -37,14 +37,14 @@ func candidateFromModelConfig(
|
||||
return providers.FallbackCandidate{}, false
|
||||
}
|
||||
|
||||
ref := providers.ParseModelRef(ensureProtocolModel(mc.Model), defaultProvider)
|
||||
if ref == nil {
|
||||
protocol, modelID := providers.ExtractProtocol(mc)
|
||||
if strings.TrimSpace(modelID) == "" {
|
||||
return providers.FallbackCandidate{}, false
|
||||
}
|
||||
|
||||
return providers.FallbackCandidate{
|
||||
Provider: ref.Provider,
|
||||
Model: ref.Model,
|
||||
Provider: protocol,
|
||||
Model: modelID,
|
||||
RPM: mc.RPM,
|
||||
IdentityKey: modelConfigIdentityKey(mc),
|
||||
}, true
|
||||
@@ -60,6 +60,12 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
|
||||
return mc
|
||||
}
|
||||
|
||||
rawRef := providers.ParseModelRef(raw, "")
|
||||
rawKey := ""
|
||||
if rawRef != nil && strings.TrimSpace(rawRef.Provider) != "" && strings.TrimSpace(rawRef.Model) != "" {
|
||||
rawKey = providers.ModelKey(rawRef.Provider, rawRef.Model)
|
||||
}
|
||||
|
||||
for i := range cfg.ModelList {
|
||||
mc := cfg.ModelList[i]
|
||||
if mc == nil {
|
||||
@@ -72,10 +78,13 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
|
||||
if fullModel == raw {
|
||||
return mc
|
||||
}
|
||||
_, modelID := providers.ExtractProtocol(fullModel)
|
||||
protocol, modelID := providers.ExtractProtocol(mc)
|
||||
if modelID == raw {
|
||||
return mc
|
||||
}
|
||||
if rawKey != "" && providers.ModelKey(protocol, modelID) == rawKey {
|
||||
return mc
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+13
-13
@@ -19,16 +19,16 @@ type TranscriptionResponse struct {
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
func supportsAudioTranscription(model string) bool {
|
||||
protocol, _ := providers.ExtractProtocol(model)
|
||||
func supportsAudioTranscription(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
|
||||
switch protocol {
|
||||
case "openai", "azure", "azure-openai",
|
||||
"litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
|
||||
"coding-plan", "alibaba-coding", "qwen-coding":
|
||||
"coding-plan", "alibaba-coding", "qwen-coding", "zai":
|
||||
// These protocols all go through the OpenAI-compatible or Azure provider path in
|
||||
// providers.CreateProviderFromConfig, so they are the only ones that can supply
|
||||
// the audio media payload shape expected by NewAudioModelTranscriber.
|
||||
@@ -41,15 +41,15 @@ func supportsAudioTranscription(model string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func supportsWhisperTranscription(model string) bool {
|
||||
protocol, _ := providers.ExtractProtocol(model)
|
||||
func supportsWhisperTranscription(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
|
||||
switch protocol {
|
||||
case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
|
||||
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
|
||||
"coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -61,11 +61,11 @@ func whisperModelID(modelCfg *config.ModelConfig) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !supportsWhisperTranscription(modelCfg.Model) {
|
||||
if !supportsWhisperTranscription(modelCfg) {
|
||||
return ""
|
||||
}
|
||||
|
||||
_, modelID := providers.ExtractProtocol(strings.TrimSpace(modelCfg.Model))
|
||||
_, modelID := providers.ExtractProtocol(modelCfg)
|
||||
if strings.Contains(strings.ToLower(modelID), "whisper") {
|
||||
return modelID
|
||||
}
|
||||
@@ -77,14 +77,14 @@ func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
|
||||
return nil
|
||||
}
|
||||
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg.Model)
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
|
||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
||||
}
|
||||
if modelID := whisperModelID(modelCfg); modelID != "" {
|
||||
return NewWhisperTranscriber(modelCfg)
|
||||
}
|
||||
if supportsAudioTranscription(modelCfg.Model) {
|
||||
if supportsAudioTranscription(modelCfg) {
|
||||
return NewAudioModelTranscriber(modelCfg)
|
||||
}
|
||||
return nil
|
||||
@@ -95,7 +95,7 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe
|
||||
return nil
|
||||
}
|
||||
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg.Model)
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
|
||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func NewWhisperTranscriber(modelCfg *config.ModelConfig) *WhisperTranscriber {
|
||||
return nil
|
||||
}
|
||||
|
||||
protocol, modelID := providers.ExtractProtocol(modelCfg.Model)
|
||||
protocol, modelID := providers.ExtractProtocol(modelCfg)
|
||||
if modelID == "" {
|
||||
modelID = strings.TrimSpace(modelCfg.Model)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
|
||||
return nil
|
||||
}
|
||||
|
||||
protocol, modelID := providers.ExtractProtocol(mc.Model)
|
||||
protocol, modelID := providers.ExtractProtocol(mc)
|
||||
if modelID == "" {
|
||||
modelID = strings.TrimSpace(mc.Model)
|
||||
}
|
||||
|
||||
@@ -523,15 +523,16 @@ type VoiceConfig struct {
|
||||
|
||||
// ModelConfig represents a model-centric provider configuration.
|
||||
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
|
||||
// The model field uses protocol prefix format: [protocol/]model-identifier
|
||||
// Supported protocols include openai, anthropic, antigravity, claude-cli,
|
||||
// The Model field may be either a plain model identifier or a provider-prefixed
|
||||
// identifier such as "openai/gpt-5.4" or "nvidia/z-ai/glm-5.1".
|
||||
// Supported providers include openai, anthropic, antigravity, claude-cli,
|
||||
// codex-cli, github-copilot, and named OpenAI-compatible protocols such as
|
||||
// groq, deepseek, modelscope, and novita.
|
||||
// Default protocol is "openai" if no prefix is specified.
|
||||
type ModelConfig struct {
|
||||
// Required fields
|
||||
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-sonnet-4.6")
|
||||
Provider string `json:"provider"` // Provider name for routing and selection. When empty, provider resolution infers it from Model.
|
||||
Model string `json:"model"` // Model identifier, optionally provider-prefixed.
|
||||
|
||||
// HTTP-based providers
|
||||
APIBase string `json:"api_base,omitempty"` // API endpoint URL
|
||||
@@ -1411,6 +1412,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
|
||||
// Create a copy for the additional key
|
||||
additionalEntry := &ModelConfig{
|
||||
ModelName: expandedName,
|
||||
Provider: m.Provider,
|
||||
Model: m.Model,
|
||||
APIBase: m.APIBase,
|
||||
APIKeys: SimpleSecureStrings(keys[i]),
|
||||
@@ -1434,6 +1436,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
|
||||
// Create the primary entry with first key and fallbacks
|
||||
primaryEntry := &ModelConfig{
|
||||
ModelName: originalName,
|
||||
Provider: m.Provider,
|
||||
Model: m.Model,
|
||||
APIBase: m.APIBase,
|
||||
Proxy: m.Proxy,
|
||||
|
||||
@@ -1944,7 +1944,7 @@ func TestDefaultConfig_MinimaxExtraBody(t *testing.T) {
|
||||
|
||||
var minimaxCfg *ModelConfig
|
||||
for i := range cfg.ModelList {
|
||||
if cfg.ModelList[i].Model == "minimax/MiniMax-M2.5" {
|
||||
if cfg.ModelList[i].Provider == "minimax" && cfg.ModelList[i].Model == "MiniMax-M2.5" {
|
||||
minimaxCfg = cfg.ModelList[i]
|
||||
break
|
||||
}
|
||||
|
||||
+58
-29
@@ -61,129 +61,148 @@ func DefaultConfig() *Config {
|
||||
// Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys
|
||||
{
|
||||
ModelName: "glm-4.7",
|
||||
Model: "zhipu/glm-4.7",
|
||||
Provider: "zhipu",
|
||||
Model: "glm-4.7",
|
||||
APIBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||
},
|
||||
|
||||
// OpenAI - https://platform.openai.com/api-keys
|
||||
{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
APIBase: "https://api.openai.com/v1",
|
||||
},
|
||||
|
||||
// Anthropic Claude - https://console.anthropic.com/settings/keys
|
||||
{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
Provider: "anthropic",
|
||||
Model: "claude-sonnet-4.6",
|
||||
APIBase: "https://api.anthropic.com/v1",
|
||||
},
|
||||
|
||||
// DeepSeek - https://platform.deepseek.com/
|
||||
{
|
||||
ModelName: "deepseek-chat",
|
||||
Model: "deepseek/deepseek-chat",
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-chat",
|
||||
APIBase: "https://api.deepseek.com/v1",
|
||||
},
|
||||
|
||||
// Venice AI - https://venice.ai
|
||||
{
|
||||
ModelName: "venice-uncensored",
|
||||
Model: "venice/venice-uncensored",
|
||||
Provider: "venice",
|
||||
Model: "venice-uncensored",
|
||||
APIBase: "https://api.venice.ai/api/v1",
|
||||
},
|
||||
|
||||
// Google Gemini - https://ai.google.dev/
|
||||
{
|
||||
ModelName: "gemini-2.0-flash",
|
||||
Model: "gemini/gemini-2.0-flash-exp",
|
||||
Provider: "gemini",
|
||||
Model: "gemini-2.0-flash-exp",
|
||||
APIBase: "https://generativelanguage.googleapis.com/v1beta",
|
||||
},
|
||||
|
||||
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
|
||||
{
|
||||
ModelName: "qwen-plus",
|
||||
Model: "qwen/qwen-plus",
|
||||
Provider: "qwen",
|
||||
Model: "qwen-plus",
|
||||
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
},
|
||||
|
||||
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
|
||||
{
|
||||
ModelName: "moonshot-v1-8k",
|
||||
Model: "moonshot/moonshot-v1-8k",
|
||||
Provider: "moonshot",
|
||||
Model: "moonshot-v1-8k",
|
||||
APIBase: "https://api.moonshot.cn/v1",
|
||||
},
|
||||
|
||||
// Groq - https://console.groq.com/keys
|
||||
{
|
||||
ModelName: "llama-3.3-70b",
|
||||
Model: "groq/llama-3.3-70b-versatile",
|
||||
Provider: "groq",
|
||||
Model: "llama-3.3-70b-versatile",
|
||||
APIBase: "https://api.groq.com/openai/v1",
|
||||
},
|
||||
|
||||
// OpenRouter (100+ models) - https://openrouter.ai/keys
|
||||
{
|
||||
ModelName: "openrouter-auto",
|
||||
Model: "openrouter/auto",
|
||||
Provider: "openrouter",
|
||||
Model: "auto",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
{
|
||||
ModelName: "openrouter-gpt-5.4",
|
||||
Model: "openrouter/openai/gpt-5.4",
|
||||
Provider: "openrouter",
|
||||
Model: "openai/gpt-5.4",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
|
||||
// NVIDIA - https://build.nvidia.com/
|
||||
{
|
||||
ModelName: "nemotron-4-340b",
|
||||
Model: "nvidia/nemotron-4-340b-instruct",
|
||||
Provider: "nvidia",
|
||||
Model: "nemotron-4-340b-instruct",
|
||||
APIBase: "https://integrate.api.nvidia.com/v1",
|
||||
},
|
||||
|
||||
// Cerebras - https://inference.cerebras.ai/
|
||||
{
|
||||
ModelName: "cerebras-llama-3.3-70b",
|
||||
Model: "cerebras/llama-3.3-70b",
|
||||
Provider: "cerebras",
|
||||
Model: "llama-3.3-70b",
|
||||
APIBase: "https://api.cerebras.ai/v1",
|
||||
},
|
||||
|
||||
// Vivgrid - https://vivgrid.com
|
||||
{
|
||||
ModelName: "vivgrid-auto",
|
||||
Model: "vivgrid/auto",
|
||||
Provider: "vivgrid",
|
||||
Model: "auto",
|
||||
APIBase: "https://api.vivgrid.com/v1",
|
||||
},
|
||||
|
||||
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
||||
{
|
||||
ModelName: "ark-code-latest",
|
||||
Model: "volcengine/ark-code-latest",
|
||||
Provider: "volcengine",
|
||||
Model: "ark-code-latest",
|
||||
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
{
|
||||
ModelName: "doubao-pro",
|
||||
Model: "volcengine/doubao-pro-32k",
|
||||
Provider: "volcengine",
|
||||
Model: "doubao-pro-32k",
|
||||
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
|
||||
// ShengsuanYun (神算云)
|
||||
{
|
||||
ModelName: "deepseek-v3",
|
||||
Model: "shengsuanyun/deepseek-v3",
|
||||
Provider: "shengsuanyun",
|
||||
Model: "deepseek-v3",
|
||||
APIBase: "https://api.shengsuanyun.com/v1",
|
||||
},
|
||||
|
||||
// Antigravity (Google Cloud Code Assist) - OAuth only
|
||||
{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
Provider: "antigravity",
|
||||
Model: "gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
|
||||
// GitHub Copilot - https://github.com/settings/tokens
|
||||
{
|
||||
ModelName: "copilot-gpt-5.4",
|
||||
Model: "github-copilot/gpt-5.4",
|
||||
Provider: "github-copilot",
|
||||
Model: "gpt-5.4",
|
||||
APIBase: "http://localhost:4321",
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
@@ -191,33 +210,38 @@ func DefaultConfig() *Config {
|
||||
// Ollama (local) - https://ollama.com
|
||||
{
|
||||
ModelName: "llama3",
|
||||
Model: "ollama/llama3",
|
||||
Provider: "ollama",
|
||||
Model: "llama3",
|
||||
APIBase: "http://localhost:11434/v1",
|
||||
},
|
||||
|
||||
// Mistral AI - https://console.mistral.ai/api-keys
|
||||
{
|
||||
ModelName: "mistral-small",
|
||||
Model: "mistral/mistral-small-latest",
|
||||
Provider: "mistral",
|
||||
Model: "mistral-small-latest",
|
||||
APIBase: "https://api.mistral.ai/v1",
|
||||
},
|
||||
|
||||
// Avian - https://avian.io
|
||||
{
|
||||
ModelName: "deepseek-v3.2",
|
||||
Model: "avian/deepseek/deepseek-v3.2",
|
||||
Provider: "avian",
|
||||
Model: "deepseek/deepseek-v3.2",
|
||||
APIBase: "https://api.avian.io/v1",
|
||||
},
|
||||
{
|
||||
ModelName: "kimi-k2.5",
|
||||
Model: "avian/moonshotai/kimi-k2.5",
|
||||
Provider: "avian",
|
||||
Model: "moonshotai/kimi-k2.5",
|
||||
APIBase: "https://api.avian.io/v1",
|
||||
},
|
||||
|
||||
// Minimax - https://api.minimaxi.com/
|
||||
{
|
||||
ModelName: "MiniMax-M2.5",
|
||||
Model: "minimax/MiniMax-M2.5",
|
||||
Provider: "minimax",
|
||||
Model: "MiniMax-M2.5",
|
||||
APIBase: "https://api.minimaxi.com/v1",
|
||||
ExtraBody: map[string]any{"reasoning_split": true},
|
||||
},
|
||||
@@ -225,28 +249,32 @@ func DefaultConfig() *Config {
|
||||
// LongCat - https://longcat.chat/platform
|
||||
{
|
||||
ModelName: "LongCat-Flash-Thinking",
|
||||
Model: "longcat/LongCat-Flash-Thinking",
|
||||
Provider: "longcat",
|
||||
Model: "LongCat-Flash-Thinking",
|
||||
APIBase: "https://api.longcat.chat/openai",
|
||||
},
|
||||
|
||||
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
|
||||
{
|
||||
ModelName: "modelscope-qwen",
|
||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
Provider: "modelscope",
|
||||
Model: "Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
APIBase: "https://api-inference.modelscope.cn/v1",
|
||||
},
|
||||
|
||||
// VLLM (local) - http://localhost:8000
|
||||
{
|
||||
ModelName: "local-model",
|
||||
Model: "vllm/custom-model",
|
||||
Provider: "vllm",
|
||||
Model: "custom-model",
|
||||
APIBase: "http://localhost:8000/v1",
|
||||
},
|
||||
|
||||
// LM Studio (local) - http://localhost:1234
|
||||
{
|
||||
ModelName: "lmstudio-local",
|
||||
Model: "lmstudio/openai/gpt-oss-20b",
|
||||
Provider: "lmstudio",
|
||||
Model: "openai/gpt-oss-20b",
|
||||
APIBase: "http://localhost:1234/v1",
|
||||
},
|
||||
|
||||
@@ -254,7 +282,8 @@ func DefaultConfig() *Config {
|
||||
// model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name
|
||||
{
|
||||
ModelName: "azure-gpt5",
|
||||
Model: "azure/my-gpt5-deployment",
|
||||
Provider: "azure",
|
||||
Model: "my-gpt5-deployment",
|
||||
APIBase: "https://your-resource.openai.azure.com",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
|
||||
func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
|
||||
modelCfg := &ModelConfig{
|
||||
ModelName: "gpt-4",
|
||||
Provider: "openrouter",
|
||||
Model: "openai/gpt-4o",
|
||||
APIBase: "https://api.example.com",
|
||||
Proxy: "http://proxy:8080",
|
||||
@@ -206,6 +207,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
|
||||
if primary.APIBase != "https://api.example.com" {
|
||||
t.Errorf("expected api_base preserved, got %q", primary.APIBase)
|
||||
}
|
||||
if primary.Provider != "openrouter" {
|
||||
t.Errorf("expected provider preserved, got %q", primary.Provider)
|
||||
}
|
||||
if primary.Proxy != "http://proxy:8080" {
|
||||
t.Errorf("expected proxy preserved, got %q", primary.Proxy)
|
||||
}
|
||||
@@ -224,6 +228,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
|
||||
|
||||
// Check additional entry also preserves fields
|
||||
additional := result[0]
|
||||
if additional.Provider != "openrouter" {
|
||||
t.Errorf("expected additional provider preserved, got %q", additional.Provider)
|
||||
}
|
||||
if additional.APIBase != "https://api.example.com" {
|
||||
t.Errorf("expected additional api_base preserved, got %q", additional.APIBase)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ var protocolMetaByName = map[string]protocolMeta{
|
||||
"vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"},
|
||||
"volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"},
|
||||
"qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"},
|
||||
"qwen-portal": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"},
|
||||
"qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
|
||||
"qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
|
||||
"dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
|
||||
@@ -51,6 +52,7 @@ var protocolMetaByName = map[string]protocolMeta{
|
||||
"qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
|
||||
"coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
|
||||
"alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
|
||||
"zai": {defaultAPIBase: "https://api.z.ai/api/coding/paas/v4"},
|
||||
"vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true},
|
||||
"mistral": {defaultAPIBase: "https://api.mistral.ai/v1"},
|
||||
"avian": {defaultAPIBase: "https://api.avian.io/v1"},
|
||||
@@ -84,19 +86,43 @@ func createCodexAuthProvider() (LLMProvider, error) {
|
||||
return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
|
||||
}
|
||||
|
||||
// ExtractProtocol extracts the protocol prefix and model identifier from a model string.
|
||||
// If no prefix is specified, it defaults to "openai".
|
||||
// ExtractProtocol extracts the effective protocol and model identifier from a
|
||||
// model configuration.
|
||||
//
|
||||
// The explicit Provider field takes precedence. When Provider is empty, the
|
||||
// protocol is inferred from Model. Plain model names default to "openai".
|
||||
// Provider-prefixed models strip the first slash-separated segment from the
|
||||
// returned model ID.
|
||||
//
|
||||
// The returned protocol is normalized to the provider's canonical spelling.
|
||||
// Examples:
|
||||
// - "openai/gpt-4o" -> ("openai", "gpt-4o")
|
||||
// - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6")
|
||||
// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol
|
||||
func ExtractProtocol(model string) (protocol, modelID string) {
|
||||
model = strings.TrimSpace(model)
|
||||
protocol, modelID, found := strings.Cut(model, "/")
|
||||
// - Model "openai/gpt-4o" -> ("openai", "gpt-4o")
|
||||
// - Model "nvidia/z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1")
|
||||
// - Provider "nvidia", Model "z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1")
|
||||
// - Provider "openai", Model "openai/gpt-4o" -> ("openai", "openai/gpt-4o")
|
||||
// - Model "gpt-4o" -> ("openai", "gpt-4o")
|
||||
func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) {
|
||||
if cfg == nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
model := strings.TrimSpace(cfg.Model)
|
||||
if provider := strings.TrimSpace(cfg.Provider); provider != "" {
|
||||
return NormalizeProvider(provider), model
|
||||
}
|
||||
if model == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
protocol, rest, found := strings.Cut(model, "/")
|
||||
if !found {
|
||||
return "openai", model
|
||||
}
|
||||
return protocol, modelID
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
if protocol == "" {
|
||||
return "", strings.TrimSpace(rest)
|
||||
}
|
||||
return NormalizeProvider(protocol), strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
// ResolveAPIBase returns the configured API base, or the protocol default when
|
||||
@@ -108,16 +134,16 @@ func ResolveAPIBase(cfg *config.ModelConfig) string {
|
||||
if apiBase := strings.TrimSpace(cfg.APIBase); apiBase != "" {
|
||||
return strings.TrimRight(apiBase, "/")
|
||||
}
|
||||
protocol, _ := ExtractProtocol(cfg.Model)
|
||||
protocol, _ := ExtractProtocol(cfg)
|
||||
return strings.TrimRight(getDefaultAPIBase(protocol), "/")
|
||||
}
|
||||
|
||||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||
// It uses ExtractProtocol to determine which provider to create.
|
||||
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq),
|
||||
// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims.
|
||||
// See the switch on protocol in this function for the authoritative list.
|
||||
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||
// Returns the provider, the effective model ID from ExtractProtocol, and any error.
|
||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||
if cfg == nil {
|
||||
return nil, "", fmt.Errorf("config is nil")
|
||||
@@ -127,7 +153,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
return nil, "", fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
protocol, modelID := ExtractProtocol(cfg.Model)
|
||||
protocol, modelID := ExtractProtocol(cfg)
|
||||
|
||||
userAgent := cfg.UserAgent
|
||||
if userAgent == "" {
|
||||
@@ -220,9 +246,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
|
||||
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
|
||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
|
||||
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
|
||||
"coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo":
|
||||
// All other OpenAI-compatible HTTP providers
|
||||
if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) {
|
||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||
|
||||
@@ -19,68 +19,103 @@ import (
|
||||
func TestExtractProtocol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
config *config.ModelConfig
|
||||
wantProtocol string
|
||||
wantModelID string
|
||||
}{
|
||||
{
|
||||
name: "openai with prefix",
|
||||
model: "openai/gpt-4o",
|
||||
config: &config.ModelConfig{Model: "openai/gpt-4o"},
|
||||
wantProtocol: "openai",
|
||||
wantModelID: "gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "anthropic with prefix",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
config: &config.ModelConfig{Model: "anthropic/claude-sonnet-4.6"},
|
||||
wantProtocol: "anthropic",
|
||||
wantModelID: "claude-sonnet-4.6",
|
||||
},
|
||||
{
|
||||
name: "no prefix - defaults to openai",
|
||||
model: "gpt-4o",
|
||||
config: &config.ModelConfig{Model: "gpt-4o"},
|
||||
wantProtocol: "openai",
|
||||
wantModelID: "gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "groq with prefix",
|
||||
model: "groq/llama-3.1-70b",
|
||||
config: &config.ModelConfig{Model: "groq/llama-3.1-70b"},
|
||||
wantProtocol: "groq",
|
||||
wantModelID: "llama-3.1-70b",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
model: "",
|
||||
wantProtocol: "openai",
|
||||
config: &config.ModelConfig{Model: ""},
|
||||
wantProtocol: "",
|
||||
wantModelID: "",
|
||||
},
|
||||
{
|
||||
name: "with whitespace",
|
||||
model: " openai/gpt-4 ",
|
||||
config: &config.ModelConfig{Model: " openai/gpt-4 "},
|
||||
wantProtocol: "openai",
|
||||
wantModelID: "gpt-4",
|
||||
},
|
||||
{
|
||||
name: "multiple slashes",
|
||||
model: "nvidia/meta/llama-3.1-8b",
|
||||
config: &config.ModelConfig{Model: "nvidia/meta/llama-3.1-8b"},
|
||||
wantProtocol: "nvidia",
|
||||
wantModelID: "meta/llama-3.1-8b",
|
||||
},
|
||||
{
|
||||
name: "normalizes provider",
|
||||
config: &config.ModelConfig{Model: "z.ai/glm-5.1"},
|
||||
wantProtocol: "zai",
|
||||
wantModelID: "glm-5.1",
|
||||
},
|
||||
{
|
||||
name: "azure with prefix",
|
||||
model: "azure/my-gpt5-deployment",
|
||||
config: &config.ModelConfig{Model: "azure/my-gpt5-deployment"},
|
||||
wantProtocol: "azure",
|
||||
wantModelID: "my-gpt5-deployment",
|
||||
},
|
||||
{
|
||||
name: "explicit provider keeps model",
|
||||
config: &config.ModelConfig{Provider: "nvidia", Model: "z-ai/glm-5.1"},
|
||||
wantProtocol: "nvidia",
|
||||
wantModelID: "z-ai/glm-5.1",
|
||||
},
|
||||
{
|
||||
name: "explicit provider preserves matching prefix",
|
||||
config: &config.ModelConfig{Provider: "openai", Model: "openai/gpt-4o"},
|
||||
wantProtocol: "openai",
|
||||
wantModelID: "openai/gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "explicit provider preserves aliased prefix",
|
||||
config: &config.ModelConfig{Provider: "qwen", Model: "qwen/qwen-plus"},
|
||||
wantProtocol: "qwen-portal",
|
||||
wantModelID: "qwen/qwen-plus",
|
||||
},
|
||||
{
|
||||
name: "empty provider segment",
|
||||
config: &config.ModelConfig{Model: "/gpt-4o"},
|
||||
wantProtocol: "",
|
||||
wantModelID: "gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "nil config",
|
||||
wantProtocol: "",
|
||||
wantModelID: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
protocol, modelID := ExtractProtocol(tt.model)
|
||||
protocol, modelID := ExtractProtocol(tt.config)
|
||||
if protocol != tt.wantProtocol {
|
||||
t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol)
|
||||
t.Errorf("ExtractProtocol() protocol = %q, want %q", protocol, tt.wantProtocol)
|
||||
}
|
||||
if modelID != tt.wantModelID {
|
||||
t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID)
|
||||
t.Errorf("ExtractProtocol() modelID = %q, want %q", modelID, tt.wantModelID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -106,6 +141,50 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_UsesExplicitProvider(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-explicit-provider",
|
||||
Model: "z-ai/glm-5.1",
|
||||
Provider: "nvidia",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "z-ai/glm-5.1" {
|
||||
t.Fatalf("modelID = %q, want z-ai/glm-5.1", modelID)
|
||||
}
|
||||
if got := ResolveAPIBase(cfg); got != "https://integrate.api.nvidia.com/v1" {
|
||||
t.Fatalf("ResolveAPIBase() = %q, want NVIDIA default API base", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_PreservesExplicitProviderPrefixedModel(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-openai",
|
||||
Provider: "openai",
|
||||
Model: "openai/gpt-4o",
|
||||
APIBase: "https://api.example.com/v1",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "openai/gpt-4o" {
|
||||
t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-4o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -701,8 +780,9 @@ func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) {
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "qwen-max" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "qwen-max")
|
||||
wantModelID := "qwen-max"
|
||||
if modelID != wantModelID {
|
||||
t.Errorf("modelID = %q, want %q", modelID, wantModelID)
|
||||
}
|
||||
if _, ok := provider.(*HTTPProvider); !ok {
|
||||
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
||||
@@ -735,8 +815,9 @@ func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) {
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "qwen-max" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "qwen-max")
|
||||
wantModelID := "qwen-max"
|
||||
if modelID != wantModelID {
|
||||
t.Errorf("modelID = %q, want %q", modelID, wantModelID)
|
||||
}
|
||||
if _, ok := provider.(*HTTPProvider); !ok {
|
||||
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
||||
@@ -769,8 +850,9 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "claude-sonnet-4-20250514" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514")
|
||||
wantModelID := "claude-sonnet-4-20250514"
|
||||
if modelID != wantModelID {
|
||||
t.Errorf("modelID = %q, want %q", modelID, wantModelID)
|
||||
}
|
||||
// coding-plan-anthropic uses Anthropic Messages provider
|
||||
// Verify it's the anthropic messages provider by checking interface
|
||||
|
||||
@@ -87,7 +87,7 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||
apiKey := strings.TrimSpace(m.APIKey())
|
||||
|
||||
if authMethod == "oauth" || authMethod == "token" {
|
||||
if provider, ok := oauthProviderForModel(m.Model); ok {
|
||||
if provider, ok := oauthProviderForModel(m); ok {
|
||||
cred, err := oauthGetCredential(provider)
|
||||
if err != nil || cred == nil {
|
||||
return false
|
||||
@@ -123,7 +123,7 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
protocol := modelProtocol(m.Model)
|
||||
protocol := modelProtocol(m)
|
||||
|
||||
switch protocol {
|
||||
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
|
||||
@@ -172,7 +172,7 @@ func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) boo
|
||||
|
||||
func runLocalModelProbe(m *config.ModelConfig) bool {
|
||||
apiBase := modelProbeAPIBase(m)
|
||||
protocol, modelID := splitModel(m.Model)
|
||||
protocol, modelID := splitModel(m)
|
||||
switch protocol {
|
||||
case "ollama":
|
||||
return probeOllamaModelFunc(apiBase, modelID)
|
||||
@@ -191,7 +191,7 @@ func runLocalModelProbe(m *config.ModelConfig) bool {
|
||||
}
|
||||
|
||||
func modelProbeCacheKey(m *config.ModelConfig) string {
|
||||
protocol, modelID := splitModel(m.Model)
|
||||
protocol, modelID := splitModel(m)
|
||||
|
||||
apiBaseRaw := modelProbeAPIBase(m)
|
||||
apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/"))
|
||||
@@ -384,7 +384,7 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
|
||||
return normalizeModelProbeAPIBase(apiBase)
|
||||
}
|
||||
|
||||
protocol := modelProtocol(m.Model)
|
||||
protocol := modelProtocol(m)
|
||||
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
|
||||
return providers.DefaultAPIBaseForProtocol(protocol)
|
||||
}
|
||||
@@ -419,8 +419,8 @@ func normalizeModelProbeAPIBase(raw string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func oauthProviderForModel(model string) (string, bool) {
|
||||
switch modelProtocol(model) {
|
||||
func oauthProviderForModel(m *config.ModelConfig) (string, bool) {
|
||||
switch modelProtocol(m) {
|
||||
case "openai":
|
||||
return oauthProviderOpenAI, true
|
||||
case "anthropic":
|
||||
@@ -432,18 +432,14 @@ func oauthProviderForModel(model string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func modelProtocol(model string) string {
|
||||
protocol, _ := splitModel(model)
|
||||
func modelProtocol(m *config.ModelConfig) string {
|
||||
protocol, _ := splitModel(m)
|
||||
return protocol
|
||||
}
|
||||
|
||||
func splitModel(model string) (protocol, modelID string) {
|
||||
model = strings.ToLower(strings.TrimSpace(model))
|
||||
protocol, _, found := strings.Cut(model, "/")
|
||||
if !found {
|
||||
return "openai", model
|
||||
}
|
||||
return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:])
|
||||
func splitModel(m *config.ModelConfig) (protocol, modelID string) {
|
||||
protocol, modelID = providers.ExtractProtocol(m)
|
||||
return strings.ToLower(strings.TrimSpace(protocol)), strings.ToLower(strings.TrimSpace(modelID))
|
||||
}
|
||||
|
||||
func hasLocalAPIBase(raw string) bool {
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// registerModelRoutes binds model list management endpoints to the ServeMux.
|
||||
@@ -26,6 +28,7 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
|
||||
type modelResponse struct {
|
||||
Index int `json:"index"`
|
||||
ModelName string `json:"model_name"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model"`
|
||||
APIBase string `json:"api_base,omitempty"`
|
||||
APIKey string `json:"api_key"`
|
||||
@@ -73,10 +76,12 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
models := make([]modelResponse, 0, len(cfg.ModelList))
|
||||
for i, m := range cfg.ModelList {
|
||||
provider, modelID := providers.ExtractProtocol(m)
|
||||
models = append(models, modelResponse{
|
||||
Index: i,
|
||||
ModelName: m.ModelName,
|
||||
Model: m.Model,
|
||||
Provider: provider,
|
||||
Model: modelID,
|
||||
APIBase: m.APIBase,
|
||||
APIKey: maskAPIKey(m.APIKey()),
|
||||
Proxy: m.Proxy,
|
||||
@@ -176,6 +181,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var rawFields map[string]json.RawMessage
|
||||
if err = json.Unmarshal(body, &rawFields); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
type custom struct {
|
||||
config.ModelConfig
|
||||
APIKey string `json:"api_key"`
|
||||
@@ -226,6 +237,35 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||
} else if len(mc.CustomHeaders) == 0 {
|
||||
mc.CustomHeaders = nil
|
||||
}
|
||||
// Preserve the existing Provider when the caller omits it. This keeps the
|
||||
// update API backward-compatible for clients that haven't started sending
|
||||
// the new field yet, while still allowing explicit clearing via "".
|
||||
if _, ok := rawFields["provider"]; !ok {
|
||||
mc.Provider = cfg.ModelList[idx].Provider
|
||||
// Older clients still round-trip the legacy model field only. When the
|
||||
// stored config encodes provider/model in Model and has no explicit
|
||||
// Provider field yet, continue preserving that hidden provider prefix.
|
||||
// This keeps provider-omitted updates backward-compatible even when an
|
||||
// older client edits the visible model ID.
|
||||
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
|
||||
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
|
||||
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
|
||||
incomingModel := strings.TrimSpace(mc.Model)
|
||||
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
|
||||
if incomingModel == existingModelID {
|
||||
mc.Model = existingRawModel
|
||||
} else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") {
|
||||
// Older clients never saw the hidden provider prefix for simple
|
||||
// legacy entries such as "openai/gpt-4o". If they now send an
|
||||
// explicit provider/model string, treat it as the caller's full
|
||||
// intent instead of re-applying the old hidden prefix.
|
||||
mc.Model = incomingModel
|
||||
} else if !strings.HasPrefix(incomingModel, existingProtocol+"/") {
|
||||
mc.Model = existingProtocol + "/" + incomingModel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg.ModelList[idx] = &mc.ModelConfig
|
||||
|
||||
|
||||
@@ -392,6 +392,49 @@ func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListModels_RuntimeProbeUsesExplicitProviderField(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
resetOAuthHooks(t)
|
||||
resetModelProbeHooks(t)
|
||||
|
||||
var gotProbe string
|
||||
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
|
||||
gotProbe = apiBase + "|" + modelID + "|" + apiKey
|
||||
return true
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "vllm-local",
|
||||
Provider: "vllm",
|
||||
Model: "custom-model",
|
||||
APIBase: "http://127.0.0.1:8000/v1",
|
||||
}}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
|
||||
t.Fatalf("probe = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
@@ -430,6 +473,76 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAddModel_PersistsProvider(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
|
||||
"model_name":"nvidia-glm",
|
||||
"provider":"nvidia",
|
||||
"model":"z-ai/glm-5.1",
|
||||
"api_key":"nv-key"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
added := cfg.ModelList[len(cfg.ModelList)-1]
|
||||
if added.Provider != "nvidia" {
|
||||
t.Fatalf("provider = %q, want %q", added.Provider, "nvidia")
|
||||
}
|
||||
if added.Model != "z-ai/glm-5.1" {
|
||||
t.Fatalf("model = %q, want %q", added.Model, "z-ai/glm-5.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
|
||||
"model_name":"openai-gpt",
|
||||
"provider":"openai",
|
||||
"model":"openai/gpt-4o-mini",
|
||||
"api_key":"sk-openai"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
added := cfg.ModelList[len(cfg.ModelList)-1]
|
||||
if got := added.Provider; got != "openai" {
|
||||
t.Fatalf("provider = %q, want %q", got, "openai")
|
||||
}
|
||||
if got := added.Model; got != "openai/gpt-4o-mini" {
|
||||
t.Fatalf("model = %q, want %q", got, "openai/gpt-4o-mini")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
@@ -536,6 +649,370 @@ func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateModel_PersistsProvider(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "editable",
|
||||
Model: "gpt-4o",
|
||||
Provider: "openai",
|
||||
}}
|
||||
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
|
||||
"model_name":"editable",
|
||||
"provider":"openrouter",
|
||||
"model":"openai/gpt-4o"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
updated, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if got := updated.ModelList[0].Provider; got != "openrouter" {
|
||||
t.Fatalf("provider = %q, want %q", got, "openrouter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "editable",
|
||||
Model: "gpt-4o",
|
||||
Provider: "openai",
|
||||
}}
|
||||
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
|
||||
"model_name":"editable",
|
||||
"provider":"openai",
|
||||
"model":"openai/gpt-5.4"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
updated, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if got := updated.ModelList[0].Provider; got != "openai" {
|
||||
t.Fatalf("provider = %q, want %q", got, "openai")
|
||||
}
|
||||
if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" {
|
||||
t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "openrouter-auto-explicit",
|
||||
Provider: "openrouter",
|
||||
Model: "openrouter/auto",
|
||||
}}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Models []modelResponse `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Models) != 1 {
|
||||
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||
}
|
||||
if got := resp.Models[0].Provider; got != "openrouter" {
|
||||
t.Fatalf("provider = %q, want %q", got, "openrouter")
|
||||
}
|
||||
if got := resp.Models[0].Model; got != "openrouter/auto" {
|
||||
t.Fatalf("model = %q, want %q", got, "openrouter/auto")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "legacy-openrouter",
|
||||
Model: "openrouter/openai/gpt-5.4",
|
||||
}}
|
||||
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
// Simulate an older client: it reads GET /api/models, ignores the new
|
||||
// provider field, then PUTs the visible model string back unchanged.
|
||||
recList := httptest.NewRecorder()
|
||||
reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||
mux.ServeHTTP(recList, reqList)
|
||||
|
||||
if recList.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String())
|
||||
}
|
||||
|
||||
var listResp struct {
|
||||
Models []modelResponse `json:"models"`
|
||||
}
|
||||
if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(listResp.Models) != 1 {
|
||||
t.Fatalf("len(models) = %d, want 1", len(listResp.Models))
|
||||
}
|
||||
if got := listResp.Models[0].Provider; got != "openrouter" {
|
||||
t.Fatalf("provider = %q, want %q", got, "openrouter")
|
||||
}
|
||||
if got := listResp.Models[0].Model; got != "openai/gpt-5.4" {
|
||||
t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
|
||||
}
|
||||
|
||||
recUpdate := httptest.NewRecorder()
|
||||
reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
|
||||
"model_name":"legacy-openrouter",
|
||||
"model":"openai/gpt-5.4"
|
||||
}`))
|
||||
reqUpdate.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(recUpdate, reqUpdate)
|
||||
|
||||
if recUpdate.Code != http.StatusOK {
|
||||
t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String())
|
||||
}
|
||||
|
||||
updated, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if got := updated.ModelList[0].Provider; got != "" {
|
||||
t.Fatalf("provider = %q, want empty", got)
|
||||
}
|
||||
if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" {
|
||||
t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "legacy-openrouter",
|
||||
Model: "openrouter/openai/gpt-5.4",
|
||||
}}
|
||||
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
|
||||
"model_name":"legacy-openrouter",
|
||||
"model":"openai/gpt-5.5"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
updated, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if got := updated.ModelList[0].Provider; got != "" {
|
||||
t.Fatalf("provider = %q, want empty", got)
|
||||
}
|
||||
if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.5" {
|
||||
t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListModels_ReturnsProviderField(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "nvidia-glm",
|
||||
Provider: "nvidia",
|
||||
Model: "z-ai/glm-5.1",
|
||||
APIKeys: config.SimpleSecureStrings("nv-key"),
|
||||
}}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Models []modelResponse `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Models) != 1 {
|
||||
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||
}
|
||||
if got := resp.Models[0].Provider; got != "nvidia" {
|
||||
t.Fatalf("provider = %q, want %q", got, "nvidia")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "plain-openai",
|
||||
Model: "gpt-4o",
|
||||
},
|
||||
{
|
||||
ModelName: "explicit-google",
|
||||
Provider: "google",
|
||||
Model: "gemini-2.5-pro",
|
||||
},
|
||||
{
|
||||
ModelName: "explicit-qwen-intl",
|
||||
Provider: "qwen-international",
|
||||
Model: "qwen3-coder-plus",
|
||||
},
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Models []modelResponse `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Models) != 3 {
|
||||
t.Fatalf("len(models) = %d, want 3", len(resp.Models))
|
||||
}
|
||||
|
||||
if got := resp.Models[0].Provider; got != "openai" {
|
||||
t.Fatalf("provider[0] = %q, want %q", got, "openai")
|
||||
}
|
||||
if got := resp.Models[0].Model; got != "gpt-4o" {
|
||||
t.Fatalf("model[0] = %q, want %q", got, "gpt-4o")
|
||||
}
|
||||
if got := resp.Models[1].Provider; got != "gemini" {
|
||||
t.Fatalf("provider[1] = %q, want %q", got, "gemini")
|
||||
}
|
||||
if got := resp.Models[1].Model; got != "gemini-2.5-pro" {
|
||||
t.Fatalf("model[1] = %q, want %q", got, "gemini-2.5-pro")
|
||||
}
|
||||
if got := resp.Models[2].Provider; got != "qwen-intl" {
|
||||
t.Fatalf("provider[2] = %q, want %q", got, "qwen-intl")
|
||||
}
|
||||
if got := resp.Models[2].Model; got != "qwen3-coder-plus" {
|
||||
t.Fatalf("model[2] = %q, want %q", got, "qwen3-coder-plus")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent
|
||||
// model as default returns 404. This covers the case where virtual models (which are
|
||||
// filtered by SaveConfig) cannot be set as default.
|
||||
|
||||
+12
-12
@@ -746,7 +746,7 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
|
||||
|
||||
found := false
|
||||
for i := range cfg.ModelList {
|
||||
if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
|
||||
if modelBelongsToProvider(provider, cfg.ModelList[i]) {
|
||||
cfg.ModelList[i].AuthMethod = authMethod
|
||||
found = true
|
||||
}
|
||||
@@ -759,18 +759,15 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
|
||||
return oauthSaveConfig(h.configPath, cfg)
|
||||
}
|
||||
|
||||
func modelBelongsToProvider(provider, model string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(model))
|
||||
func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
switch provider {
|
||||
case oauthProviderOpenAI:
|
||||
return lower == "openai" || strings.HasPrefix(lower, "openai/")
|
||||
return protocol == "openai"
|
||||
case oauthProviderAnthropic:
|
||||
return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/")
|
||||
return protocol == "anthropic"
|
||||
case oauthProviderGoogleAntigravity:
|
||||
return lower == "antigravity" ||
|
||||
lower == "google-antigravity" ||
|
||||
strings.HasPrefix(lower, "antigravity/") ||
|
||||
strings.HasPrefix(lower, "google-antigravity/")
|
||||
return protocol == "antigravity" || protocol == "google-antigravity"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -781,19 +778,22 @@ func defaultModelConfigForProvider(provider, authMethod string) *config.ModelCon
|
||||
case oauthProviderOpenAI:
|
||||
return &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
case oauthProviderAnthropic:
|
||||
return &config.ModelConfig{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
Provider: "anthropic",
|
||||
Model: "claude-sonnet-4.6",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
case oauthProviderGoogleAntigravity:
|
||||
return &config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
Provider: "antigravity",
|
||||
Model: "gemini-3-flash",
|
||||
AuthMethod: authMethod,
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -214,6 +214,54 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLogoutClearsAuthMethodForExplicitProviderField(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
resetOAuthHooks(t)
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig error: %v", err)
|
||||
}
|
||||
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig error: %v", err)
|
||||
}
|
||||
if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
|
||||
AccessToken: "token-before-logout",
|
||||
Provider: oauthProviderOpenAI,
|
||||
AuthMethod: "oauth",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetCredential error: %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
updated, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig error: %v", err)
|
||||
}
|
||||
if got := updated.ModelList[len(updated.ModelList)-1].AuthMethod; got != "" {
|
||||
t.Fatalf("auth_method = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func setupOAuthTestEnv(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { refreshGatewayState } from "@/store/gateway"
|
||||
export interface ModelInfo {
|
||||
index: number
|
||||
model_name: string
|
||||
provider?: string
|
||||
model: string
|
||||
api_base?: string
|
||||
api_key: string
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
interface AddForm {
|
||||
modelName: string
|
||||
provider: string
|
||||
model: string
|
||||
apiBase: string
|
||||
apiKey: string
|
||||
@@ -41,6 +42,7 @@ interface AddForm {
|
||||
|
||||
const EMPTY_ADD_FORM: AddForm = {
|
||||
modelName: "",
|
||||
provider: "",
|
||||
model: "",
|
||||
apiBase: "",
|
||||
apiKey: "",
|
||||
@@ -119,9 +121,11 @@ export function AddModelSheet({
|
||||
setServerError("")
|
||||
try {
|
||||
const modelName = form.modelName.trim()
|
||||
const provider = form.provider.trim()
|
||||
const modelId = form.model.trim()
|
||||
await addModel({
|
||||
model_name: modelName,
|
||||
provider: provider || undefined,
|
||||
model: modelId,
|
||||
api_base: form.apiBase.trim() || undefined,
|
||||
api_key: form.apiKey.trim() || undefined,
|
||||
@@ -186,6 +190,17 @@ export function AddModelSheet({
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.provider")}
|
||||
hint={t("models.field.providerHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.provider}
|
||||
onChange={setField("provider")}
|
||||
placeholder={t("models.field.providerPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.add.modelId")}
|
||||
hint={t("models.add.modelIdHint")}
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
interface EditForm {
|
||||
provider: string
|
||||
modelId: string
|
||||
apiKey: string
|
||||
apiBase: string
|
||||
proxy: string
|
||||
@@ -52,6 +54,8 @@ export function EditModelSheet({
|
||||
}: EditModelSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState<EditForm>({
|
||||
provider: "",
|
||||
modelId: "",
|
||||
apiKey: "",
|
||||
apiBase: "",
|
||||
proxy: "",
|
||||
@@ -72,6 +76,8 @@ export function EditModelSheet({
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
setForm({
|
||||
provider: model.provider ?? "",
|
||||
modelId: model.model,
|
||||
apiKey: "",
|
||||
apiBase: model.api_base ?? "",
|
||||
proxy: model.proxy ?? "",
|
||||
@@ -103,12 +109,17 @@ export function EditModelSheet({
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!model) return
|
||||
if (!form.modelId.trim()) {
|
||||
setError(t("models.add.errorRequired"))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError("")
|
||||
try {
|
||||
await updateModel(model.index, {
|
||||
model_name: model.model_name,
|
||||
model: model.model,
|
||||
provider: form.provider.trim(),
|
||||
model: form.modelId.trim(),
|
||||
api_base: form.apiBase || undefined,
|
||||
api_key: form.apiKey || undefined,
|
||||
proxy: form.proxy || undefined,
|
||||
@@ -166,6 +177,29 @@ export function EditModelSheet({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<Field
|
||||
label={t("models.field.provider")}
|
||||
hint={t("models.field.providerHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.provider}
|
||||
onChange={setField("provider")}
|
||||
placeholder={t("models.field.providerPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.add.modelId")}
|
||||
hint={t("models.add.modelIdHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.modelId}
|
||||
onChange={setField("modelId")}
|
||||
placeholder={t("models.add.modelIdPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isOAuth && (
|
||||
<Field
|
||||
label={t("models.field.apiKey")}
|
||||
|
||||
@@ -20,19 +20,28 @@ const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
zhipu: 4,
|
||||
deepseek: 5,
|
||||
openrouter: 6,
|
||||
qwen: 7,
|
||||
moonshot: 8,
|
||||
groq: 9,
|
||||
"github-copilot": 10,
|
||||
antigravity: 11,
|
||||
nvidia: 12,
|
||||
cerebras: 13,
|
||||
shengsuanyun: 14,
|
||||
ollama: 15,
|
||||
vllm: 16,
|
||||
mistral: 17,
|
||||
avian: 18,
|
||||
mimo: 19,
|
||||
"qwen-portal": 7,
|
||||
"qwen-intl": 8,
|
||||
moonshot: 9,
|
||||
groq: 10,
|
||||
"github-copilot": 11,
|
||||
antigravity: 12,
|
||||
nvidia: 13,
|
||||
cerebras: 14,
|
||||
shengsuanyun: 15,
|
||||
venice: 16,
|
||||
vivgrid: 17,
|
||||
minimax: 18,
|
||||
longcat: 19,
|
||||
modelscope: 20,
|
||||
mistral: 21,
|
||||
avian: 22,
|
||||
azure: 23,
|
||||
ollama: 24,
|
||||
vllm: 25,
|
||||
lmstudio: 26,
|
||||
zai: 27,
|
||||
mimo: 28,
|
||||
}
|
||||
|
||||
interface ProviderGroup {
|
||||
@@ -95,10 +104,10 @@ export function ModelsPage() {
|
||||
|
||||
const grouped: Record<string, { label: string; models: ModelInfo[] }> = {}
|
||||
for (const model of models) {
|
||||
const providerKey = getProviderKey(model.model)
|
||||
const providerKey = getProviderKey(model.provider)
|
||||
if (!grouped[providerKey]) {
|
||||
grouped[providerKey] = {
|
||||
label: getProviderLabel(model.model),
|
||||
label: getProviderLabel(model.provider),
|
||||
models: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useMemo, useState } from "react"
|
||||
const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
azure: "microsoftazure",
|
||||
gemini: "googlegemini",
|
||||
deepseek: "deepseek",
|
||||
qwen: "alibabacloud",
|
||||
"qwen-portal": "alibabacloud",
|
||||
"qwen-intl": "alibabacloud",
|
||||
groq: "groq",
|
||||
openrouter: "openrouter",
|
||||
nvidia: "nvidia",
|
||||
@@ -20,9 +22,11 @@ const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||
const PROVIDER_DOMAINS: Record<string, string> = {
|
||||
openai: "openai.com",
|
||||
anthropic: "anthropic.com",
|
||||
azure: "azure.com",
|
||||
gemini: "gemini.google.com",
|
||||
deepseek: "deepseek.com",
|
||||
qwen: "qwenlm.ai",
|
||||
"qwen-portal": "qwenlm.ai",
|
||||
"qwen-intl": "alibabacloud.com",
|
||||
moonshot: "moonshot.ai",
|
||||
groq: "groq.com",
|
||||
openrouter: "openrouter.ai",
|
||||
@@ -33,11 +37,18 @@ const PROVIDER_DOMAINS: Record<string, string> = {
|
||||
antigravity: "antigravity.google",
|
||||
"github-copilot": "github.com",
|
||||
ollama: "ollama.com",
|
||||
lmstudio: "lmstudio.ai",
|
||||
mistral: "mistral.ai",
|
||||
avian: "avian.io",
|
||||
vllm: "vllm.ai",
|
||||
zhipu: "zhipuai.cn",
|
||||
zai: "z.ai",
|
||||
mimo: "xiaomi.com",
|
||||
venice: "venice.ai",
|
||||
vivgrid: "vivgrid.com",
|
||||
minimax: "minimaxi.com",
|
||||
longcat: "longcat.chat",
|
||||
modelscope: "modelscope.cn",
|
||||
}
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
azure: "Azure OpenAI",
|
||||
gemini: "Google Gemini",
|
||||
deepseek: "DeepSeek",
|
||||
qwen: "Qwen (阿里云)",
|
||||
"qwen-portal": "Qwen (阿里云)",
|
||||
"qwen-intl": "Qwen International",
|
||||
moonshot: "Moonshot (月之暗面)",
|
||||
groq: "Groq",
|
||||
openrouter: "OpenRouter",
|
||||
@@ -14,21 +16,37 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||
antigravity: "Google Code Assist",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
ollama: "Ollama (local)",
|
||||
lmstudio: "LM Studio (local)",
|
||||
mistral: "Mistral AI",
|
||||
avian: "Avian",
|
||||
vllm: "VLLM (local)",
|
||||
zhipu: "Zhipu AI (智谱)",
|
||||
zai: "Z.ai",
|
||||
mimo: "Xiaomi MiMo",
|
||||
venice: "Venice AI",
|
||||
vivgrid: "Vivgrid",
|
||||
minimax: "MiniMax",
|
||||
longcat: "LongCat",
|
||||
modelscope: "ModelScope (魔搭社区)",
|
||||
}
|
||||
|
||||
export function getProviderKey(model: string): string {
|
||||
return model.split("/")[0]
|
||||
const PROVIDER_ALIASES: Record<string, string> = {
|
||||
qwen: "qwen-portal",
|
||||
"qwen-international": "qwen-intl",
|
||||
"dashscope-intl": "qwen-intl",
|
||||
"z.ai": "zai",
|
||||
"z-ai": "zai",
|
||||
google: "gemini",
|
||||
"google-antigravity": "antigravity",
|
||||
}
|
||||
|
||||
export function getProviderLabel(model: string): string {
|
||||
const prefix = getProviderKey(model)
|
||||
const labels: Record<string, string> = {
|
||||
...PROVIDER_LABELS,
|
||||
}
|
||||
return labels[prefix] ?? prefix
|
||||
export function getProviderKey(provider?: string): string {
|
||||
const normalized = provider?.trim().toLowerCase()
|
||||
if (!normalized) return "openai"
|
||||
return PROVIDER_ALIASES[normalized] ?? normalized
|
||||
}
|
||||
|
||||
export function getProviderLabel(provider?: string): string {
|
||||
const prefix = getProviderKey(provider)
|
||||
return PROVIDER_LABELS[prefix] ?? prefix
|
||||
}
|
||||
|
||||
@@ -244,8 +244,8 @@
|
||||
"modelNamePlaceholder": "e.g. my-gpt4",
|
||||
"modelNameHint": "A short name used to identify this model in conversations.",
|
||||
"modelId": "Model Identifier",
|
||||
"modelIdPlaceholder": "e.g. openai/gpt-4o",
|
||||
"modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …",
|
||||
"modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
|
||||
"modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.",
|
||||
"errorRequired": "This field is required.",
|
||||
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
||||
"saveError": "Failed to add model",
|
||||
@@ -260,6 +260,9 @@
|
||||
"toggle": "Advanced options"
|
||||
},
|
||||
"field": {
|
||||
"provider": "Provider",
|
||||
"providerPlaceholder": "e.g. openai",
|
||||
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
|
||||
"apiBase": "API Base URL",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyPlaceholder": "Enter your API key",
|
||||
|
||||
@@ -244,8 +244,8 @@
|
||||
"modelNamePlaceholder": "例如 my-gpt4",
|
||||
"modelNameHint": "用于在对话中识别此模型的简短名称。",
|
||||
"modelId": "模型标识符",
|
||||
"modelIdPlaceholder": "例如 openai/gpt-4o",
|
||||
"modelIdHint": "格式:协议/模型ID。支持:openai、anthropic、gemini、groq 等。",
|
||||
"modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o",
|
||||
"modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。",
|
||||
"errorRequired": "此字段为必填项。",
|
||||
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
||||
"saveError": "添加模型失败",
|
||||
@@ -260,6 +260,9 @@
|
||||
"toggle": "高级选项"
|
||||
},
|
||||
"field": {
|
||||
"provider": "Provider",
|
||||
"providerPlaceholder": "例如 openai",
|
||||
"providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。",
|
||||
"apiBase": "API Base URL",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyPlaceholder": "请输入 API Key",
|
||||
|
||||
Reference in New Issue
Block a user