From 2cd1267f2a74f2f791a5329a082127194906528b Mon Sep 17 00:00:00 2001 From: lxowalle Date: Tue, 21 Apr 2026 17:37:50 +0800 Subject: [PATCH] fix(web): preserve explicit model providers --- web/backend/api/model_status.go | 28 +- web/backend/api/models.go | 18 +- web/backend/api/models_test.go | 278 ++++++++++++++++++ web/backend/api/oauth.go | 24 +- web/backend/api/oauth_test.go | 48 +++ web/frontend/src/api/models.ts | 1 + .../src/components/models/add-model-sheet.tsx | 15 + .../components/models/edit-model-sheet.tsx | 36 ++- .../src/components/models/models-page.tsx | 39 ++- .../src/components/models/provider-icon.tsx | 15 +- .../src/components/models/provider-label.ts | 36 ++- web/frontend/src/i18n/locales/en.json | 7 +- web/frontend/src/i18n/locales/zh.json | 7 +- 13 files changed, 492 insertions(+), 60 deletions(-) diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 98bd501f5..d262cf124 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -87,7 +87,7 @@ func hasModelConfiguration(m *config.ModelConfig) bool { apiKey := strings.TrimSpace(m.APIKey()) if authMethod == "oauth" || authMethod == "token" { - if provider, ok := oauthProviderForModel(m.Model); ok { + if provider, ok := oauthProviderForModel(m); ok { cred, err := oauthGetCredential(provider) if err != nil || cred == nil { return false @@ -123,7 +123,7 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool { return true } - protocol := modelProtocol(m.Model) + protocol := modelProtocol(m) switch protocol { case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": @@ -172,7 +172,7 @@ func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) boo func runLocalModelProbe(m *config.ModelConfig) bool { apiBase := modelProbeAPIBase(m) - protocol, modelID := splitModel(m.Model) + protocol, modelID := splitModel(m) switch protocol { case "ollama": return probeOllamaModelFunc(apiBase, modelID) @@ -191,7 +191,7 @@ func runLocalModelProbe(m *config.ModelConfig) bool { } func modelProbeCacheKey(m *config.ModelConfig) string { - protocol, modelID := splitModel(m.Model) + protocol, modelID := splitModel(m) apiBaseRaw := modelProbeAPIBase(m) apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/")) @@ -384,7 +384,7 @@ func modelProbeAPIBase(m *config.ModelConfig) string { return normalizeModelProbeAPIBase(apiBase) } - protocol := modelProtocol(m.Model) + protocol := modelProtocol(m) if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { return providers.DefaultAPIBaseForProtocol(protocol) } @@ -419,8 +419,8 @@ func normalizeModelProbeAPIBase(raw string) string { return u.String() } -func oauthProviderForModel(model string) (string, bool) { - switch modelProtocol(model) { +func oauthProviderForModel(m *config.ModelConfig) (string, bool) { + switch modelProtocol(m) { case "openai": return oauthProviderOpenAI, true case "anthropic": @@ -432,18 +432,14 @@ func oauthProviderForModel(model string) (string, bool) { } } -func modelProtocol(model string) string { - protocol, _ := splitModel(model) +func modelProtocol(m *config.ModelConfig) string { + protocol, _ := splitModel(m) return protocol } -func splitModel(model string) (protocol, modelID string) { - model = strings.ToLower(strings.TrimSpace(model)) - protocol, _, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:]) +func splitModel(m *config.ModelConfig) (protocol, modelID string) { + protocol, modelID = providers.ExtractProtocol(m) + return strings.ToLower(strings.TrimSpace(protocol)), strings.ToLower(strings.TrimSpace(modelID)) } func hasLocalAPIBase(raw string) bool { diff --git a/web/backend/api/models.go b/web/backend/api/models.go index aa4a775eb..e2ff19952 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -10,6 +10,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" ) // registerModelRoutes binds model list management endpoints to the ServeMux. @@ -26,6 +27,7 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) { type modelResponse struct { Index int `json:"index"` ModelName string `json:"model_name"` + Provider string `json:"provider,omitempty"` Model string `json:"model"` APIBase string `json:"api_base,omitempty"` APIKey string `json:"api_key"` @@ -73,10 +75,12 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { models := make([]modelResponse, 0, len(cfg.ModelList)) for i, m := range cfg.ModelList { + provider, modelID := providers.ExtractProtocol(m) models = append(models, modelResponse{ Index: i, ModelName: m.ModelName, - Model: m.Model, + Provider: provider, + Model: modelID, APIBase: m.APIBase, APIKey: maskAPIKey(m.APIKey()), Proxy: m.Proxy, @@ -176,6 +180,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() + var rawFields map[string]json.RawMessage + if err = json.Unmarshal(body, &rawFields); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + type custom struct { config.ModelConfig APIKey string `json:"api_key"` @@ -226,6 +236,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } else if len(mc.CustomHeaders) == 0 { mc.CustomHeaders = nil } + // Preserve the existing Provider when the caller omits it. This keeps the + // update API backward-compatible for clients that haven't started sending + // the new field yet, while still allowing explicit clearing via "". + if _, ok := rawFields["provider"]; !ok { + mc.Provider = cfg.ModelList[idx].Provider + } cfg.ModelList[idx] = &mc.ModelConfig diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index e4297f679..5bb9769ce 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -392,6 +392,49 @@ func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) { } } +func TestHandleListModels_RuntimeProbeUsesExplicitProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + var gotProbe string + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + gotProbe = apiBase + "|" + modelID + "|" + apiKey + return true + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "vllm-local", + Provider: "vllm", + Model: "custom-model", + APIBase: "http://127.0.0.1:8000/v1", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" { + t.Fatalf("probe = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|") + } +} + func TestHandleAddModel_PersistsAPIKey(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -430,6 +473,41 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) { } } +func TestHandleAddModel_PersistsProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"nvidia-glm", + "provider":"nvidia", + "model":"z-ai/glm-5.1", + "api_key":"nv-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if added.Provider != "nvidia" { + t.Fatalf("provider = %q, want %q", added.Provider, "nvidia") + } + if added.Model != "z-ai/glm-5.1" { + t.Fatalf("model = %q, want %q", added.Model, "z-ai/glm-5.1") + } +} + func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -536,6 +614,206 @@ func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) { } } +func TestHandleUpdateModel_PersistsProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "editable", + Model: "gpt-4o", + Provider: "openai", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "provider":"openrouter", + "model":"openai/gpt-4o" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } +} + +func TestHandleUpdateModel_PreservesProviderWhenOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "editable", + Model: "z-ai/glm-5.1", + Provider: "nvidia", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "model":"z-ai/glm-5.1" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "nvidia" { + t.Fatalf("provider = %q, want %q", got, "nvidia") + } +} + +func TestHandleListModels_ReturnsProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "nvidia-glm", + Provider: "nvidia", + Model: "z-ai/glm-5.1", + APIKeys: config.SimpleSecureStrings("nv-key"), + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "nvidia" { + t.Fatalf("provider = %q, want %q", got, "nvidia") + } +} + +func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "plain-openai", + Model: "gpt-4o", + }, + { + ModelName: "explicit-google", + Provider: "google", + Model: "gemini-2.5-pro", + }, + { + ModelName: "explicit-qwen-intl", + Provider: "qwen-international", + Model: "qwen3-coder-plus", + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Models) != 3 { + t.Fatalf("len(models) = %d, want 3", len(resp.Models)) + } + + if got := resp.Models[0].Provider; got != "openai" { + t.Fatalf("provider[0] = %q, want %q", got, "openai") + } + if got := resp.Models[0].Model; got != "gpt-4o" { + t.Fatalf("model[0] = %q, want %q", got, "gpt-4o") + } + if got := resp.Models[1].Provider; got != "gemini" { + t.Fatalf("provider[1] = %q, want %q", got, "gemini") + } + if got := resp.Models[1].Model; got != "gemini-2.5-pro" { + t.Fatalf("model[1] = %q, want %q", got, "gemini-2.5-pro") + } + if got := resp.Models[2].Provider; got != "qwen-intl" { + t.Fatalf("provider[2] = %q, want %q", got, "qwen-intl") + } + if got := resp.Models[2].Model; got != "qwen3-coder-plus" { + t.Fatalf("model[2] = %q, want %q", got, "qwen3-coder-plus") + } +} + // TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent // model as default returns 404. This covers the case where virtual models (which are // filtered by SaveConfig) cannot be set as default. diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go index 213b53836..116e304b1 100644 --- a/web/backend/api/oauth.go +++ b/web/backend/api/oauth.go @@ -746,7 +746,7 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { found := false for i := range cfg.ModelList { - if modelBelongsToProvider(provider, cfg.ModelList[i].Model) { + if modelBelongsToProvider(provider, cfg.ModelList[i]) { cfg.ModelList[i].AuthMethod = authMethod found = true } @@ -759,18 +759,15 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { return oauthSaveConfig(h.configPath, cfg) } -func modelBelongsToProvider(provider, model string) bool { - lower := strings.ToLower(strings.TrimSpace(model)) +func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) switch provider { case oauthProviderOpenAI: - return lower == "openai" || strings.HasPrefix(lower, "openai/") + return protocol == "openai" case oauthProviderAnthropic: - return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/") + return protocol == "anthropic" case oauthProviderGoogleAntigravity: - return lower == "antigravity" || - lower == "google-antigravity" || - strings.HasPrefix(lower, "antigravity/") || - strings.HasPrefix(lower, "google-antigravity/") + return protocol == "antigravity" || protocol == "google-antigravity" default: return false } @@ -781,19 +778,22 @@ func defaultModelConfigForProvider(provider, authMethod string) *config.ModelCon case oauthProviderOpenAI: return &config.ModelConfig{ ModelName: "gpt-5.4", - Model: "openai/gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", AuthMethod: authMethod, } case oauthProviderAnthropic: return &config.ModelConfig{ ModelName: "claude-sonnet-4.6", - Model: "anthropic/claude-sonnet-4.6", + Provider: "anthropic", + Model: "claude-sonnet-4.6", AuthMethod: authMethod, } case oauthProviderGoogleAntigravity: return &config.ModelConfig{ ModelName: "gemini-flash", - Model: "antigravity/gemini-3-flash", + Provider: "antigravity", + Model: "gemini-3-flash", AuthMethod: authMethod, } default: diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go index 5aaff8d8f..9468c8873 100644 --- a/web/backend/api/oauth_test.go +++ b/web/backend/api/oauth_test.go @@ -214,6 +214,54 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) { } } +func TestOAuthLogoutClearsAuthMethodForExplicitProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ + ModelName: "gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", + AuthMethod: "oauth", + }) + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ + AccessToken: "token-before-logout", + Provider: oauthProviderOpenAI, + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential error: %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + if got := updated.ModelList[len(updated.ModelList)-1].AuthMethod; got != "" { + t.Fatalf("auth_method = %q, want empty", got) + } +} + func setupOAuthTestEnv(t *testing.T) (string, func()) { t.Helper() diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index bfdd80d6d..d2d2dca88 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -6,6 +6,7 @@ import { refreshGatewayState } from "@/store/gateway" export interface ModelInfo { index: number model_name: string + provider?: string model: string api_base?: string api_key: string diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index dfbcd4b13..a9102aa8a 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -24,6 +24,7 @@ import { Textarea } from "@/components/ui/textarea" interface AddForm { modelName: string + provider: string model: string apiBase: string apiKey: string @@ -41,6 +42,7 @@ interface AddForm { const EMPTY_ADD_FORM: AddForm = { modelName: "", + provider: "", model: "", apiBase: "", apiKey: "", @@ -119,9 +121,11 @@ export function AddModelSheet({ setServerError("") try { const modelName = form.modelName.trim() + const provider = form.provider.trim() const modelId = form.model.trim() await addModel({ model_name: modelName, + provider: provider || undefined, model: modelId, api_base: form.apiBase.trim() || undefined, api_key: form.apiKey.trim() || undefined, @@ -186,6 +190,17 @@ export function AddModelSheet({ )} + + + + ({ + provider: "", + modelId: "", apiKey: "", apiBase: "", proxy: "", @@ -72,6 +76,8 @@ export function EditModelSheet({ useEffect(() => { if (model) { setForm({ + provider: model.provider ?? "", + modelId: model.model, apiKey: "", apiBase: model.api_base ?? "", proxy: model.proxy ?? "", @@ -103,12 +109,17 @@ export function EditModelSheet({ const handleSave = async () => { if (!model) return + if (!form.modelId.trim()) { + setError(t("models.add.errorRequired")) + return + } setSaving(true) setError("") try { await updateModel(model.index, { model_name: model.model_name, - model: model.model, + provider: form.provider.trim() || undefined, + model: form.modelId.trim(), api_base: form.apiBase || undefined, api_key: form.apiKey || undefined, proxy: form.proxy || undefined, @@ -166,6 +177,29 @@ export function EditModelSheet({
+ + + + + + + + {!isOAuth && ( = { zhipu: 4, deepseek: 5, openrouter: 6, - qwen: 7, - moonshot: 8, - groq: 9, - "github-copilot": 10, - antigravity: 11, - nvidia: 12, - cerebras: 13, - shengsuanyun: 14, - ollama: 15, - vllm: 16, - mistral: 17, - avian: 18, - mimo: 19, + "qwen-portal": 7, + "qwen-intl": 8, + moonshot: 9, + groq: 10, + "github-copilot": 11, + antigravity: 12, + nvidia: 13, + cerebras: 14, + shengsuanyun: 15, + venice: 16, + vivgrid: 17, + minimax: 18, + longcat: 19, + modelscope: 20, + mistral: 21, + avian: 22, + azure: 23, + ollama: 24, + vllm: 25, + lmstudio: 26, + zai: 27, + mimo: 28, } interface ProviderGroup { @@ -95,10 +104,10 @@ export function ModelsPage() { const grouped: Record = {} for (const model of models) { - const providerKey = getProviderKey(model.model) + const providerKey = getProviderKey(model.provider) if (!grouped[providerKey]) { grouped[providerKey] = { - label: getProviderLabel(model.model), + label: getProviderLabel(model.provider), models: [], } } diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx index 814a59834..8d1cfe2c9 100644 --- a/web/frontend/src/components/models/provider-icon.tsx +++ b/web/frontend/src/components/models/provider-icon.tsx @@ -3,9 +3,11 @@ import { useMemo, useState } from "react" const PROVIDER_ICON_SLUGS: Record = { openai: "openai", anthropic: "anthropic", + azure: "microsoftazure", gemini: "googlegemini", deepseek: "deepseek", - qwen: "alibabacloud", + "qwen-portal": "alibabacloud", + "qwen-intl": "alibabacloud", groq: "groq", openrouter: "openrouter", nvidia: "nvidia", @@ -20,9 +22,11 @@ const PROVIDER_ICON_SLUGS: Record = { const PROVIDER_DOMAINS: Record = { openai: "openai.com", anthropic: "anthropic.com", + azure: "azure.com", gemini: "gemini.google.com", deepseek: "deepseek.com", - qwen: "qwenlm.ai", + "qwen-portal": "qwenlm.ai", + "qwen-intl": "alibabacloud.com", moonshot: "moonshot.ai", groq: "groq.com", openrouter: "openrouter.ai", @@ -33,11 +37,18 @@ const PROVIDER_DOMAINS: Record = { antigravity: "antigravity.google", "github-copilot": "github.com", ollama: "ollama.com", + lmstudio: "lmstudio.ai", mistral: "mistral.ai", avian: "avian.io", vllm: "vllm.ai", zhipu: "zhipuai.cn", + zai: "z.ai", mimo: "xiaomi.com", + venice: "venice.ai", + vivgrid: "vivgrid.com", + minimax: "minimaxi.com", + longcat: "longcat.chat", + modelscope: "modelscope.cn", } interface ProviderIconProps { diff --git a/web/frontend/src/components/models/provider-label.ts b/web/frontend/src/components/models/provider-label.ts index 82600a96f..123640fe5 100644 --- a/web/frontend/src/components/models/provider-label.ts +++ b/web/frontend/src/components/models/provider-label.ts @@ -1,9 +1,11 @@ const PROVIDER_LABELS: Record = { openai: "OpenAI", anthropic: "Anthropic", + azure: "Azure OpenAI", gemini: "Google Gemini", deepseek: "DeepSeek", - qwen: "Qwen (阿里云)", + "qwen-portal": "Qwen (阿里云)", + "qwen-intl": "Qwen International", moonshot: "Moonshot (月之暗面)", groq: "Groq", openrouter: "OpenRouter", @@ -14,21 +16,37 @@ const PROVIDER_LABELS: Record = { antigravity: "Google Code Assist", "github-copilot": "GitHub Copilot", ollama: "Ollama (local)", + lmstudio: "LM Studio (local)", mistral: "Mistral AI", avian: "Avian", vllm: "VLLM (local)", zhipu: "Zhipu AI (智谱)", + zai: "Z.ai", mimo: "Xiaomi MiMo", + venice: "Venice AI", + vivgrid: "Vivgrid", + minimax: "MiniMax", + longcat: "LongCat", + modelscope: "ModelScope (魔搭社区)", } -export function getProviderKey(model: string): string { - return model.split("/")[0] +const PROVIDER_ALIASES: Record = { + qwen: "qwen-portal", + "qwen-international": "qwen-intl", + "dashscope-intl": "qwen-intl", + "z.ai": "zai", + "z-ai": "zai", + google: "gemini", + "google-antigravity": "antigravity", } -export function getProviderLabel(model: string): string { - const prefix = getProviderKey(model) - const labels: Record = { - ...PROVIDER_LABELS, - } - return labels[prefix] ?? prefix +export function getProviderKey(provider?: string): string { + const normalized = provider?.trim().toLowerCase() + if (!normalized) return "openai" + return PROVIDER_ALIASES[normalized] ?? normalized +} + +export function getProviderLabel(provider?: string): string { + const prefix = getProviderKey(provider) + return PROVIDER_LABELS[prefix] ?? prefix } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index c96d4b71b..11cf409a6 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -239,8 +239,8 @@ "modelNamePlaceholder": "e.g. my-gpt4", "modelNameHint": "A short name used to identify this model in conversations.", "modelId": "Model Identifier", - "modelIdPlaceholder": "e.g. openai/gpt-4o", - "modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …", + "modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o", + "modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.", "errorRequired": "This field is required.", "errorDuplicateModelName": "Model alias already exists. Please use a different name.", "saveError": "Failed to add model", @@ -255,6 +255,9 @@ "toggle": "Advanced options" }, "field": { + "provider": "Provider", + "providerPlaceholder": "e.g. openai", + "providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "Enter your API key", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 4a9e59cf4..824cf2b3a 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -239,8 +239,8 @@ "modelNamePlaceholder": "例如 my-gpt4", "modelNameHint": "用于在对话中识别此模型的简短名称。", "modelId": "模型标识符", - "modelIdPlaceholder": "例如 openai/gpt-4o", - "modelIdHint": "格式:协议/模型ID。支持:openai、anthropic、gemini、groq 等。", + "modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o", + "modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。", "errorRequired": "此字段为必填项。", "errorDuplicateModelName": "模型别名已存在,请使用其他名称。", "saveError": "添加模型失败", @@ -255,6 +255,9 @@ "toggle": "高级选项" }, "field": { + "provider": "Provider", + "providerPlaceholder": "例如 openai", + "providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "请输入 API Key",