From 8deb0a251a2d1dc2d7bf428a3376ad6298baf6ad Mon Sep 17 00:00:00 2001 From: lxowalle Date: Tue, 21 Apr 2026 16:30:48 +0800 Subject: [PATCH] refactor: support explicit model list providers --- 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 | 11 ++ pkg/agent/model_resolution.go | 10 +- 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 | 55 +++++-- pkg/providers/factory_provider_test.go | 87 ++++++++--- 29 files changed, 764 insertions(+), 381 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 bb58d5081..fc7c45fc7 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -493,7 +493,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: @@ -546,7 +546,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 } ], @@ -570,31 +571,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` | OpenAI | [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` | OpenAI | [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 @@ -603,22 +604,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"] } ], @@ -634,6 +639,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). @@ -644,7 +656,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 } ``` @@ -657,7 +670,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 } ``` @@ -670,7 +684,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 } ``` @@ -683,7 +698,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 } ``` @@ -696,7 +712,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 } ``` @@ -708,7 +725,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" } @@ -724,7 +742,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "llama3", - "model": "ollama/llama3" + "provider": "ollama", + "model": "llama3" } ``` @@ -736,12 +755,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. @@ -751,13 +771,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. @@ -782,7 +803,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 } @@ -797,13 +819,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"] } @@ -862,7 +886,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 ecaef6eb7..c5956d6b8 100644 --- a/docs/guides/configuration.zh.md +++ b/docs/guides/configuration.zh.md @@ -424,7 +424,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**场景,灵活选择提供商: @@ -435,31 +435,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` | OpenAI | [获取](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` | OpenAI | [获取](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 | — | #### 基础配置 @@ -468,22 +468,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"] } ], @@ -495,6 +499,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。 + #### 各厂商配置示例
@@ -503,7 +514,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-..."] } ``` @@ -516,7 +528,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-..."] } ``` @@ -529,7 +542,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"] } ``` @@ -542,7 +556,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", "api_keys": ["sk-..."] } ``` @@ -555,7 +570,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"] } ``` @@ -567,7 +583,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" } @@ -583,7 +600,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "llama3", - "model": "ollama/llama3" + "provider": "ollama", + "model": "llama3" } ``` @@ -595,12 +613,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。
@@ -610,13 +629,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` 时也会得到相同结果。 @@ -629,13 +649,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"] } @@ -689,7 +711,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 3ccc7a2a7..97299bab0 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 521747d16..f5d483af4 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 41f3caae0..2d15114b7 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` | OpenAI | [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` | OpenAI | [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"] } ], @@ -464,7 +504,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 1f1031043..421b23030 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` | OpenAI | [获取密钥](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` | OpenAI | [获取密钥](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"] } ], @@ -435,7 +473,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 5cdac186c..61a292cb7 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..790ab16ca 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, }, }, diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index 7cbf3a8d6..1c23cee50 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 @@ -72,7 +72,7 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig if fullModel == raw { return mc } - _, modelID := providers.ExtractProtocol(fullModel) + _, modelID := providers.ExtractProtocol(mc) if modelID == raw { return mc } 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 d9ca0cb9d..85a98402e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1933,7 +1933,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..2f956c7a0 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,42 @@ 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") +// - 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 +133,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 +152,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 +245,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..62b838acb 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -19,68 +19,91 @@ 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: "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 +129,29 @@ 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_DefaultAPIBase(t *testing.T) { tests := []struct { name string @@ -701,8 +747,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 +782,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 +817,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