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:
lxowalle 2026-04-22 11:28:47 +08:00 committed by GitHub
parent 3316ee6923
commit 77b0c43392
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1559 additions and 441 deletions

View file

@ -59,7 +59,7 @@ func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
// Update or add openai in ModelList // Update or add openai in ModelList
foundOpenAI := false foundOpenAI := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) { if isOpenAIModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "oauth" appCfg.ModelList[i].AuthMethod = "oauth"
foundOpenAI = true foundOpenAI = true
break break
@ -130,7 +130,7 @@ func authLoginGoogleAntigravity(noBrowser bool) error {
// Update or add antigravity in ModelList // Update or add antigravity in ModelList
foundAntigravity := false foundAntigravity := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
if isAntigravityModel(appCfg.ModelList[i].Model) { if isAntigravityModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "oauth" appCfg.ModelList[i].AuthMethod = "oauth"
foundAntigravity = true foundAntigravity = true
break break
@ -206,7 +206,7 @@ func authLoginAnthropicSetupToken() error {
if err == nil { if err == nil {
found := false found := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) { if isAnthropicModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "oauth" appCfg.ModelList[i].AuthMethod = "oauth"
found = true found = true
break break
@ -282,7 +282,7 @@ func authLoginPasteToken(provider string) error {
// Update ModelList // Update ModelList
found := false found := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) { if isAnthropicModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "token" appCfg.ModelList[i].AuthMethod = "token"
found = true found = true
break break
@ -300,7 +300,7 @@ func authLoginPasteToken(provider string) error {
// Update ModelList // Update ModelList
found := false found := false
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) { if isOpenAIModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "token" appCfg.ModelList[i].AuthMethod = "token"
found = true found = true
break break
@ -342,15 +342,15 @@ func authLogoutCmd(provider string) error {
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
switch provider { switch provider {
case "openai": case "openai":
if isOpenAIModel(appCfg.ModelList[i].Model) { if isOpenAIModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "" appCfg.ModelList[i].AuthMethod = ""
} }
case "anthropic": case "anthropic":
if isAnthropicModel(appCfg.ModelList[i].Model) { if isAnthropicModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "" appCfg.ModelList[i].AuthMethod = ""
} }
case "google-antigravity", "antigravity": case "google-antigravity", "antigravity":
if isAntigravityModel(appCfg.ModelList[i].Model) { if isAntigravityModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "" appCfg.ModelList[i].AuthMethod = ""
} }
} }
@ -484,22 +484,20 @@ func authModelsCmd() error {
return nil return nil
} }
// isAntigravityModel checks if a model string belongs to antigravity provider // isAntigravityModel checks if a model config belongs to an Antigravity provider.
func isAntigravityModel(model string) bool { func isAntigravityModel(modelCfg *config.ModelConfig) bool {
return model == "antigravity" || protocol, _ := providers.ExtractProtocol(modelCfg)
model == "google-antigravity" || return protocol == "antigravity" || protocol == "google-antigravity"
strings.HasPrefix(model, "antigravity/") ||
strings.HasPrefix(model, "google-antigravity/")
} }
// isOpenAIModel checks if a model string belongs to openai provider // isOpenAIModel checks if a model config belongs to the OpenAI provider.
func isOpenAIModel(model string) bool { func isOpenAIModel(modelCfg *config.ModelConfig) bool {
return model == "openai" || protocol, _ := providers.ExtractProtocol(modelCfg)
strings.HasPrefix(model, "openai/") return protocol == "openai"
} }
// isAnthropicModel checks if a model string belongs to anthropic provider // isAnthropicModel checks if a model config belongs to the Anthropic provider.
func isAnthropicModel(model string) bool { func isAnthropicModel(modelCfg *config.ModelConfig) bool {
return model == "anthropic" || protocol, _ := providers.ExtractProtocol(modelCfg)
strings.HasPrefix(model, "anthropic/") return protocol == "anthropic"
} }

View file

@ -3,12 +3,12 @@ package status
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
) )
func statusCmd() { func statusCmd() {
@ -44,12 +44,13 @@ func statusCmd() {
// not depend on a legacy cfg.Providers field (which may not exist under some // not depend on a legacy cfg.Providers field (which may not exist under some
// build tags). We infer provider availability from model_list entries. // build tags). We infer provider availability from model_list entries.
hasProtocolKey := func(protocol string) bool { hasProtocolKey := func(protocol string) bool {
prefix := protocol + "/" want := providers.NormalizeProvider(protocol)
for _, m := range cfg.ModelList { for _, m := range cfg.ModelList {
if m == nil { if m == nil {
continue continue
} }
if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" { got, _ := providers.ExtractProtocol(m)
if got == want && m.APIKey() != "" {
return true return true
} }
} }
@ -67,12 +68,13 @@ func statusCmd() {
return "", false return "", false
} }
findProtocolBase := func(protocol string) (string, bool) { findProtocolBase := func(protocol string) (string, bool) {
prefix := protocol + "/" want := providers.NormalizeProvider(protocol)
for _, m := range cfg.ModelList { for _, m := range cfg.ModelList {
if m == nil { if m == nil {
continue continue
} }
if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" { got, _ := providers.ExtractProtocol(m)
if got == want && m.APIBase != "" {
return m.APIBase, true return m.APIBase, true
} }
} }

View file

@ -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)
}
}

View file

@ -494,7 +494,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
### Model Configuration (model_list) ### 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: This design also enables **multi-agent support** with flexible provider selection:
@ -547,7 +547,8 @@ chmod 600 ~/.picoclaw/.security.yml
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4" "provider": "openai",
"model": "gpt-5.4"
// api_key loaded from .security.yml // api_key loaded from .security.yml
} }
], ],
@ -571,31 +572,31 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
#### All Supported Vendors #### 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) | | **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) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **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) | | **通义千问 (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) | | **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) | | **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | | **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) | | **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 | | **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **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) | | **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 | — | | **神算云** | `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) | | **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) | | **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) | | **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) | | **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — | | **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — |
#### Basic Configuration #### Basic Configuration
@ -604,22 +605,26 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
"model_list": [ "model_list": [
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest", "provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"] "api_keys": ["sk-your-api-key"]
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"] "api_keys": ["sk-your-openai-key"]
}, },
{ {
"model_name": "claude-sonnet-4.6", "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"] "api_keys": ["sk-ant-your-key"]
}, },
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7", "provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"] "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. > **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 #### 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). > **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 ```json
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4" "provider": "openai",
"model": "gpt-5.4"
// api_key: set in .security.yml // api_key: set in .security.yml
} }
``` ```
@ -658,7 +671,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json ```json
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest" "provider": "volcengine",
"model": "ark-code-latest"
// api_key: set in .security.yml // api_key: set in .security.yml
} }
``` ```
@ -671,7 +685,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json ```json
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7" "provider": "zhipu",
"model": "glm-4.7"
// api_key: set in .security.yml // api_key: set in .security.yml
} }
``` ```
@ -684,7 +699,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json ```json
{ {
"model_name": "deepseek-chat", "model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat" "provider": "deepseek",
"model": "deepseek-chat"
// api_key: set in .security.yml // api_key: set in .security.yml
} }
``` ```
@ -697,7 +713,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json ```json
{ {
"model_name": "claude-sonnet-4.6", "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 // api_key: set in .security.yml
} }
``` ```
@ -709,7 +726,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json ```json
{ {
"model_name": "claude-opus-4-6", "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_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com" "api_base": "https://api.anthropic.com"
} }
@ -725,7 +743,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json ```json
{ {
"model_name": "llama3", "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 ```json
{ {
"model_name": "lmstudio-local", "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/> `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> </details>
@ -752,13 +772,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
```json ```json
{ {
"model_name": "my-custom-model", "model_name": "my-custom-model",
"model": "openai/custom-model", "provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1" "api_base": "https://my-proxy.com/v1"
// api_key: set in .security.yml // 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> </details>
@ -783,7 +804,8 @@ model_list:
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
// api_keys loaded from .security.yml // api_keys loaded from .security.yml
} }
@ -798,13 +820,15 @@ model_list:
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"] "api_keys": ["sk-key1"]
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"] "api_keys": ["sk-key2"]
} }
@ -864,7 +888,7 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model_name": "claude-opus-4-5"
} }
}, },
"session": { "session": {

View file

@ -425,7 +425,7 @@ Agent 读取 HEARTBEAT.md
### 模型配置 (model_list) ### 模型配置 (model_list)
> **新特性:** PicoClaw 现在采用**以模型为中心**的配置方式。只需指定 `vendor/model` 格式(例如 `zhipu/glm-4.7`)即可接入新提供商——**无需修改任何代码!** > **新特性:** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。
这一设计同时支持**多 Agent**场景,灵活选择提供商: 这一设计同时支持**多 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) | | **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.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) | | **智谱 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) | | **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) | | **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) | | **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) | | **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) | | **通义千问 (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) | | **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key | | **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | | **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key | | **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) | | **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) | | **火山引擎 (豆包)** | `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 | — | | **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) | | **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) | | **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) | | **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) | | **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | 仅 OAuth | | **Antigravity** | `antigravity` | Google Cloud | Custom | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — | | **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — |
#### 基础配置 #### 基础配置
@ -469,22 +469,26 @@ Agent 读取 HEARTBEAT.md
"model_list": [ "model_list": [
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest", "provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"] "api_keys": ["sk-your-api-key"]
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"] "api_keys": ["sk-your-openai-key"]
}, },
{ {
"model_name": "claude-sonnet-4.6", "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"] "api_keys": ["sk-ant-your-key"]
}, },
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7", "provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"] "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> <details>
@ -504,7 +515,8 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-..."] "api_keys": ["sk-..."]
} }
``` ```
@ -517,7 +529,8 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest", "provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-..."] "api_keys": ["sk-..."]
} }
``` ```
@ -530,7 +543,8 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7", "provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"] "api_keys": ["your-key"]
} }
``` ```
@ -543,7 +557,8 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "deepseek-chat", "model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat", "provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-..."] "api_keys": ["sk-..."]
} }
``` ```
@ -556,7 +571,8 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "claude-sonnet-4.6", "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"] "api_keys": ["sk-ant-your-key"]
} }
``` ```
@ -568,7 +584,8 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "claude-opus-4-6", "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_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com" "api_base": "https://api.anthropic.com"
} }
@ -584,7 +601,8 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "llama3", "model_name": "llama3",
"model": "ollama/llama3" "provider": "ollama",
"model": "llama3"
} }
``` ```
@ -596,12 +614,13 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "lmstudio-local", "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。 `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> </details>
@ -611,13 +630,14 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
```json ```json
{ {
"model_name": "my-custom-model", "model_name": "my-custom-model",
"model": "openai/custom-model", "provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1", "api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."] "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> </details>
@ -630,13 +650,15 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"] "api_keys": ["sk-key1"]
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"] "api_keys": ["sk-key2"]
} }
@ -691,7 +713,7 @@ PicoClaw 按协议族路由提供商:
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model_name": "claude-opus-4-5"
} }
}, },
"session": { "session": {

View file

@ -94,19 +94,22 @@ picoclaw onboard
"model_list": [ "model_list": [
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest", "provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"], "api_keys": ["sk-your-api-key"],
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["your-api-key"], "api_keys": ["your-api-key"],
"request_timeout": 300 "request_timeout": 300
}, },
{ {
"model_name": "claude-sonnet-4.6", "model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6", "provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["your-anthropic-key"] "api_keys": ["your-anthropic-key"]
} }
], ],

