fix(web): preserve explicit model providers

This commit is contained in:
lxowalle 2026-04-21 17:37:50 +08:00
parent 8deb0a251a
commit 2cd1267f2a
13 changed files with 492 additions and 60 deletions

View file

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

View file

@ -10,6 +10,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
) )
// registerModelRoutes binds model list management endpoints to the ServeMux. // registerModelRoutes binds model list management endpoints to the ServeMux.
@ -26,6 +27,7 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
type modelResponse struct { type modelResponse struct {
Index int `json:"index"` Index int `json:"index"`
ModelName string `json:"model_name"` ModelName string `json:"model_name"`
Provider string `json:"provider,omitempty"`
Model string `json:"model"` Model string `json:"model"`
APIBase string `json:"api_base,omitempty"` APIBase string `json:"api_base,omitempty"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
@ -73,10 +75,12 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
models := make([]modelResponse, 0, len(cfg.ModelList)) models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList { for i, m := range cfg.ModelList {
provider, modelID := providers.ExtractProtocol(m)
models = append(models, modelResponse{ models = append(models, modelResponse{
Index: i, Index: i,
ModelName: m.ModelName, ModelName: m.ModelName,
Model: m.Model, Provider: provider,
Model: modelID,
APIBase: m.APIBase, APIBase: m.APIBase,
APIKey: maskAPIKey(m.APIKey()), APIKey: maskAPIKey(m.APIKey()),
Proxy: m.Proxy, Proxy: m.Proxy,
@ -176,6 +180,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} }
defer r.Body.Close() defer r.Body.Close()
var rawFields map[string]json.RawMessage
if err = json.Unmarshal(body, &rawFields); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
type custom struct { type custom struct {
config.ModelConfig config.ModelConfig
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
@ -226,6 +236,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} else if len(mc.CustomHeaders) == 0 { } else if len(mc.CustomHeaders) == 0 {
mc.CustomHeaders = nil mc.CustomHeaders = nil
} }
// Preserve the existing Provider when the caller omits it. This keeps the
// update API backward-compatible for clients that haven't started sending
// the new field yet, while still allowing explicit clearing via "".
if _, ok := rawFields["provider"]; !ok {
mc.Provider = cfg.ModelList[idx].Provider
}
cfg.ModelList[idx] = &mc.ModelConfig cfg.ModelList[idx] = &mc.ModelConfig

View file

@ -392,6 +392,49 @@ func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) {
} }
} }
func TestHandleListModels_RuntimeProbeUsesExplicitProviderField(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
resetModelProbeHooks(t)
var gotProbe string
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
gotProbe = apiBase + "|" + modelID + "|" + apiKey
return true
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "vllm-local",
Provider: "vllm",
Model: "custom-model",
APIBase: "http://127.0.0.1:8000/v1",
}}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
t.Fatalf("probe = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
}
}
func TestHandleAddModel_PersistsAPIKey(t *testing.T) { func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -430,6 +473,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) { func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() 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 // TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent
// model as default returns 404. This covers the case where virtual models (which are // model as default returns 404. This covers the case where virtual models (which are
// filtered by SaveConfig) cannot be set as default. // filtered by SaveConfig) cannot be set as default.

View file

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

View file

@ -214,6 +214,54 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
} }
} }
func TestOAuthLogoutClearsAuthMethodForExplicitProviderField(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
ModelName: "gpt-5.4",
Provider: "openai",
Model: "gpt-5.4",
AuthMethod: "oauth",
})
if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig error: %v", err)
}
if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
AccessToken: "token-before-logout",
Provider: oauthProviderOpenAI,
AuthMethod: "oauth",
}); err != nil {
t.Fatalf("SetCredential error: %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
if got := updated.ModelList[len(updated.ModelList)-1].AuthMethod; got != "" {
t.Fatalf("auth_method = %q, want empty", got)
}
}
func setupOAuthTestEnv(t *testing.T) (string, func()) { func setupOAuthTestEnv(t *testing.T) (string, func()) {
t.Helper() t.Helper()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -239,8 +239,8 @@
"modelNamePlaceholder": "e.g. my-gpt4", "modelNamePlaceholder": "e.g. my-gpt4",
"modelNameHint": "A short name used to identify this model in conversations.", "modelNameHint": "A short name used to identify this model in conversations.",
"modelId": "Model Identifier", "modelId": "Model Identifier",
"modelIdPlaceholder": "e.g. openai/gpt-4o", "modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
"modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …", "modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.",
"errorRequired": "This field is required.", "errorRequired": "This field is required.",
"errorDuplicateModelName": "Model alias already exists. Please use a different name.", "errorDuplicateModelName": "Model alias already exists. Please use a different name.",
"saveError": "Failed to add model", "saveError": "Failed to add model",
@ -255,6 +255,9 @@
"toggle": "Advanced options" "toggle": "Advanced options"
}, },
"field": { "field": {
"provider": "Provider",
"providerPlaceholder": "e.g. openai",
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
"apiBase": "API Base URL", "apiBase": "API Base URL",
"apiKey": "API Key", "apiKey": "API Key",
"apiKeyPlaceholder": "Enter your API key", "apiKeyPlaceholder": "Enter your API key",

View file

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