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