View file

@ -93,19 +93,22 @@ picoclaw onboard
"model_list": [ "model_list": [
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest", "provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"], "api_keys": ["sk-your-api-key"],
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["your-api-key"], "api_keys": ["your-api-key"],
"request_timeout": 300 "request_timeout": 300
}, },
{ {
"model_name": "claude-sonnet-4.6", "model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6", "provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["your-anthropic-key"] "api_keys": ["your-anthropic-key"]
} }
], ],

View file

@ -33,7 +33,7 @@
### Model Configuration (model_list) ### 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). 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 #### 📋 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) | | **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) | | **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) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **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) | | **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) | | **通义千问 (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) | | **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) | | **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | | **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) | | **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 | | **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **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) | | **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 | - | | **神算云** | `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) | | **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) | | **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) | | **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) | | **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) | | **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) | | **Azure OpenAI** | `azure` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - |
#### Basic Configuration #### Basic Configuration
@ -83,22 +83,26 @@ This design also enables **multi-agent support** with flexible provider selectio
"model_list": [ "model_list": [
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest", "provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"] "api_keys": ["sk-your-api-key"]
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"] "api_keys": ["sk-your-openai-key"]
}, },
{ {
"model_name": "claude-sonnet-4.6", "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"] "api_keys": ["sk-ant-your-key"]
}, },
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7", "provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"] "api_keys": ["your-zhipu-key"]
} }
], ],
@ -115,7 +119,8 @@ This design also enables **multi-agent support** with flexible provider selectio
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `model_name` | string | Yes | Unique name used to reference this model in agent config | | `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_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 | | `api_base` | string | No | Override the default API endpoint URL |
| `proxy` | string | No | HTTP proxy URL for this model entry | | `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 | | `fallbacks` | string[] | No | Fallback model names for automatic failover |
| `enabled` | bool | No | Whether this model entry is active (default: `true`) | | `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 #### 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. 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_list": [
{ {
"model_name": "voice-gemini", "model_name": "voice-gemini",
"model": "gemini/gemini-2.5-flash", "provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["your-gemini-key"] "api_keys": ["your-gemini-key"]
} }
], ],
@ -163,7 +185,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json ```json
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-..."] "api_keys": ["sk-..."]
} }
``` ```
@ -173,7 +196,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json ```json
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest", "provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-..."] "api_keys": ["sk-..."]
} }
``` ```
@ -183,7 +207,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json ```json
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7", "provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"] "api_keys": ["your-key"]
} }
``` ```
@ -193,7 +218,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json ```json
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "openai/glm-4.7", "provider": "openai",
"model": "glm-4.7",
"api_keys": ["your-z.ai-key"], "api_keys": ["your-z.ai-key"],
"api_base": "https://api.z.ai/api/coding/paas/v4" "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 ```json
{ {
"model_name": "deepseek-chat", "model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat", "provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-..."] "api_keys": ["sk-..."]
} }
``` ```
@ -214,7 +241,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json ```json
{ {
"model_name": "claude-sonnet-4.6", "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"] "api_keys": ["sk-ant-your-key"]
} }
``` ```
@ -228,7 +256,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json ```json
{ {
"model_name": "claude-opus-4-6", "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_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com" "api_base": "https://api.anthropic.com"
} }
@ -246,7 +275,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json ```json
{ {
"model_name": "llama3", "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 ```json
{ {
"model_name": "lmstudio-local", "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/> `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** **Custom Proxy/API**
```json ```json
{ {
"model_name": "my-custom-model", "model_name": "my-custom-model",
"model": "openai/custom-model", "provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1", "api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."], "api_keys": ["sk-..."],
"user_agent": "MyApp/1.0", "user_agent": "MyApp/1.0",
@ -280,13 +312,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
```json ```json
{ {
"model_name": "lite-gpt4", "model_name": "lite-gpt4",
"model": "litellm/lite-gpt4", "provider": "litellm",
"model": "lite-gpt4",
"api_base": "http://localhost:4000/v1", "api_base": "http://localhost:4000/v1",
"api_keys": ["sk-..."] "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** **Z.AI Coding Plan**
@ -295,7 +328,8 @@ If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns
```json ```json
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "openai/glm-4.7", "provider": "openai",
"model": "glm-4.7",
"api_keys": ["your-zhipu-api-key"], "api_keys": ["your-zhipu-api-key"],
"api_base": "https://api.z.ai/api/coding/paas/v4" "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_list": [
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"] "api_keys": ["sk-key1"]
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"] "api_keys": ["sk-key2"]
} }
@ -337,18 +373,21 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a
"model_list": [ "model_list": [
{ {
"model_name": "qwen-main", "model_name": "qwen-main",
"model": "openai/qwen3.5:cloud", "provider": "openai",
"model": "qwen3.5:cloud",
"api_base": "https://api.example.com/v1", "api_base": "https://api.example.com/v1",
"api_keys": ["sk-main"] "api_keys": ["sk-main"]
}, },
{ {
"model_name": "deepseek-backup", "model_name": "deepseek-backup",
"model": "deepseek/deepseek-chat", "provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-backup-1"] "api_keys": ["sk-backup-1"]
}, },
{ {
"model_name": "gemini-backup", "model_name": "gemini-backup",
"model": "gemini/gemini-2.5-flash", "provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["sk-backup-2"] "api_keys": ["sk-backup-2"]
} }
], ],
@ -396,7 +435,8 @@ The old `providers` configuration is **deprecated** and has been removed in V2.
"model_list": [ "model_list": [
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7", "provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"] "api_keys": ["your-key"]
} }
], ],
@ -465,7 +505,7 @@ picoclaw agent -m "Hello"
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model_name": "anthropic/claude-opus-4-5" "model_name": "claude-opus-4-5"
} }
}, },
"session": { "session": {

View file

@ -32,7 +32,7 @@
<a id="模型配置-model_list"></a> <a id="模型配置-model_list"></a>
### 模型配置 (model_list) ### 模型配置 (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)。 如果你想看 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) | | **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) | | **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) | | **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) | | **智谱 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) | | **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) | | **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) | | **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) | | **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) | | **通义千问 (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) | | **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | | **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | | **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | | **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | | **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) | | **火山引擎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 | - | | **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) | | **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) | | **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) | | **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) | | **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) | | **小米 MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) |
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | | **Antigravity** | `antigravity` | Google Cloud | 自定义 | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - |
#### 基础配置示例 #### 基础配置示例
@ -80,22 +80,26 @@
"model_list": [ "model_list": [
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest", "provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"] "api_keys": ["sk-your-api-key"]
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"] "api_keys": ["sk-your-openai-key"]
}, },
{ {
"model_name": "claude-sonnet-4.6", "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"] "api_keys": ["sk-ant-your-key"]
}, },
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7", "provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"] "api_keys": ["your-zhipu-key"]
} }
], ],
@ -112,7 +116,8 @@
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 | | `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[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 providerOllama、LM Studio、VLLM不需要 | | `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 providerOllama、LM Studio、VLLM不需要 |
| `api_base` | string | 否 | 覆盖默认的 API 端点 URL | | `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL | | `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
@ -126,6 +131,22 @@
| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 | | `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 |
| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true` | | `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。 你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider而不必只依赖 Groq。
@ -137,7 +158,8 @@
"model_list": [ "model_list": [
{ {
"model_name": "voice-gemini", "model_name": "voice-gemini",
"model": "gemini/gemini-2.5-flash", "provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["your-gemini-key"] "api_keys": ["your-gemini-key"]
} }
], ],
@ -160,7 +182,8 @@
```json ```json
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-..."] "api_keys": ["sk-..."]
} }
``` ```
@ -170,7 +193,8 @@
```json ```json
{ {
"model_name": "ark-code-latest", "model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest", "provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-..."] "api_keys": ["sk-..."]
} }
``` ```
@ -180,7 +204,8 @@
```json ```json
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7", "provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"] "api_keys": ["your-key"]
} }
``` ```
@ -190,7 +215,8 @@
```json ```json
{ {
"model_name": "deepseek-chat", "model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat", "provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-..."] "api_keys": ["sk-..."]
} }
``` ```
@ -200,7 +226,8 @@
```json ```json
{ {
"model_name": "claude-sonnet-4.6", "model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6", "provider": "anthropic",
"model": "claude-sonnet-4.6",
"auth_method": "oauth" "auth_method": "oauth"
} }
``` ```
@ -214,7 +241,8 @@
```json ```json
{ {
"model_name": "claude-opus-4-6", "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_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com" "api_base": "https://api.anthropic.com"
} }
@ -232,7 +260,8 @@
```json ```json
{ {
"model_name": "llama3", "model_name": "llama3",
"model": "ollama/llama3" "provider": "ollama",
"model": "llama3"
} }
``` ```
@ -241,19 +270,21 @@
```json ```json
{ {
"model_name": "lmstudio-local", "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。 `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** **自定义代理/API**
```json ```json
{ {
"model_name": "my-custom-model", "model_name": "my-custom-model",
"model": "openai/custom-model", "provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1", "api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."], "api_keys": ["sk-..."],
"user_agent": "MyApp/1.0", "user_agent": "MyApp/1.0",
@ -266,13 +297,14 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
```json ```json
{ {
"model_name": "lite-gpt4", "model_name": "lite-gpt4",
"model": "litellm/lite-gpt4", "provider": "litellm",
"model": "lite-gpt4",
"api_base": "http://localhost:4000/v1", "api_base": "http://localhost:4000/v1",
"api_keys": ["sk-..."] "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_list": [
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"] "api_keys": ["sk-key1"]
}, },
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"] "api_keys": ["sk-key2"]
} }
@ -308,18 +342,21 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
"model_list": [ "model_list": [
{ {
"model_name": "qwen-main", "model_name": "qwen-main",
"model": "openai/qwen3.5:cloud", "provider": "openai",
"model": "qwen3.5:cloud",
"api_base": "https://api.example.com/v1", "api_base": "https://api.example.com/v1",
"api_keys": ["sk-main"] "api_keys": ["sk-main"]
}, },
{ {
"model_name": "deepseek-backup", "model_name": "deepseek-backup",
"model": "deepseek/deepseek-chat", "provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-backup-1"] "api_keys": ["sk-backup-1"]
}, },
{ {
"model_name": "gemini-backup", "model_name": "gemini-backup",
"model": "gemini/gemini-2.5-flash", "provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["sk-backup-2"] "api_keys": ["sk-backup-2"]
} }
], ],
@ -367,7 +404,8 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
"model_list": [ "model_list": [
{ {
"model_name": "glm-4.7", "model_name": "glm-4.7",
"model": "zhipu/glm-4.7", "provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"] "api_keys": ["your-key"]
} }
], ],
@ -436,7 +474,7 @@ picoclaw agent -m "你好"
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model_name": "anthropic/claude-opus-4-5" "model_name": "claude-opus-4-5"
} }
}, },
"session": { "session": {

View file

@ -69,12 +69,14 @@ This guide explains how to configure both for real deployments.
"model_list": [ "model_list": [
{ {
"model_name": "gpt-main", "model_name": "gpt-main",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-main"] "api_keys": ["sk-main"]
}, },
{ {
"model_name": "flash-light", "model_name": "flash-light",
"model": "gemini/gemini-2.0-flash-exp", "provider": "gemini",
"model": "gemini-2.0-flash-exp",
"api_keys": ["sk-light"] "api_keys": ["sk-light"]
} }
], ],

View file

@ -69,12 +69,14 @@ PicoClaw 里用户能直接感知到的“路由”主要有两部分:
"model_list": [ "model_list": [
{ {
"model_name": "gpt-main", "model_name": "gpt-main",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-main"] "api_keys": ["sk-main"]
}, },
{ {
"model_name": "flash-light", "model_name": "flash-light",
"model": "gemini/gemini-2.0-flash-exp", "provider": "gemini",
"model": "gemini-2.0-flash-exp",
"api_keys": ["sk-light"] "api_keys": ["sk-light"]
} }
], ],

View file

@ -8,7 +8,7 @@ The new `model_list` configuration offers several advantages:
- **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only - **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only
- **Load balancing**: Configure multiple endpoints for the same model - **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 - **Cleaner configuration**: Model-centric instead of vendor-centric
## Timeline ## Timeline
@ -54,18 +54,21 @@ The new `model_list` configuration offers several advantages:
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"], "api_keys": ["sk-your-openai-key"],
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
}, },
{ {
"model_name": "claude-sonnet-4.6", "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"] "api_keys": ["sk-ant-your-key"]
}, },
{ {
"model_name": "deepseek", "model_name": "deepseek",
"model": "deepseek/deepseek-chat", "provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-your-deepseek-key"] "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. > **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 | ```json
|--------|-------------|---------| {
| `openai/` | OpenAI API (default) | `openai/gpt-5.4` | "provider": "openai",
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` | "model": "gpt-5.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` |
**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 ## ModelConfig Fields
| Field | Required | Description | | Field | Required | Description |
|-------|----------|-------------| |-------|----------|-------------|
| `model_name` | Yes | User-facing alias for the model | | `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_base` | No | API endpoint URL |
| `api_keys` | No | API authentication keys (array; supports multiple keys for load balancing) | | `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. | | `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_list": [
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-key1", "sk-key2", "sk-key3"], "api_keys": ["sk-key1", "sk-key2", "sk-key3"],
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
} }
@ -162,19 +172,22 @@ model_list:
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-key1"], "api_keys": ["sk-key1"],
"api_base": "https://api1.example.com/v1" "api_base": "https://api1.example.com/v1"
}, },
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-key2"], "api_keys": ["sk-key2"],
"api_base": "https://api2.example.com/v1" "api_base": "https://api2.example.com/v1"
}, },
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.4", "provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-key3"], "api_keys": ["sk-key3"],
"api_base": "https://api3.example.com/v1" "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_list": [
{ {
"model_name": "my-custom-llm", "model_name": "my-custom-llm",
"model": "openai/my-model-v1", "provider": "openai",
"model": "my-model-v1",
"api_keys": ["your-api-key"], "api_keys": ["your-api-key"],
"api_base": "https://api.your-provider.com/v1" "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 ## 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 - [ ] Identify all providers you're currently using
- [ ] Create `model_list` entries for each provider - [ ] 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` - [ ] Update `agents.defaults.model_name` to reference the new `model_name`
- [ ] Test that all models work correctly - [ ] Test that all models work correctly
- [ ] Remove or comment out the old `providers` section - [ ] 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 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 ### Missing API key error

View file

@ -7,16 +7,22 @@
- `Error creating provider: model "openrouter/free" not found in model_list` - `Error creating provider: model "openrouter/free" not found in model_list`
- OpenRouter returns 400: `"free is not a valid model ID"` - 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. - If `provider` is set, the `model` field is sent to that provider unchanged.
- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing). - 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): **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"`). 1. **agents.defaults.model_name** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
2. That entrys **model** must be a valid OpenRouter model ID, for example: 2. That entry should preferably set **provider** to `openrouter`, and **model** should be a valid OpenRouter model ID, for example:
- `"openrouter/free"` auto free-tier - `"free"` auto free-tier
- `"google/gemini-2.0-flash-exp:free"` - `"google/gemini-2.0-flash-exp:free"`
- `"meta-llama/llama-3.1-8b-instruct:free"` - `"meta-llama/llama-3.1-8b-instruct:free"`
@ -32,8 +38,9 @@ Example snippet:
"model_list": [ "model_list": [
{ {
"model_name": "openrouter-free", "model_name": "openrouter-free",
"model": "openrouter/free", "provider": "openrouter",
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", "model": "free",
"api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"],
"api_base": "https://openrouter.ai/api/v1" "api_base": "https://openrouter.ai/api/v1"
} }
] ]

View file

@ -9,16 +9,22 @@
- `Error creating provider: model "openrouter/free" not found in model_list` - `Error creating provider: model "openrouter/free" not found in model_list`
- OpenRouter 返回 400`"free is not a valid model ID"` - OpenRouter 返回 400`"free is not a valid model ID"`
**原因:** `model_list` 条目中的 `model` 字段是发送给 API 的内容。对于 OpenRouter你必须使用**完整的**模型 ID而不是简写。 **原因:** PicoClaw 现在按两步解析 provider 和 model
- **错误:** `"model": "free"` → OpenRouter 收到 `free` 并拒绝。 - 如果设置了 `provider`,则会把 `model` 原样发送给该 provider。
- **正确:** `"model": "openrouter/free"` → OpenRouter 收到 `openrouter/free`(自动免费层路由)。 - 如果未设置 `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`(或你的配置路径)中: **修复方法:** 在 `~/.picoclaw/config.json`(或你的配置路径)中:
1. **agents.defaults.model_name** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。 1. **agents.defaults.model_name** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。
2. 该条目的 **model** 必须是有效的 OpenRouter 模型 ID例如 2. 该条目推荐显式设置 **provider**`openrouter`,并在 **model** 中填写有效的 OpenRouter 模型 ID例如
- `"openrouter/free"` 自动免费层 - `"free"` 自动免费层
- `"google/gemini-2.0-flash-exp:free"` - `"google/gemini-2.0-flash-exp:free"`
- `"meta-llama/llama-3.1-8b-instruct:free"` - `"meta-llama/llama-3.1-8b-instruct:free"`
@ -34,8 +40,9 @@
"model_list": [ "model_list": [
{ {
"model_name": "openrouter-free", "model_name": "openrouter-free",
"model": "openrouter/free", "provider": "openrouter",
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", "model": "free",
"api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"],
"api_base": "https://openrouter.ai/api/v1" "api_base": "https://openrouter.ai/api/v1"
} }
] ]

View file

@ -39,20 +39,23 @@ Set `rpm` on any model in `model_list`:
```yaml ```yaml
model_list: model_list:
- model_name: gpt-4o-free - model_name: gpt-4o-free
model: openai/gpt-4o provider: openai
model: gpt-4o
api_base: https://api.openai.com/v1 api_base: https://api.openai.com/v1
rpm: 3 # max 3 requests per minute rpm: 3 # max 3 requests per minute
api_keys: api_keys:
- sk-... - sk-...
- model_name: claude-haiku - model_name: claude-haiku
model: anthropic/claude-haiku-4-5 provider: anthropic
model: claude-haiku-4-5
rpm: 60 # 60 rpm (Anthropic free tier) rpm: 60 # 60 rpm (Anthropic free tier)
api_keys: api_keys:
- sk-ant-... - sk-ant-...
- model_name: local-llm - model_name: local-llm
model: openai/llama3 provider: ollama
model: llama3
api_base: http://localhost:11434/v1 api_base: http://localhost:11434/v1
# no rpm → unrestricted # no rpm → unrestricted
``` ```
@ -68,7 +71,8 @@ When a model has fallbacks configured, each candidate is rate-limited **independ
```yaml ```yaml
model_list: model_list:
- model_name: gpt4-with-fallback - model_name: gpt4-with-fallback
model: openai/gpt-4o provider: openai
model: gpt-4o
rpm: 5 rpm: 5
fallbacks: fallbacks:
- gpt-4o-mini # must also be in model_list; its own rpm applies - gpt-4o-mini # must also be in model_list; its own rpm applies

View file

@ -128,7 +128,7 @@ func useTestSideQuestionProvider(al *AgentLoop, provider providers.LLMProvider)
al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) {
model := provider.GetDefaultModel() model := provider.GetDefaultModel()
if mc != nil { if mc != nil {
if _, modelID := providers.ExtractProtocol(mc.Model); modelID != "" { if _, modelID := providers.ExtractProtocol(mc); modelID != "" {
model = modelID model = modelID
} }
} }

View file

@ -881,7 +881,11 @@ func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) {
resultCh <- result{resp: resp, err: err} 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 { if err := al.InterruptGraceful("stop now"); err != nil {
t.Fatalf("InterruptGraceful failed: %v", err) t.Fatalf("InterruptGraceful failed: %v", err)

View file

@ -270,8 +270,8 @@ func populateCandidateProvidersFromNames(
map[string]any{"name": name, "error": err.Error()}) map[string]any{"name": name, "error": err.Error()})
continue continue
} }
protocol, modelID := providers.ExtractProtocol(strings.TrimSpace(mc.Model)) protocol, modelID := providers.ExtractProtocol(mc)
key := providers.ModelKey(providers.NormalizeProvider(protocol), modelID) key := providers.ModelKey(protocol, modelID)
if _, exists := out[key]; exists { if _, exists := out[key]; exists {
continue continue
} }

View file

@ -104,6 +104,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
name string name string
aliasName string aliasName string
modelName string modelName string
provider string
apiBase string apiBase string
wantProvider string wantProvider string
wantModel string wantModel string
@ -124,6 +125,15 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
wantProvider: "openai", wantProvider: "openai",
wantModel: "glm-5", 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 { for _, tt := range tests {
@ -145,6 +155,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
{ {
ModelName: tt.aliasName, ModelName: tt.aliasName,
Model: tt.modelName, Model: tt.modelName,
Provider: tt.provider,
APIBase: tt.apiBase, 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) { func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
mediaDir := media.TempDir() mediaDir := media.TempDir()

View file

@ -37,14 +37,14 @@ func candidateFromModelConfig(
return providers.FallbackCandidate{}, false return providers.FallbackCandidate{}, false
} }
ref := providers.ParseModelRef(ensureProtocolModel(mc.Model), defaultProvider) protocol, modelID := providers.ExtractProtocol(mc)
if ref == nil { if strings.TrimSpace(modelID) == "" {
return providers.FallbackCandidate{}, false return providers.FallbackCandidate{}, false
} }
return providers.FallbackCandidate{ return providers.FallbackCandidate{
Provider: ref.Provider, Provider: protocol,
Model: ref.Model, Model: modelID,
RPM: mc.RPM, RPM: mc.RPM,
IdentityKey: modelConfigIdentityKey(mc), IdentityKey: modelConfigIdentityKey(mc),
}, true }, true
@ -60,6 +60,12 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
return mc 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 { for i := range cfg.ModelList {
mc := cfg.ModelList[i] mc := cfg.ModelList[i]
if mc == nil { if mc == nil {
@ -72,10 +78,13 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
if fullModel == raw { if fullModel == raw {
return mc return mc
} }
_, modelID := providers.ExtractProtocol(fullModel) protocol, modelID := providers.ExtractProtocol(mc)
if modelID == raw { if modelID == raw {
return mc return mc
} }
if rawKey != "" && providers.ModelKey(protocol, modelID) == rawKey {
return mc
}
} }
return nil return nil

View file

@ -19,16 +19,16 @@ type TranscriptionResponse struct {
Duration float64 `json:"duration,omitempty"` Duration float64 `json:"duration,omitempty"`
} }
func supportsAudioTranscription(model string) bool { func supportsAudioTranscription(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(model) protocol, _ := providers.ExtractProtocol(modelCfg)
switch protocol { switch protocol {
case "openai", "azure", "azure-openai", case "openai", "azure", "azure-openai",
"litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "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", "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 // These protocols all go through the OpenAI-compatible or Azure provider path in
// providers.CreateProviderFromConfig, so they are the only ones that can supply // providers.CreateProviderFromConfig, so they are the only ones that can supply
// the audio media payload shape expected by NewAudioModelTranscriber. // the audio media payload shape expected by NewAudioModelTranscriber.
@ -41,15 +41,15 @@ func supportsAudioTranscription(model string) bool {
} }
} }
func supportsWhisperTranscription(model string) bool { func supportsWhisperTranscription(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(model) protocol, _ := providers.ExtractProtocol(modelCfg)
switch protocol { switch protocol {
case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "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", "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 return true
default: default:
return false return false
@ -61,11 +61,11 @@ func whisperModelID(modelCfg *config.ModelConfig) string {
return "" return ""
} }
if !supportsWhisperTranscription(modelCfg.Model) { if !supportsWhisperTranscription(modelCfg) {
return "" return ""
} }
_, modelID := providers.ExtractProtocol(strings.TrimSpace(modelCfg.Model)) _, modelID := providers.ExtractProtocol(modelCfg)
if strings.Contains(strings.ToLower(modelID), "whisper") { if strings.Contains(strings.ToLower(modelID), "whisper") {
return modelID return modelID
} }
@ -77,14 +77,14 @@ func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
return nil return nil
} }
protocol, _ := providers.ExtractProtocol(modelCfg.Model) protocol, _ := providers.ExtractProtocol(modelCfg)
if protocol == "elevenlabs" && modelCfg.APIKey() != "" { if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
} }
if modelID := whisperModelID(modelCfg); modelID != "" { if modelID := whisperModelID(modelCfg); modelID != "" {
return NewWhisperTranscriber(modelCfg) return NewWhisperTranscriber(modelCfg)
} }
if supportsAudioTranscription(modelCfg.Model) { if supportsAudioTranscription(modelCfg) {
return NewAudioModelTranscriber(modelCfg) return NewAudioModelTranscriber(modelCfg)
} }
return nil return nil
@ -95,7 +95,7 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe
return nil return nil
} }
protocol, _ := providers.ExtractProtocol(modelCfg.Model) protocol, _ := providers.ExtractProtocol(modelCfg)
if protocol == "elevenlabs" && modelCfg.APIKey() != "" { if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
} }

View file

@ -32,7 +32,7 @@ func NewWhisperTranscriber(modelCfg *config.ModelConfig) *WhisperTranscriber {
return nil return nil
} }
protocol, modelID := providers.ExtractProtocol(modelCfg.Model) protocol, modelID := providers.ExtractProtocol(modelCfg)
if modelID == "" { if modelID == "" {
modelID = strings.TrimSpace(modelCfg.Model) modelID = strings.TrimSpace(modelCfg.Model)
} }

View file

@ -24,7 +24,7 @@ func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
return nil return nil
} }
protocol, modelID := providers.ExtractProtocol(mc.Model) protocol, modelID := providers.ExtractProtocol(mc)
if modelID == "" { if modelID == "" {
modelID = strings.TrimSpace(mc.Model) modelID = strings.TrimSpace(mc.Model)
} }

View file

@ -523,15 +523,16 @@ type VoiceConfig struct {
// ModelConfig represents a model-centric provider configuration. // ModelConfig represents a model-centric provider configuration.
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only. // It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
// The model field uses protocol prefix format: [protocol/]model-identifier // The Model field may be either a plain model identifier or a provider-prefixed
// Supported protocols include openai, anthropic, antigravity, claude-cli, // 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 // codex-cli, github-copilot, and named OpenAI-compatible protocols such as
// groq, deepseek, modelscope, and novita. // groq, deepseek, modelscope, and novita.
// Default protocol is "openai" if no prefix is specified.
type ModelConfig struct { type ModelConfig struct {
// Required fields // Required fields
ModelName string `json:"model_name"` // User-facing alias for the model 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 // HTTP-based providers
APIBase string `json:"api_base,omitempty"` // API endpoint URL 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 // Create a copy for the additional key
additionalEntry := &ModelConfig{ additionalEntry := &ModelConfig{
ModelName: expandedName, ModelName: expandedName,
Provider: m.Provider,
Model: m.Model, Model: m.Model,
APIBase: m.APIBase, APIBase: m.APIBase,
APIKeys: SimpleSecureStrings(keys[i]), APIKeys: SimpleSecureStrings(keys[i]),
@ -1434,6 +1436,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
// Create the primary entry with first key and fallbacks // Create the primary entry with first key and fallbacks
primaryEntry := &ModelConfig{ primaryEntry := &ModelConfig{
ModelName: originalName, ModelName: originalName,
Provider: m.Provider,
Model: m.Model, Model: m.Model,
APIBase: m.APIBase, APIBase: m.APIBase,
Proxy: m.Proxy, Proxy: m.Proxy,

View file

@ -1944,7 +1944,7 @@ func TestDefaultConfig_MinimaxExtraBody(t *testing.T) {
var minimaxCfg *ModelConfig var minimaxCfg *ModelConfig
for i := range cfg.ModelList { 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] minimaxCfg = cfg.ModelList[i]
break break
} }

View file

@ -61,129 +61,148 @@ func DefaultConfig() *Config {
// Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys // Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys
{ {
ModelName: "glm-4.7", ModelName: "glm-4.7",
Model: "zhipu/glm-4.7", Provider: "zhipu",
Model: "glm-4.7",
APIBase: "https://open.bigmodel.cn/api/paas/v4", APIBase: "https://open.bigmodel.cn/api/paas/v4",
}, },
// OpenAI - https://platform.openai.com/api-keys // OpenAI - https://platform.openai.com/api-keys
{ {
ModelName: "gpt-5.4", ModelName: "gpt-5.4",
Model: "openai/gpt-5.4", Provider: "openai",
Model: "gpt-5.4",
APIBase: "https://api.openai.com/v1", APIBase: "https://api.openai.com/v1",
}, },
// Anthropic Claude - https://console.anthropic.com/settings/keys // Anthropic Claude - https://console.anthropic.com/settings/keys
{ {
ModelName: "claude-sonnet-4.6", ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6", Provider: "anthropic",
Model: "claude-sonnet-4.6",
APIBase: "https://api.anthropic.com/v1", APIBase: "https://api.anthropic.com/v1",
}, },
// DeepSeek - https://platform.deepseek.com/ // DeepSeek - https://platform.deepseek.com/
{ {
ModelName: "deepseek-chat", ModelName: "deepseek-chat",
Model: "deepseek/deepseek-chat", Provider: "deepseek",
Model: "deepseek-chat",
APIBase: "https://api.deepseek.com/v1", APIBase: "https://api.deepseek.com/v1",
}, },
// Venice AI - https://venice.ai // Venice AI - https://venice.ai
{ {
ModelName: "venice-uncensored", ModelName: "venice-uncensored",
Model: "venice/venice-uncensored", Provider: "venice",
Model: "venice-uncensored",
APIBase: "https://api.venice.ai/api/v1", APIBase: "https://api.venice.ai/api/v1",
}, },
// Google Gemini - https://ai.google.dev/ // Google Gemini - https://ai.google.dev/
{ {
ModelName: "gemini-2.0-flash", 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", APIBase: "https://generativelanguage.googleapis.com/v1beta",
}, },
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey // Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
{ {
ModelName: "qwen-plus", ModelName: "qwen-plus",
Model: "qwen/qwen-plus", Provider: "qwen",
Model: "qwen-plus",
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1", APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
}, },
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys // Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
{ {
ModelName: "moonshot-v1-8k", ModelName: "moonshot-v1-8k",
Model: "moonshot/moonshot-v1-8k", Provider: "moonshot",
Model: "moonshot-v1-8k",
APIBase: "https://api.moonshot.cn/v1", APIBase: "https://api.moonshot.cn/v1",
}, },
// Groq - https://console.groq.com/keys // Groq - https://console.groq.com/keys
{ {
ModelName: "llama-3.3-70b", 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", APIBase: "https://api.groq.com/openai/v1",
}, },
// OpenRouter (100+ models) - https://openrouter.ai/keys // OpenRouter (100+ models) - https://openrouter.ai/keys
{ {
ModelName: "openrouter-auto", ModelName: "openrouter-auto",
Model: "openrouter/auto", Provider: "openrouter",
Model: "auto",
APIBase: "https://openrouter.ai/api/v1", APIBase: "https://openrouter.ai/api/v1",
}, },
{ {
ModelName: "openrouter-gpt-5.4", ModelName: "openrouter-gpt-5.4",
Model: "openrouter/openai/gpt-5.4", Provider: "openrouter",
Model: "openai/gpt-5.4",
APIBase: "https://openrouter.ai/api/v1", APIBase: "https://openrouter.ai/api/v1",
}, },
// NVIDIA - https://build.nvidia.com/ // NVIDIA - https://build.nvidia.com/
{ {
ModelName: "nemotron-4-340b", ModelName: "nemotron-4-340b",
Model: "nvidia/nemotron-4-340b-instruct", Provider: "nvidia",
Model: "nemotron-4-340b-instruct",
APIBase: "https://integrate.api.nvidia.com/v1", APIBase: "https://integrate.api.nvidia.com/v1",
}, },
// Cerebras - https://inference.cerebras.ai/ // Cerebras - https://inference.cerebras.ai/
{ {
ModelName: "cerebras-llama-3.3-70b", 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", APIBase: "https://api.cerebras.ai/v1",
}, },
// Vivgrid - https://vivgrid.com // Vivgrid - https://vivgrid.com
{ {
ModelName: "vivgrid-auto", ModelName: "vivgrid-auto",
Model: "vivgrid/auto", Provider: "vivgrid",
Model: "auto",
APIBase: "https://api.vivgrid.com/v1", APIBase: "https://api.vivgrid.com/v1",
}, },
// Volcengine (火山引擎) - https://console.volcengine.com/ark // Volcengine (火山引擎) - https://console.volcengine.com/ark
{ {
ModelName: "ark-code-latest", ModelName: "ark-code-latest",
Model: "volcengine/ark-code-latest", Provider: "volcengine",
Model: "ark-code-latest",
APIBase: "https://ark.cn-beijing.volces.com/api/v3", APIBase: "https://ark.cn-beijing.volces.com/api/v3",
}, },
{ {
ModelName: "doubao-pro", ModelName: "doubao-pro",
Model: "volcengine/doubao-pro-32k", Provider: "volcengine",
Model: "doubao-pro-32k",
APIBase: "https://ark.cn-beijing.volces.com/api/v3", APIBase: "https://ark.cn-beijing.volces.com/api/v3",
}, },
// ShengsuanYun (神算云) // ShengsuanYun (神算云)
{ {
ModelName: "deepseek-v3", ModelName: "deepseek-v3",
Model: "shengsuanyun/deepseek-v3", Provider: "shengsuanyun",
Model: "deepseek-v3",
APIBase: "https://api.shengsuanyun.com/v1", APIBase: "https://api.shengsuanyun.com/v1",
}, },
// Antigravity (Google Cloud Code Assist) - OAuth only // Antigravity (Google Cloud Code Assist) - OAuth only
{ {
ModelName: "gemini-flash", ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash", Provider: "antigravity",
Model: "gemini-3-flash",
AuthMethod: "oauth", AuthMethod: "oauth",
}, },
// GitHub Copilot - https://github.com/settings/tokens // GitHub Copilot - https://github.com/settings/tokens
{ {
ModelName: "copilot-gpt-5.4", ModelName: "copilot-gpt-5.4",
Model: "github-copilot/gpt-5.4", Provider: "github-copilot",
Model: "gpt-5.4",
APIBase: "http://localhost:4321", APIBase: "http://localhost:4321",
AuthMethod: "oauth", AuthMethod: "oauth",
}, },
@ -191,33 +210,38 @@ func DefaultConfig() *Config {
// Ollama (local) - https://ollama.com // Ollama (local) - https://ollama.com
{ {
ModelName: "llama3", ModelName: "llama3",
Model: "ollama/llama3", Provider: "ollama",
Model: "llama3",
APIBase: "http://localhost:11434/v1", APIBase: "http://localhost:11434/v1",
}, },
// Mistral AI - https://console.mistral.ai/api-keys // Mistral AI - https://console.mistral.ai/api-keys
{ {
ModelName: "mistral-small", ModelName: "mistral-small",
Model: "mistral/mistral-small-latest", Provider: "mistral",
Model: "mistral-small-latest",
APIBase: "https://api.mistral.ai/v1", APIBase: "https://api.mistral.ai/v1",
}, },
// Avian - https://avian.io // Avian - https://avian.io
{ {
ModelName: "deepseek-v3.2", ModelName: "deepseek-v3.2",
Model: "avian/deepseek/deepseek-v3.2", Provider: "avian",
Model: "deepseek/deepseek-v3.2",
APIBase: "https://api.avian.io/v1", APIBase: "https://api.avian.io/v1",
}, },
{ {
ModelName: "kimi-k2.5", ModelName: "kimi-k2.5",
Model: "avian/moonshotai/kimi-k2.5", Provider: "avian",
Model: "moonshotai/kimi-k2.5",
APIBase: "https://api.avian.io/v1", APIBase: "https://api.avian.io/v1",
}, },
// Minimax - https://api.minimaxi.com/ // Minimax - https://api.minimaxi.com/
{ {
ModelName: "MiniMax-M2.5", ModelName: "MiniMax-M2.5",
Model: "minimax/MiniMax-M2.5", Provider: "minimax",
Model: "MiniMax-M2.5",
APIBase: "https://api.minimaxi.com/v1", APIBase: "https://api.minimaxi.com/v1",
ExtraBody: map[string]any{"reasoning_split": true}, ExtraBody: map[string]any{"reasoning_split": true},
}, },
@ -225,28 +249,32 @@ func DefaultConfig() *Config {
// LongCat - https://longcat.chat/platform // LongCat - https://longcat.chat/platform
{ {
ModelName: "LongCat-Flash-Thinking", ModelName: "LongCat-Flash-Thinking",
Model: "longcat/LongCat-Flash-Thinking", Provider: "longcat",
Model: "LongCat-Flash-Thinking",
APIBase: "https://api.longcat.chat/openai", APIBase: "https://api.longcat.chat/openai",
}, },
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens // ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
{ {
ModelName: "modelscope-qwen", 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", APIBase: "https://api-inference.modelscope.cn/v1",
}, },
// VLLM (local) - http://localhost:8000 // VLLM (local) - http://localhost:8000
{ {
ModelName: "local-model", ModelName: "local-model",
Model: "vllm/custom-model", Provider: "vllm",
Model: "custom-model",
APIBase: "http://localhost:8000/v1", APIBase: "http://localhost:8000/v1",
}, },
// LM Studio (local) - http://localhost:1234 // LM Studio (local) - http://localhost:1234
{ {
ModelName: "lmstudio-local", ModelName: "lmstudio-local",
Model: "lmstudio/openai/gpt-oss-20b", Provider: "lmstudio",
Model: "openai/gpt-oss-20b",
APIBase: "http://localhost:1234/v1", 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 // model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name
{ {
ModelName: "azure-gpt5", ModelName: "azure-gpt5",
Model: "azure/my-gpt5-deployment", Provider: "azure",
Model: "my-gpt5-deployment",
APIBase: "https://your-resource.openai.azure.com", APIBase: "https://your-resource.openai.azure.com",
}, },
}, },

View file

@ -188,6 +188,7 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
modelCfg := &ModelConfig{ modelCfg := &ModelConfig{
ModelName: "gpt-4", ModelName: "gpt-4",
Provider: "openrouter",
Model: "openai/gpt-4o", Model: "openai/gpt-4o",
APIBase: "https://api.example.com", APIBase: "https://api.example.com",
Proxy: "http://proxy:8080", Proxy: "http://proxy:8080",
@ -206,6 +207,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
if primary.APIBase != "https://api.example.com" { if primary.APIBase != "https://api.example.com" {
t.Errorf("expected api_base preserved, got %q", primary.APIBase) 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" { if primary.Proxy != "http://proxy:8080" {
t.Errorf("expected proxy preserved, got %q", primary.Proxy) 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 // Check additional entry also preserves fields
additional := result[0] additional := result[0]
if additional.Provider != "openrouter" {
t.Errorf("expected additional provider preserved, got %q", additional.Provider)
}
if additional.APIBase != "https://api.example.com" { if additional.APIBase != "https://api.example.com" {
t.Errorf("expected additional api_base preserved, got %q", additional.APIBase) t.Errorf("expected additional api_base preserved, got %q", additional.APIBase)
} }

View file

@ -41,6 +41,7 @@ var protocolMetaByName = map[string]protocolMeta{
"vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"}, "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"},
"volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"}, "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"},
"qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, "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-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
"qwen-international": {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"}, "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"}, "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
"coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
"alibaba-coding-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}, "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true},
"mistral": {defaultAPIBase: "https://api.mistral.ai/v1"}, "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"},
"avian": {defaultAPIBase: "https://api.avian.io/v1"}, "avian": {defaultAPIBase: "https://api.avian.io/v1"},
@ -84,19 +86,43 @@ func createCodexAuthProvider() (LLMProvider, error) {
return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
} }
// ExtractProtocol extracts the protocol prefix and model identifier from a model string. // ExtractProtocol extracts the effective protocol and model identifier from a
// If no prefix is specified, it defaults to "openai". // 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: // Examples:
// - "openai/gpt-4o" -> ("openai", "gpt-4o") // - Model "openai/gpt-4o" -> ("openai", "gpt-4o")
// - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6") // - Model "nvidia/z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1")
// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol // - Provider "nvidia", Model "z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1")
func ExtractProtocol(model string) (protocol, modelID string) { // - Provider "openai", Model "openai/gpt-4o" -> ("openai", "openai/gpt-4o")
model = strings.TrimSpace(model) // - Model "gpt-4o" -> ("openai", "gpt-4o")
protocol, modelID, found := strings.Cut(model, "/") 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 { if !found {
return "openai", model 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 // 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 != "" { if apiBase := strings.TrimSpace(cfg.APIBase); apiBase != "" {
return strings.TrimRight(apiBase, "/") return strings.TrimRight(apiBase, "/")
} }
protocol, _ := ExtractProtocol(cfg.Model) protocol, _ := ExtractProtocol(cfg)
return strings.TrimRight(getDefaultAPIBase(protocol), "/") return strings.TrimRight(getDefaultAPIBase(protocol), "/")
} }
// CreateProviderFromConfig creates a provider based on the ModelConfig. // 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), // Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq),
// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims. // Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims.
// See the switch on protocol in this function for the authoritative list. // 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) { func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
if cfg == nil { if cfg == nil {
return nil, "", fmt.Errorf("config is 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") return nil, "", fmt.Errorf("model is required")
} }
protocol, modelID := ExtractProtocol(cfg.Model) protocol, modelID := ExtractProtocol(cfg)
userAgent := cfg.UserAgent userAgent := cfg.UserAgent
if userAgent == "" { if userAgent == "" {
@ -220,9 +246,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "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", "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 // All other OpenAI-compatible HTTP providers
if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)

View file

@ -19,68 +19,103 @@ import (
func TestExtractProtocol(t *testing.T) { func TestExtractProtocol(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
model string config *config.ModelConfig
wantProtocol string wantProtocol string
wantModelID string wantModelID string
}{ }{
{ {
name: "openai with prefix", name: "openai with prefix",
model: "openai/gpt-4o", config: &config.ModelConfig{Model: "openai/gpt-4o"},
wantProtocol: "openai", wantProtocol: "openai",
wantModelID: "gpt-4o", wantModelID: "gpt-4o",
}, },
{ {
name: "anthropic with prefix", name: "anthropic with prefix",
model: "anthropic/claude-sonnet-4.6", config: &config.ModelConfig{Model: "anthropic/claude-sonnet-4.6"},
wantProtocol: "anthropic", wantProtocol: "anthropic",
wantModelID: "claude-sonnet-4.6", wantModelID: "claude-sonnet-4.6",
}, },
{ {
name: "no prefix - defaults to openai", name: "no prefix - defaults to openai",
model: "gpt-4o", config: &config.ModelConfig{Model: "gpt-4o"},
wantProtocol: "openai", wantProtocol: "openai",
wantModelID: "gpt-4o", wantModelID: "gpt-4o",
}, },
{ {
name: "groq with prefix", name: "groq with prefix",
model: "groq/llama-3.1-70b", config: &config.ModelConfig{Model: "groq/llama-3.1-70b"},
wantProtocol: "groq", wantProtocol: "groq",
wantModelID: "llama-3.1-70b", wantModelID: "llama-3.1-70b",
}, },
{ {
name: "empty string", name: "empty string",
model: "", config: &config.ModelConfig{Model: ""},
wantProtocol: "openai", wantProtocol: "",
wantModelID: "", wantModelID: "",
}, },
{ {
name: "with whitespace", name: "with whitespace",
model: " openai/gpt-4 ", config: &config.ModelConfig{Model: " openai/gpt-4 "},
wantProtocol: "openai", wantProtocol: "openai",
wantModelID: "gpt-4", wantModelID: "gpt-4",
}, },
{ {
name: "multiple slashes", name: "multiple slashes",
model: "nvidia/meta/llama-3.1-8b", config: &config.ModelConfig{Model: "nvidia/meta/llama-3.1-8b"},
wantProtocol: "nvidia", wantProtocol: "nvidia",
wantModelID: "meta/llama-3.1-8b", 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", name: "azure with prefix",
model: "azure/my-gpt5-deployment", config: &config.ModelConfig{Model: "azure/my-gpt5-deployment"},
wantProtocol: "azure", wantProtocol: "azure",
wantModelID: "my-gpt5-deployment", 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
protocol, modelID := ExtractProtocol(tt.model) protocol, modelID := ExtractProtocol(tt.config)
if protocol != tt.wantProtocol { 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 { 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) { func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -701,8 +780,9 @@ func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) {
if provider == nil { if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider") t.Fatal("CreateProviderFromConfig() returned nil provider")
} }
if modelID != "qwen-max" { wantModelID := "qwen-max"
t.Errorf("modelID = %q, want %q", modelID, "qwen-max") if modelID != wantModelID {
t.Errorf("modelID = %q, want %q", modelID, wantModelID)
} }
if _, ok := provider.(*HTTPProvider); !ok { if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider) t.Fatalf("expected *HTTPProvider, got %T", provider)
@ -735,8 +815,9 @@ func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) {
if provider == nil { if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider") t.Fatal("CreateProviderFromConfig() returned nil provider")
} }
if modelID != "qwen-max" { wantModelID := "qwen-max"
t.Errorf("modelID = %q, want %q", modelID, "qwen-max") if modelID != wantModelID {
t.Errorf("modelID = %q, want %q", modelID, wantModelID)
} }
if _, ok := provider.(*HTTPProvider); !ok { if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider) t.Fatalf("expected *HTTPProvider, got %T", provider)
@ -769,8 +850,9 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {
if provider == nil { if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider") t.Fatal("CreateProviderFromConfig() returned nil provider")
} }
if modelID != "claude-sonnet-4-20250514" { wantModelID := "claude-sonnet-4-20250514"
t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514") if modelID != wantModelID {
t.Errorf("modelID = %q, want %q", modelID, wantModelID)
} }
// coding-plan-anthropic uses Anthropic Messages provider // coding-plan-anthropic uses Anthropic Messages provider
// Verify it's the anthropic messages provider by checking interface // Verify it's the anthropic messages provider by checking interface

View file

@ -87,7 +87,7 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
apiKey := strings.TrimSpace(m.APIKey()) apiKey := strings.TrimSpace(m.APIKey())
if authMethod == "oauth" || authMethod == "token" { if authMethod == "oauth" || authMethod == "token" {
if provider, ok := oauthProviderForModel(m.Model); ok { if provider, ok := oauthProviderForModel(m); ok {
cred, err := oauthGetCredential(provider) cred, err := oauthGetCredential(provider)
if err != nil || cred == nil { if err != nil || cred == nil {
return false return false
@ -123,7 +123,7 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
return true return true
} }
protocol := modelProtocol(m.Model) protocol := modelProtocol(m)
switch protocol { switch protocol {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": 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 { func runLocalModelProbe(m *config.ModelConfig) bool {
apiBase := modelProbeAPIBase(m) apiBase := modelProbeAPIBase(m)
protocol, modelID := splitModel(m.Model) protocol, modelID := splitModel(m)
switch protocol { switch protocol {
case "ollama": case "ollama":
return probeOllamaModelFunc(apiBase, modelID) return probeOllamaModelFunc(apiBase, modelID)
@ -191,7 +191,7 @@ func runLocalModelProbe(m *config.ModelConfig) bool {
} }
func modelProbeCacheKey(m *config.ModelConfig) string { func modelProbeCacheKey(m *config.ModelConfig) string {
protocol, modelID := splitModel(m.Model) protocol, modelID := splitModel(m)
apiBaseRaw := modelProbeAPIBase(m) apiBaseRaw := modelProbeAPIBase(m)
apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/")) apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/"))
@ -384,7 +384,7 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
return normalizeModelProbeAPIBase(apiBase) return normalizeModelProbeAPIBase(apiBase)
} }
protocol := modelProtocol(m.Model) protocol := modelProtocol(m)
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
return providers.DefaultAPIBaseForProtocol(protocol) return providers.DefaultAPIBaseForProtocol(protocol)
} }
@ -419,8 +419,8 @@ func normalizeModelProbeAPIBase(raw string) string {
return u.String() return u.String()
} }
func oauthProviderForModel(model string) (string, bool) { func oauthProviderForModel(m *config.ModelConfig) (string, bool) {
switch modelProtocol(model) { switch modelProtocol(m) {
case "openai": case "openai":
return oauthProviderOpenAI, true return oauthProviderOpenAI, true
case "anthropic": case "anthropic":
@ -432,18 +432,14 @@ func oauthProviderForModel(model string) (string, bool) {
} }
} }
func modelProtocol(model string) string { func modelProtocol(m *config.ModelConfig) string {
protocol, _ := splitModel(model) protocol, _ := splitModel(m)
return protocol return protocol
} }
func splitModel(model string) (protocol, modelID string) { func splitModel(m *config.ModelConfig) (protocol, modelID string) {
model = strings.ToLower(strings.TrimSpace(model)) protocol, modelID = providers.ExtractProtocol(m)
protocol, _, found := strings.Cut(model, "/") return strings.ToLower(strings.TrimSpace(protocol)), strings.ToLower(strings.TrimSpace(modelID))
if !found {
return "openai", model
}
return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:])
} }
func hasLocalAPIBase(raw string) bool { func hasLocalAPIBase(raw string) bool {

View file

@ -6,10 +6,12 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync" "sync"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
) )
// registerModelRoutes binds model list management endpoints to the ServeMux. // registerModelRoutes binds model list management endpoints to the ServeMux.
@ -26,6 +28,7 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
type modelResponse struct { type modelResponse struct {
Index int `json:"index"` Index int `json:"index"`
ModelName string `json:"model_name"` ModelName string `json:"model_name"`
Provider string `json:"provider,omitempty"`
Model string `json:"model"` Model string `json:"model"`
APIBase string `json:"api_base,omitempty"` APIBase string `json:"api_base,omitempty"`
APIKey string `json:"api_key"` 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)) models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList { for i, m := range cfg.ModelList {
provider, modelID := providers.ExtractProtocol(m)
models = append(models, modelResponse{ models = append(models, modelResponse{
Index: i, Index: i,
ModelName: m.ModelName, ModelName: m.ModelName,
Model: m.Model, Provider: provider,
Model: modelID,
APIBase: m.APIBase, APIBase: m.APIBase,
APIKey: maskAPIKey(m.APIKey()), APIKey: maskAPIKey(m.APIKey()),
Proxy: m.Proxy, Proxy: m.Proxy,
@ -176,6 +181,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} }
defer r.Body.Close() 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 { type custom struct {
config.ModelConfig config.ModelConfig
APIKey string `json:"api_key"` 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 { } else if len(mc.CustomHeaders) == 0 {
mc.CustomHeaders = nil 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 cfg.ModelList[idx] = &mc.ModelConfig

View file

@ -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) { func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() 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) { func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() 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 // TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent
// model as default returns 404. This covers the case where virtual models (which are // model as default returns 404. This covers the case where virtual models (which are
// filtered by SaveConfig) cannot be set as default. // filtered by SaveConfig) cannot be set as default.

View file

@ -746,7 +746,7 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
found := false found := false
for i := range cfg.ModelList { for i := range cfg.ModelList {
if modelBelongsToProvider(provider, cfg.ModelList[i].Model) { if modelBelongsToProvider(provider, cfg.ModelList[i]) {
cfg.ModelList[i].AuthMethod = authMethod cfg.ModelList[i].AuthMethod = authMethod
found = true found = true
} }
@ -759,18 +759,15 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
return oauthSaveConfig(h.configPath, cfg) return oauthSaveConfig(h.configPath, cfg)
} }
func modelBelongsToProvider(provider, model string) bool { func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool {
lower := strings.ToLower(strings.TrimSpace(model)) protocol, _ := providers.ExtractProtocol(modelCfg)
switch provider { switch provider {
case oauthProviderOpenAI: case oauthProviderOpenAI:
return lower == "openai" || strings.HasPrefix(lower, "openai/") return protocol == "openai"
case oauthProviderAnthropic: case oauthProviderAnthropic:
return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/") return protocol == "anthropic"
case oauthProviderGoogleAntigravity: case oauthProviderGoogleAntigravity:
return lower == "antigravity" || return protocol == "antigravity" || protocol == "google-antigravity"
lower == "google-antigravity" ||
strings.HasPrefix(lower, "antigravity/") ||
strings.HasPrefix(lower, "google-antigravity/")
default: default:
return false return false
} }
@ -781,19 +778,22 @@ func defaultModelConfigForProvider(provider, authMethod string) *config.ModelCon
case oauthProviderOpenAI: case oauthProviderOpenAI:
return &config.ModelConfig{ return &config.ModelConfig{
ModelName: "gpt-5.4", ModelName: "gpt-5.4",
Model: "openai/gpt-5.4", Provider: "openai",
Model: "gpt-5.4",
AuthMethod: authMethod, AuthMethod: authMethod,
} }
case oauthProviderAnthropic: case oauthProviderAnthropic:
return &config.ModelConfig{ return &config.ModelConfig{
ModelName: "claude-sonnet-4.6", ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6", Provider: "anthropic",
Model: "claude-sonnet-4.6",
AuthMethod: authMethod, AuthMethod: authMethod,
} }
case oauthProviderGoogleAntigravity: case oauthProviderGoogleAntigravity:
return &config.ModelConfig{ return &config.ModelConfig{
ModelName: "gemini-flash", ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash", Provider: "antigravity",
Model: "gemini-3-flash",
AuthMethod: authMethod, AuthMethod: authMethod,
} }
default: default:

View file

@ -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()) { func setupOAuthTestEnv(t *testing.T) (string, func()) {
t.Helper() t.Helper()

View file

@ -6,6 +6,7 @@ import { refreshGatewayState } from "@/store/gateway"
export interface ModelInfo { export interface ModelInfo {
index: number index: number
model_name: string model_name: string
provider?: string
model: string model: string
api_base?: string api_base?: string
api_key: string api_key: string

View file

@ -24,6 +24,7 @@ import { Textarea } from "@/components/ui/textarea"
interface AddForm { interface AddForm {
modelName: string modelName: string
provider: string
model: string model: string
apiBase: string apiBase: string
apiKey: string apiKey: string
@ -41,6 +42,7 @@ interface AddForm {
const EMPTY_ADD_FORM: AddForm = { const EMPTY_ADD_FORM: AddForm = {
modelName: "", modelName: "",
provider: "",
model: "", model: "",
apiBase: "", apiBase: "",
apiKey: "", apiKey: "",
@ -119,9 +121,11 @@ export function AddModelSheet({
setServerError("") setServerError("")
try { try {
const modelName = form.modelName.trim() const modelName = form.modelName.trim()
const provider = form.provider.trim()
const modelId = form.model.trim() const modelId = form.model.trim()
await addModel({ await addModel({
model_name: modelName, model_name: modelName,
provider: provider || undefined,
model: modelId, model: modelId,
api_base: form.apiBase.trim() || undefined, api_base: form.apiBase.trim() || undefined,
api_key: form.apiKey.trim() || undefined, api_key: form.apiKey.trim() || undefined,
@ -186,6 +190,17 @@ export function AddModelSheet({
)} )}
</Field> </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 <Field
label={t("models.add.modelId")} label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")} hint={t("models.add.modelIdHint")}

View file

@ -23,6 +23,8 @@ import {
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
interface EditForm { interface EditForm {
provider: string
modelId: string
apiKey: string apiKey: string
apiBase: string apiBase: string
proxy: string proxy: string
@ -52,6 +54,8 @@ export function EditModelSheet({
}: EditModelSheetProps) { }: EditModelSheetProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [form, setForm] = useState<EditForm>({ const [form, setForm] = useState<EditForm>({
provider: "",
modelId: "",
apiKey: "", apiKey: "",
apiBase: "", apiBase: "",
proxy: "", proxy: "",
@ -72,6 +76,8 @@ export function EditModelSheet({
useEffect(() => { useEffect(() => {
if (model) { if (model) {
setForm({ setForm({
provider: model.provider ?? "",
modelId: model.model,
apiKey: "", apiKey: "",
apiBase: model.api_base ?? "", apiBase: model.api_base ?? "",
proxy: model.proxy ?? "", proxy: model.proxy ?? "",
@ -103,12 +109,17 @@ export function EditModelSheet({
const handleSave = async () => { const handleSave = async () => {
if (!model) return if (!model) return
if (!form.modelId.trim()) {
setError(t("models.add.errorRequired"))
return
}
setSaving(true) setSaving(true)
setError("") setError("")
try { try {
await updateModel(model.index, { await updateModel(model.index, {
model_name: model.model_name, model_name: model.model_name,
model: model.model, provider: form.provider.trim(),
model: form.modelId.trim(),
api_base: form.apiBase || undefined, api_base: form.apiBase || undefined,
api_key: form.apiKey || undefined, api_key: form.apiKey || undefined,
proxy: form.proxy || undefined, proxy: form.proxy || undefined,
@ -166,6 +177,29 @@ export function EditModelSheet({
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 px-6 py-5"> <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 && ( {!isOAuth && (
<Field <Field
label={t("models.field.apiKey")} label={t("models.field.apiKey")}

View file

@ -20,19 +20,28 @@ const PROVIDER_PRIORITY: Record<string, number> = {
zhipu: 4, zhipu: 4,
deepseek: 5, deepseek: 5,
openrouter: 6, openrouter: 6,
qwen: 7, "qwen-portal": 7,
moonshot: 8, "qwen-intl": 8,
groq: 9, moonshot: 9,
"github-copilot": 10, groq: 10,
antigravity: 11, "github-copilot": 11,
nvidia: 12, antigravity: 12,
cerebras: 13, nvidia: 13,
shengsuanyun: 14, cerebras: 14,
ollama: 15, shengsuanyun: 15,
vllm: 16, venice: 16,
mistral: 17, vivgrid: 17,
avian: 18, minimax: 18,
mimo: 19, longcat: 19,
modelscope: 20,
mistral: 21,
avian: 22,
azure: 23,
ollama: 24,
vllm: 25,
lmstudio: 26,
zai: 27,
mimo: 28,
} }
interface ProviderGroup { interface ProviderGroup {
@ -95,10 +104,10 @@ export function ModelsPage() {
const grouped: Record<string, { label: string; models: ModelInfo[] }> = {} const grouped: Record<string, { label: string; models: ModelInfo[] }> = {}
for (const model of models) { for (const model of models) {
const providerKey = getProviderKey(model.model) const providerKey = getProviderKey(model.provider)
if (!grouped[providerKey]) { if (!grouped[providerKey]) {
grouped[providerKey] = { grouped[providerKey] = {
label: getProviderLabel(model.model), label: getProviderLabel(model.provider),
models: [], models: [],
} }
} }

View file

@ -3,9 +3,11 @@ import { useMemo, useState } from "react"
const PROVIDER_ICON_SLUGS: Record<string, string> = { const PROVIDER_ICON_SLUGS: Record<string, string> = {
openai: "openai", openai: "openai",
anthropic: "anthropic", anthropic: "anthropic",
azure: "microsoftazure",
gemini: "googlegemini", gemini: "googlegemini",
deepseek: "deepseek", deepseek: "deepseek",
qwen: "alibabacloud", "qwen-portal": "alibabacloud",
"qwen-intl": "alibabacloud",
groq: "groq", groq: "groq",
openrouter: "openrouter", openrouter: "openrouter",
nvidia: "nvidia", nvidia: "nvidia",
@ -20,9 +22,11 @@ const PROVIDER_ICON_SLUGS: Record<string, string> = {
const PROVIDER_DOMAINS: Record<string, string> = { const PROVIDER_DOMAINS: Record<string, string> = {
openai: "openai.com", openai: "openai.com",
anthropic: "anthropic.com", anthropic: "anthropic.com",
azure: "azure.com",
gemini: "gemini.google.com", gemini: "gemini.google.com",
deepseek: "deepseek.com", deepseek: "deepseek.com",
qwen: "qwenlm.ai", "qwen-portal": "qwenlm.ai",
"qwen-intl": "alibabacloud.com",
moonshot: "moonshot.ai", moonshot: "moonshot.ai",
groq: "groq.com", groq: "groq.com",
openrouter: "openrouter.ai", openrouter: "openrouter.ai",
@ -33,11 +37,18 @@ const PROVIDER_DOMAINS: Record<string, string> = {
antigravity: "antigravity.google", antigravity: "antigravity.google",
"github-copilot": "github.com", "github-copilot": "github.com",
ollama: "ollama.com", ollama: "ollama.com",
lmstudio: "lmstudio.ai",
mistral: "mistral.ai", mistral: "mistral.ai",
avian: "avian.io", avian: "avian.io",
vllm: "vllm.ai", vllm: "vllm.ai",
zhipu: "zhipuai.cn", zhipu: "zhipuai.cn",
zai: "z.ai",
mimo: "xiaomi.com", mimo: "xiaomi.com",
venice: "venice.ai",
vivgrid: "vivgrid.com",
minimax: "minimaxi.com",
longcat: "longcat.chat",
modelscope: "modelscope.cn",
} }
interface ProviderIconProps { interface ProviderIconProps {

View file

@ -1,9 +1,11 @@
const PROVIDER_LABELS: Record<string, string> = { const PROVIDER_LABELS: Record<string, string> = {
openai: "OpenAI", openai: "OpenAI",
anthropic: "Anthropic", anthropic: "Anthropic",
azure: "Azure OpenAI",
gemini: "Google Gemini", gemini: "Google Gemini",
deepseek: "DeepSeek", deepseek: "DeepSeek",
qwen: "Qwen (阿里云)", "qwen-portal": "Qwen (阿里云)",
"qwen-intl": "Qwen International",
moonshot: "Moonshot (月之暗面)", moonshot: "Moonshot (月之暗面)",
groq: "Groq", groq: "Groq",
openrouter: "OpenRouter", openrouter: "OpenRouter",
@ -14,21 +16,37 @@ const PROVIDER_LABELS: Record<string, string> = {
antigravity: "Google Code Assist", antigravity: "Google Code Assist",
"github-copilot": "GitHub Copilot", "github-copilot": "GitHub Copilot",
ollama: "Ollama (local)", ollama: "Ollama (local)",
lmstudio: "LM Studio (local)",
mistral: "Mistral AI", mistral: "Mistral AI",
avian: "Avian", avian: "Avian",
vllm: "VLLM (local)", vllm: "VLLM (local)",
zhipu: "Zhipu AI (智谱)", zhipu: "Zhipu AI (智谱)",
zai: "Z.ai",
mimo: "Xiaomi MiMo", mimo: "Xiaomi MiMo",
venice: "Venice AI",
vivgrid: "Vivgrid",
minimax: "MiniMax",
longcat: "LongCat",
modelscope: "ModelScope (魔搭社区)",
} }
export function getProviderKey(model: string): string { const PROVIDER_ALIASES: Record<string, string> = {
return model.split("/")[0] 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 { export function getProviderKey(provider?: string): string {
const prefix = getProviderKey(model) const normalized = provider?.trim().toLowerCase()
const labels: Record<string, string> = { if (!normalized) return "openai"
...PROVIDER_LABELS, return PROVIDER_ALIASES[normalized] ?? normalized
} }
return labels[prefix] ?? prefix
export function getProviderLabel(provider?: string): string {
const prefix = getProviderKey(provider)
return PROVIDER_LABELS[prefix] ?? prefix
} }

View file

@ -244,8 +244,8 @@
"modelNamePlaceholder": "e.g. my-gpt4", "modelNamePlaceholder": "e.g. my-gpt4",
"modelNameHint": "A short name used to identify this model in conversations.", "modelNameHint": "A short name used to identify this model in conversations.",
"modelId": "Model Identifier", "modelId": "Model Identifier",
"modelIdPlaceholder": "e.g. openai/gpt-4o", "modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
"modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …", "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.", "errorRequired": "This field is required.",
"errorDuplicateModelName": "Model alias already exists. Please use a different name.", "errorDuplicateModelName": "Model alias already exists. Please use a different name.",
"saveError": "Failed to add model", "saveError": "Failed to add model",
@ -260,6 +260,9 @@
"toggle": "Advanced options" "toggle": "Advanced options"
}, },
"field": { "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", "apiBase": "API Base URL",
"apiKey": "API Key", "apiKey": "API Key",
"apiKeyPlaceholder": "Enter your API key", "apiKeyPlaceholder": "Enter your API key",

View file

@ -244,8 +244,8 @@
"modelNamePlaceholder": "例如 my-gpt4", "modelNamePlaceholder": "例如 my-gpt4",
"modelNameHint": "用于在对话中识别此模型的简短名称。", "modelNameHint": "用于在对话中识别此模型的简短名称。",
"modelId": "模型标识符", "modelId": "模型标识符",
"modelIdPlaceholder": "例如 openai/gpt-4o", "modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o",
"modelIdHint": "格式:协议/模型ID。支持openai、anthropic、gemini、groq 等。", "modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。",
"errorRequired": "此字段为必填项。", "errorRequired": "此字段为必填项。",
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。", "errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
"saveError": "添加模型失败", "saveError": "添加模型失败",
@ -260,6 +260,9 @@
"toggle": "高级选项" "toggle": "高级选项"
}, },
"field": { "field": {
"provider": "Provider",
"providerPlaceholder": "例如 openai",
"providerHint": "可选。指定后,将以该值作为最终 provider并将“模型标识符”字段解释为规范模型 ID。",
"apiBase": "API Base URL", "apiBase": "API Base URL",
"apiKey": "API Key", "apiKey": "API Key",
"apiKeyPlaceholder": "请输入 API Key", "apiKeyPlaceholder": "请输入 API Key",