refactor(models): unify provider metadata around backend catalog (#2896)

* feat(models): unify provider metadata around backend catalog

- Move shared provider metadata and alias normalization into backend-owned provider catalog
- Expose display, fetch, auth, and default model metadata through /api/models provider_options
- Replace frontend static provider registry with catalog-driven selection, validation, grouping, and fallback rendering
- Treat provider default api_base as placeholder and effective fetch/test base while keep submitted api_base separate from derived defaults
- Add model page retry handling, touched locale updates, and provider metadata assertions in backend tests

* fix(models): canonicalize backend provider aliases and common models

* fix(models): restore deepseek common model recommendations
This commit is contained in:
LC
2026-05-20 11:50:34 +08:00
committed by GitHub
parent 639b32703a
commit 548dc15acd
28 changed files with 1441 additions and 1084 deletions
+7 -7
View File
@@ -29,12 +29,12 @@ func supportsAudioTranscription(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg) protocol, _ := providers.ExtractProtocol(modelCfg)
switch protocol { switch protocol {
case "openai", "azure", "azure-openai", case "openai", "azure",
"litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us",
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "zai": "alibaba-coding", "zai":
// These protocols all go through the OpenAI-compatible or Azure provider path in // These protocols all go through the OpenAI-compatible or Azure provider path in
// providers.CreateProviderFromConfig, so they are the only ones that can supply // providers.CreateProviderFromConfig, so they are the only ones that can supply
// the audio media payload shape expected by NewAudioModelTranscriber. // the audio media payload shape expected by NewAudioModelTranscriber.
@@ -53,9 +53,9 @@ func supportsWhisperTranscription(modelCfg *config.ModelConfig) bool {
switch protocol { switch protocol {
case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us",
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": "alibaba-coding", "zai", "mimo":
return true return true
default: default:
return false return false
+21 -70
View File
@@ -18,54 +18,6 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/common"
) )
type protocolMeta struct {
defaultAPIBase string
emptyAPIKeyAllowed bool
}
var protocolMetaByName = map[string]protocolMeta{
"openai": {defaultAPIBase: "https://api.openai.com/v1"},
"venice": {defaultAPIBase: "https://api.venice.ai/api/v1"},
"openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"},
"litellm": {defaultAPIBase: "http://localhost:4000/v1"},
"lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true},
"novita": {defaultAPIBase: "https://api.novita.ai/openai"},
"groq": {defaultAPIBase: "https://api.groq.com/openai/v1"},
"zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"},
"gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"},
"nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"},
"ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true},
"moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"},
"shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"},
"siliconflow": {defaultAPIBase: "https://api.siliconflow.cn/v1"},
"deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"},
"cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"},
"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"},
"qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
"dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
"coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
"alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
"qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
"coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
"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"},
"minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"},
"longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
"modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
"mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"},
"anthropic": {defaultAPIBase: "https://api.anthropic.com/v1"},
"anthropic-messages": {defaultAPIBase: "https://api.anthropic.com/v1"},
}
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
func createClaudeAuthProvider() (LLMProvider, error) { func createClaudeAuthProvider() (LLMProvider, error) {
cred, err := getCredential("anthropic") cred, err := getCredential("anthropic")
@@ -184,7 +136,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
provider.SetProviderName(protocol) provider.SetProviderName(protocol)
return finalizeProviderFromConfig(provider, modelID, cfg) return finalizeProviderFromConfig(provider, modelID, cfg)
case "azure", "azure-openai": case "azure":
// Azure OpenAI uses deployment-based URLs, api-key header auth, // Azure OpenAI uses deployment-based URLs, api-key header auth,
// and always sends max_completion_tokens. // and always sends max_completion_tokens.
if cfg.APIKey() == "" { if cfg.APIKey() == "" {
@@ -241,9 +193,8 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo":
"coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo":
// All other OpenAI-compatible HTTP providers // All other OpenAI-compatible HTTP providers
if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@@ -355,7 +306,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout, cfg.RequestTimeout,
), modelID, cfg) ), modelID, cfg)
case "coding-plan-anthropic", "alibaba-coding-anthropic": case "alibaba-coding-anthropic":
// Alibaba Coding Plan with Anthropic-compatible API // Alibaba Coding Plan with Anthropic-compatible API
apiBase := cfg.APIBase apiBase := cfg.APIBase
if apiBase == "" { if apiBase == "" {
@@ -374,21 +325,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "antigravity": case "antigravity":
return finalizeProviderFromConfig(NewAntigravityProvider(), modelID, cfg) return finalizeProviderFromConfig(NewAntigravityProvider(), modelID, cfg)
case "claude-cli", "claudecli": case "claude-cli":
workspace := cfg.Workspace workspace := cfg.Workspace
if workspace == "" { if workspace == "" {
workspace = "." workspace = "."
} }
return finalizeProviderFromConfig(NewClaudeCliProvider(workspace), modelID, cfg) return finalizeProviderFromConfig(NewClaudeCliProvider(workspace), modelID, cfg)
case "codex-cli", "codexcli": case "codex-cli":
workspace := cfg.Workspace workspace := cfg.Workspace
if workspace == "" { if workspace == "" {
workspace = "." workspace = "."
} }
return finalizeProviderFromConfig(NewCodexCliProvider(workspace), modelID, cfg) return finalizeProviderFromConfig(NewCodexCliProvider(workspace), modelID, cfg)
case "github-copilot", "copilot": case "github-copilot":
apiBase := cfg.APIBase apiBase := cfg.APIBase
if apiBase == "" { if apiBase == "" {
apiBase = "localhost:4321" apiBase = "localhost:4321"
@@ -421,8 +372,8 @@ func finalizeProviderFromConfig(
} }
func isEmptyAPIKeyAllowed(protocol string) bool { func isEmptyAPIKeyAllowed(protocol string) bool {
meta, ok := protocolMetaForName(protocol) option, ok := modelProviderOptionForName(protocol)
return ok && meta.emptyAPIKeyAllowed return ok && option.EmptyAPIKeyAllowed
} }
// IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests // IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests
@@ -432,6 +383,16 @@ func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool {
return isEmptyAPIKeyAllowed(protocol) return isEmptyAPIKeyAllowed(protocol)
} }
// IsHTTPAPIProtocol reports whether a provider uses an HTTP API base in the
// model configuration path. This excludes providers such as Bedrock, CLI
// bridges, and OAuth-only managed providers even if they do not require an
// explicit api_key field.
func IsHTTPAPIProtocol(protocol string) bool {
protocol = NormalizeProvider(protocol)
option, ok := modelProviderOptionsByName[protocol]
return ok && option.httpAPI
}
// DefaultAPIBaseForProtocol returns the configured default API base for a protocol. // DefaultAPIBaseForProtocol returns the configured default API base for a protocol.
// It returns empty string if the protocol has no default base. // It returns empty string if the protocol has no default base.
func DefaultAPIBaseForProtocol(protocol string) string { func DefaultAPIBaseForProtocol(protocol string) string {
@@ -441,19 +402,9 @@ func DefaultAPIBaseForProtocol(protocol string) string {
// getDefaultAPIBase returns the default API base URL for a given protocol. // getDefaultAPIBase returns the default API base URL for a given protocol.
func getDefaultAPIBase(protocol string) string { func getDefaultAPIBase(protocol string) string {
meta, ok := protocolMetaForName(protocol) option, ok := modelProviderOptionForName(protocol)
if !ok { if !ok {
return "" return ""
} }
return meta.defaultAPIBase return option.DefaultAPIBase
}
func protocolMetaForName(protocol string) (protocolMeta, bool) {
if meta, ok := protocolMetaByName[protocol]; ok {
return meta, true
}
if meta, ok := attachedModelProviderMetaByName[protocol]; ok {
return meta.protocolMeta, true
}
return protocolMeta{}, false
} }
+73 -1
View File
@@ -946,7 +946,7 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {
if modelID != wantModelID { if modelID != wantModelID {
t.Errorf("modelID = %q, want %q", modelID, wantModelID) t.Errorf("modelID = %q, want %q", modelID, wantModelID)
} }
// coding-plan-anthropic uses Anthropic Messages provider // alibaba-coding-anthropic uses Anthropic Messages provider
// Verify it's the anthropic messages provider by checking interface // Verify it's the anthropic messages provider by checking interface
var _ LLMProvider = provider var _ LLMProvider = provider
}) })
@@ -998,6 +998,13 @@ func TestModelProviderOptions(t *testing.T) {
if option, ok := seen["openai"]; ok && !option.CreateAllowed { if option, ok := seen["openai"]; ok && !option.CreateAllowed {
t.Fatal("openai should be creatable") t.Fatal("openai should be creatable")
} }
if option, ok := seen["openai"]; ok && !option.SupportsFetch {
t.Fatal("openai should support upstream model listing")
} else if option.DisplayName != "OpenAI" {
t.Fatalf("openai display_name = %q, want %q", option.DisplayName, "OpenAI")
} else if len(option.CommonModels) == 0 {
t.Fatal("openai common_models should not be empty")
}
if option, ok := seen["lmstudio"]; !ok { if option, ok := seen["lmstudio"]; !ok {
t.Fatal("lmstudio option missing") t.Fatal("lmstudio option missing")
} else if !option.EmptyAPIKeyAllowed { } else if !option.EmptyAPIKeyAllowed {
@@ -1052,6 +1059,71 @@ func TestModelProviderOptions(t *testing.T) {
t.Fatal("github-copilot option missing") t.Fatal("github-copilot option missing")
} else if option.DefaultAPIBase != "localhost:4321" { } else if option.DefaultAPIBase != "localhost:4321" {
t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321")
} else if !option.Local {
t.Fatal("github-copilot should be marked local")
}
if option, ok := seen["qwen-portal"]; !ok {
t.Fatal("qwen-portal option missing")
} else if len(option.Aliases) == 0 || option.Aliases[0] != "qwen" {
t.Fatalf("qwen-portal aliases = %#v, want to include qwen", option.Aliases)
}
for _, option := range options {
if len(option.CommonModels) > 6 {
t.Fatalf("provider %q exposes %d common_models, want at most 6", option.ID, len(option.CommonModels))
}
if option.Local && len(option.CommonModels) > 0 {
t.Fatalf("local provider %q should not expose common_models", option.ID)
}
seenModels := make(map[string]struct{}, len(option.CommonModels))
for _, model := range option.CommonModels {
if strings.TrimSpace(model) == "" {
t.Fatalf("provider %q includes an empty common_model entry", option.ID)
}
if _, exists := seenModels[model]; exists {
t.Fatalf("provider %q includes duplicate common_model %q", option.ID, model)
}
seenModels[model] = struct{}{}
}
}
}
func TestBuildModelProviderAliasMap(t *testing.T) {
aliases := buildModelProviderAliasMap()
if len(aliases) == 0 {
t.Fatal("buildModelProviderAliasMap() returned empty map")
}
seenAliases := make(map[string]string, len(aliases))
for provider, option := range modelProviderOptionsByName {
got, ok := aliases[provider]
if !ok {
t.Fatalf("canonical provider %q missing from alias map", provider)
}
if got != provider {
t.Fatalf("canonical provider %q mapped to %q", provider, got)
}
if existing, ok := seenAliases[provider]; ok {
t.Fatalf("canonical provider key %q collides with provider %q", provider, existing)
}
seenAliases[provider] = provider
for _, alias := range option.Aliases {
normalized := strings.ToLower(strings.TrimSpace(alias))
if normalized == "" {
t.Fatalf("provider %q includes empty alias", provider)
}
if existing, ok := seenAliases[normalized]; ok && existing != provider {
t.Fatalf("alias %q for provider %q collides with provider %q", alias, provider, existing)
}
seenAliases[normalized] = provider
got, ok := aliases[normalized]
if !ok {
t.Fatalf("alias %q for provider %q missing from alias map", alias, provider)
}
if got != provider {
t.Fatalf("alias %q normalized to %q, want %q", alias, got, provider)
}
}
} }
} }
+7 -39
View File
@@ -29,46 +29,14 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef {
// NormalizeProvider normalizes provider identifiers to canonical form. // NormalizeProvider normalizes provider identifiers to canonical form.
func NormalizeProvider(provider string) string { func NormalizeProvider(provider string) string {
p := strings.ToLower(strings.TrimSpace(provider)) normalized := strings.ToLower(strings.TrimSpace(provider))
if normalized == "" {
switch p { return ""
case "z.ai", "z-ai":
return "zai"
case "opencode-zen":
return "opencode"
case "qwen":
return "qwen-portal"
case "kimi-code":
return "kimi-coding"
case "gpt":
return "openai"
case "claude":
return "anthropic"
case "glm":
return "zhipu"
case "google":
return "gemini"
case "google-antigravity":
return "antigravity"
case "alibaba-coding", "qwen-coding":
return "coding-plan"
case "alibaba-coding-anthropic":
return "coding-plan-anthropic"
case "qwen-international", "dashscope-intl":
return "qwen-intl"
case "dashscope-us":
return "qwen-us"
case "azure-openai":
return "azure"
case "claudecli":
return "claude-cli"
case "codexcli":
return "codex-cli"
case "copilot":
return "github-copilot"
} }
if canonical, ok := normalizedModelProviderAliasesByName[normalized]; ok {
return p return canonical
}
return normalized
} }
// ModelKey returns a canonical "provider/model" key for deduplication. // ModelKey returns a canonical "provider/model" key for deduplication.
+5 -5
View File
@@ -65,9 +65,7 @@ func TestNormalizeProvider(t *testing.T) {
{"z.ai", "zai"}, {"z.ai", "zai"},
{"z-ai", "zai"}, {"z-ai", "zai"},
{"Z.AI", "zai"}, {"Z.AI", "zai"},
{"opencode-zen", "opencode"},
{"qwen", "qwen-portal"}, {"qwen", "qwen-portal"},
{"kimi-code", "kimi-coding"},
{"gpt", "openai"}, {"gpt", "openai"},
{"claude", "anthropic"}, {"claude", "anthropic"},
{"glm", "zhipu"}, {"glm", "zhipu"},
@@ -79,9 +77,11 @@ func TestNormalizeProvider(t *testing.T) {
{"codexcli", "codex-cli"}, {"codexcli", "codex-cli"},
{"copilot", "github-copilot"}, {"copilot", "github-copilot"},
// Alibaba Coding Plan aliases // Alibaba Coding Plan aliases
{"alibaba-coding", "coding-plan"}, {"alibaba-coding", "alibaba-coding"},
{"qwen-coding", "coding-plan"}, {"coding-plan", "alibaba-coding"},
{"alibaba-coding-anthropic", "coding-plan-anthropic"}, {"qwen-coding", "alibaba-coding"},
{"alibaba-coding-anthropic", "alibaba-coding-anthropic"},
{"coding-plan-anthropic", "alibaba-coding-anthropic"},
// Qwen international aliases // Qwen international aliases
{"qwen-international", "qwen-intl"}, {"qwen-international", "qwen-intl"},
{"dashscope-intl", "qwen-intl"}, {"dashscope-intl", "qwen-intl"},
+14 -115
View File
@@ -5,97 +5,10 @@ import (
"strings" "strings"
) )
// ModelProviderOption describes a canonical provider entry exposed to the Web UI.
type ModelProviderOption struct {
ID string `json:"id"`
DefaultAPIBase string `json:"default_api_base"`
EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"`
CreateAllowed bool `json:"create_allowed"`
DefaultModelAllowed bool `json:"default_model_allowed"`
DefaultAuthMethod string `json:"default_auth_method,omitempty"`
AuthMethodLocked bool `json:"auth_method_locked,omitempty"`
}
type attachedModelProviderMeta struct {
protocolMeta
createAllowed bool
defaultModelAllowed bool
defaultAuthMethod string
authMethodLocked bool
}
// attachedModelProviderMetaByName augments protocolMetaByName for provider
// families that are implemented in CreateProviderFromConfig but intentionally
// kept out of the core HTTP metadata map because they have special auth/runtime
// semantics.
var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{
"azure": {createAllowed: true, defaultModelAllowed: true},
"anthropic": {
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
createAllowed: true,
defaultModelAllowed: true,
},
"anthropic-messages": {
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
createAllowed: true,
defaultModelAllowed: true,
},
"bedrock": {createAllowed: true, defaultModelAllowed: true},
"antigravity": {
createAllowed: true,
defaultModelAllowed: true,
defaultAuthMethod: "oauth",
authMethodLocked: true,
},
"claude-cli": {createAllowed: true, defaultModelAllowed: true},
"codex-cli": {createAllowed: true, defaultModelAllowed: true},
"github-copilot": {
protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"},
createAllowed: true,
defaultModelAllowed: true,
},
// ElevenLabs is intentionally exposed only as an ASR-capable provider. It
// belongs in the shared model catalog because ASR is configured via
// model_list, but it must not be selectable as the default chat model.
"elevenlabs": {
protocolMeta: protocolMeta{defaultAPIBase: "https://api.elevenlabs.io"},
createAllowed: true,
defaultModelAllowed: false,
},
}
// ModelProviderOptions returns the canonical provider catalog exposed to the Web UI. // ModelProviderOptions returns the canonical provider catalog exposed to the Web UI.
func ModelProviderOptions() []ModelProviderOption { func ModelProviderOptions() []ModelProviderOption {
optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName)) options := make([]ModelProviderOption, 0, len(modelProviderOptionsByName))
for provider := range protocolMetaByName { for _, option := range modelProviderOptionsByName {
if NormalizeProvider(provider) != provider {
continue
}
optionsByID[provider] = ModelProviderOption{
ID: provider,
DefaultAPIBase: DefaultAPIBaseForProtocol(provider),
EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider),
CreateAllowed: true,
DefaultModelAllowed: true,
}
}
for provider, meta := range attachedModelProviderMetaByName {
if NormalizeProvider(provider) != provider {
continue
}
optionsByID[provider] = ModelProviderOption{
ID: provider,
DefaultAPIBase: meta.defaultAPIBase,
EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed,
CreateAllowed: meta.createAllowed,
DefaultModelAllowed: meta.defaultModelAllowed,
DefaultAuthMethod: meta.defaultAuthMethod,
AuthMethodLocked: meta.authMethodLocked,
}
}
options := make([]ModelProviderOption, 0, len(optionsByID))
for _, option := range optionsByID {
options = append(options, option) options = append(options, option)
} }
sort.Slice(options, func(i, j int) bool { sort.Slice(options, func(i, j int) bool {
@@ -107,44 +20,30 @@ func ModelProviderOptions() []ModelProviderOption {
// IsSupportedModelProvider reports whether provider resolves to a provider ID // IsSupportedModelProvider reports whether provider resolves to a provider ID
// returned by ModelProviderOptions. // returned by ModelProviderOptions.
func IsSupportedModelProvider(provider string) bool { func IsSupportedModelProvider(provider string) bool {
normalized := NormalizeProvider(provider) _, ok := modelProviderOptionForName(provider)
if normalized == "" {
return false
}
if _, ok := protocolMetaByName[normalized]; ok {
return true
}
_, ok := attachedModelProviderMetaByName[normalized]
return ok return ok
} }
// IsModelProviderFetchable reports whether provider supports upstream /models
// listing through the launcher fetch endpoint.
func IsModelProviderFetchable(provider string) bool {
option, ok := modelProviderOptionForName(provider)
return ok && option.SupportsFetch
}
// IsCreatableModelProvider reports whether provider can be selected for a new // IsCreatableModelProvider reports whether provider can be selected for a new
// model entry from the Web UI. // model entry from the Web UI.
func IsCreatableModelProvider(provider string) bool { func IsCreatableModelProvider(provider string) bool {
normalized := NormalizeProvider(provider) option, ok := modelProviderOptionForName(provider)
if normalized == "" { return ok && option.CreateAllowed
return false
}
if _, ok := protocolMetaByName[normalized]; ok {
return true
}
meta, ok := attachedModelProviderMetaByName[normalized]
return ok && meta.createAllowed
} }
// IsDefaultModelProvider reports whether provider can be used as the default // IsDefaultModelProvider reports whether provider can be used as the default
// chat model. Some providers such as ASR-only entries are intentionally // chat model. Some providers such as ASR-only entries are intentionally
// exposed in model_list management but cannot drive the gateway default model. // exposed in model_list management but cannot drive the gateway default model.
func IsDefaultModelProvider(provider string) bool { func IsDefaultModelProvider(provider string) bool {
normalized := NormalizeProvider(provider) option, ok := modelProviderOptionForName(provider)
if normalized == "" { return ok && option.DefaultModelAllowed
return false
}
if _, ok := protocolMetaByName[normalized]; ok {
return true
}
meta, ok := attachedModelProviderMetaByName[normalized]
return ok && meta.defaultModelAllowed
} }
// SplitModelProviderAndID separates a legacy "provider/model" string into its // SplitModelProviderAndID separates a legacy "provider/model" string into its
+581
View File
@@ -0,0 +1,581 @@
package providers
import "strings"
// ModelProviderOption describes a canonical provider entry exposed to the Web UI.
// It also serves as the backend-owned source of truth for shared provider metadata.
type ModelProviderOption struct {
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
IconSlug string `json:"icon_slug,omitempty"`
Domain string `json:"domain,omitempty"`
DefaultAPIBase string `json:"default_api_base"`
EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"`
CreateAllowed bool `json:"create_allowed"`
DefaultModelAllowed bool `json:"default_model_allowed"`
SupportsFetch bool `json:"supports_fetch,omitempty"`
DefaultAuthMethod string `json:"default_auth_method,omitempty"`
AuthMethodLocked bool `json:"auth_method_locked,omitempty"`
Local bool `json:"local,omitempty"`
Priority float64 `json:"priority,omitempty"`
CommonModels []string `json:"common_models,omitempty"`
Aliases []string `json:"aliases,omitempty"`
httpAPI bool `json:"-"`
}
var modelProviderOptionsByName = map[string]ModelProviderOption{
"openai": {
ID: "openai",
DisplayName: "OpenAI",
IconSlug: "openai",
Domain: "openai.com",
DefaultAPIBase: "https://api.openai.com/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 100,
CommonModels: []string{"gpt-5.4", "gpt-5.4-mini", "gpt-5.5"},
Aliases: []string{"gpt"},
httpAPI: true,
},
"anthropic": {
ID: "anthropic",
DisplayName: "Anthropic",
IconSlug: "anthropic",
Domain: "anthropic.com",
DefaultAPIBase: "https://api.anthropic.com/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
Priority: 95,
CommonModels: []string{"claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5"},
Aliases: []string{"claude"},
httpAPI: true,
},
"anthropic-messages": {
ID: "anthropic-messages",
DisplayName: "Anthropic Messages",
IconSlug: "anthropic",
Domain: "anthropic.com",
DefaultAPIBase: "https://api.anthropic.com/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
Priority: 94,
CommonModels: []string{"claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5"},
httpAPI: true,
},
"gemini": {
ID: "gemini",
DisplayName: "Google Gemini",
IconSlug: "googlegemini",
Domain: "gemini.google.com",
DefaultAPIBase: "https://generativelanguage.googleapis.com/v1beta",
CreateAllowed: true,
DefaultModelAllowed: true,
Priority: 90,
CommonModels: []string{"gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite"},
Aliases: []string{"google"},
httpAPI: true,
},
"deepseek": {
ID: "deepseek",
DisplayName: "DeepSeek",
IconSlug: "deepseek",
Domain: "deepseek.com",
DefaultAPIBase: "https://api.deepseek.com/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 85,
CommonModels: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
httpAPI: true,
},
"openrouter": {
ID: "openrouter",
DisplayName: "OpenRouter",
IconSlug: "openrouter",
Domain: "openrouter.ai",
DefaultAPIBase: "https://openrouter.ai/api/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 80,
CommonModels: []string{
"openai/gpt-5.4",
"anthropic/claude-opus-4.7",
"google/gemini-3.1-pro-preview",
"qwen/qwen3-coder-next",
},
httpAPI: true,
},
"qwen-portal": {
ID: "qwen-portal",
DisplayName: "Qwen",
IconSlug: "alibabacloud",
Domain: "qwenlm.ai",
DefaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 75,
CommonModels: []string{"qwen3.6-max-preview", "qwen3.6-plus", "qwen3.6-flash", "qwen3-coder-next"},
Aliases: []string{"qwen"},
httpAPI: true,
},
"qwen-intl": {
ID: "qwen-intl",
DisplayName: "Qwen International",
IconSlug: "alibabacloud",
Domain: "alibabacloud.com",
DefaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 74,
CommonModels: []string{"qwen3.6-max-preview", "qwen3.6-plus", "qwen3.6-flash", "qwen3-coder-next"},
Aliases: []string{"qwen-international", "dashscope-intl"},
httpAPI: true,
},
"moonshot": {
ID: "moonshot",
DisplayName: "Moonshot",
Domain: "moonshot.ai",
DefaultAPIBase: "https://api.moonshot.cn/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 70,
CommonModels: []string{
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
"kimi-k2-turbo-preview",
},
httpAPI: true,
},
"volcengine": {
ID: "volcengine",
DisplayName: "Volcengine",
IconSlug: "bytedance",
Domain: "volcengine.com",
DefaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 69,
CommonModels: []string{
"doubao-seed-1-6-251015",
"doubao-seed-1-6-flash-250828",
"doubao-seed-1-6-thinking",
},
httpAPI: true,
},
"zhipu": {
ID: "zhipu",
DisplayName: "Zhipu AI",
IconSlug: "zhipu",
Domain: "zhipuai.cn",
DefaultAPIBase: "https://open.bigmodel.cn/api/paas/v4",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 68,
CommonModels: []string{"glm-5", "glm-4.7", "glm-4.5-air", "glm-4-flash-250414"},
Aliases: []string{"glm"},
httpAPI: true,
},
"groq": {
ID: "groq",
DisplayName: "Groq",
IconSlug: "groq",
Domain: "groq.com",
DefaultAPIBase: "https://api.groq.com/openai/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 65,
CommonModels: []string{
"openai/gpt-oss-120b",
"openai/gpt-oss-20b",
"llama-3.3-70b-versatile",
"qwen/qwen3-32b",
},
httpAPI: true,
},
"mistral": {
ID: "mistral",
DisplayName: "Mistral AI",
IconSlug: "mistralai",
Domain: "mistral.ai",
DefaultAPIBase: "https://api.mistral.ai/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 64,
CommonModels: []string{
"mistral-large-latest",
"mistral-medium-3-5",
"mistral-small-latest",
"devstral-latest",
},
httpAPI: true,
},
"nvidia": {
ID: "nvidia",
DisplayName: "NVIDIA",
IconSlug: "nvidia",
Domain: "nvidia.com",
DefaultAPIBase: "https://integrate.api.nvidia.com/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 63,
CommonModels: []string{
"openai/gpt-oss-120b",
"openai/gpt-oss-20b",
"qwen/qwen3-coder-480b-a35b-instruct",
"qwen/qwen3-next-80b-a3b-thinking",
},
httpAPI: true,
},
"cerebras": {
ID: "cerebras",
DisplayName: "Cerebras",
IconSlug: "cerebras",
Domain: "cerebras.ai",
DefaultAPIBase: "https://api.cerebras.ai/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 62,
CommonModels: []string{"gpt-oss-120b", "zai-glm-4.7"},
httpAPI: true,
},
"azure": {
ID: "azure",
DisplayName: "Azure OpenAI",
IconSlug: "microsoftazure",
Domain: "azure.com",
CreateAllowed: true,
DefaultModelAllowed: true,
Priority: 61,
Aliases: []string{"azure-openai"},
httpAPI: true,
},
"bedrock": {
ID: "bedrock",
DisplayName: "AWS Bedrock",
IconSlug: "amazonwebservices",
Domain: "aws.amazon.com",
EmptyAPIKeyAllowed: true,
CreateAllowed: true,
DefaultModelAllowed: true,
Priority: 60,
},
"github-copilot": {
ID: "github-copilot",
DisplayName: "GitHub Copilot",
IconSlug: "githubcopilot",
Domain: "github.com",
DefaultAPIBase: "localhost:4321",
EmptyAPIKeyAllowed: true,
CreateAllowed: true,
DefaultModelAllowed: true,
Local: true,
Priority: 55,
Aliases: []string{"copilot"},
},
"antigravity": {
ID: "antigravity",
DisplayName: "Google Code Assist",
Domain: "antigravity.google",
EmptyAPIKeyAllowed: true,
CreateAllowed: true,
DefaultModelAllowed: true,
DefaultAuthMethod: "oauth",
AuthMethodLocked: true,
Priority: 54,
Aliases: []string{"google-antigravity"},
},
"claude-cli": {
ID: "claude-cli",
DisplayName: "Claude CLI",
IconSlug: "anthropic",
Domain: "anthropic.com",
EmptyAPIKeyAllowed: true,
CreateAllowed: true,
DefaultModelAllowed: true,
Local: true,
Priority: 53,
Aliases: []string{"claudecli"},
},
"codex-cli": {
ID: "codex-cli",
DisplayName: "Codex CLI",
IconSlug: "openai",
Domain: "openai.com",
EmptyAPIKeyAllowed: true,
CreateAllowed: true,
DefaultModelAllowed: true,
Local: true,
Priority: 52,
Aliases: []string{"codexcli"},
},
"ollama": {
ID: "ollama",
DisplayName: "Ollama",
IconSlug: "ollama",
Domain: "ollama.com",
DefaultAPIBase: "http://localhost:11434/v1",
EmptyAPIKeyAllowed: true,
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Local: true,
Priority: 50,
httpAPI: true,
},
"vllm": {
ID: "vllm",
DisplayName: "VLLM",
Domain: "vllm.ai",
DefaultAPIBase: "http://localhost:8000/v1",
EmptyAPIKeyAllowed: true,
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Local: true,
Priority: 49,
httpAPI: true,
},
"lmstudio": {
ID: "lmstudio",
DisplayName: "LM Studio",
Domain: "lmstudio.ai",
DefaultAPIBase: "http://localhost:1234/v1",
EmptyAPIKeyAllowed: true,
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Local: true,
Priority: 48,
httpAPI: true,
},
"elevenlabs": {
ID: "elevenlabs",
DisplayName: "ElevenLabs ASR",
IconSlug: "elevenlabs",
Domain: "elevenlabs.io",
DefaultAPIBase: "https://api.elevenlabs.io",
CreateAllowed: true,
DefaultModelAllowed: false,
Priority: 47,
httpAPI: true,
},
"venice": {
ID: "venice",
DisplayName: "Venice AI",
IconSlug: "venice",
Domain: "venice.ai",
DefaultAPIBase: "https://api.venice.ai/api/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 45,
httpAPI: true,
},
"shengsuanyun": {
ID: "shengsuanyun",
DisplayName: "ShengsuanYun",
Domain: "shengsuanyun.com",
DefaultAPIBase: "https://router.shengsuanyun.com/api/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 44,
httpAPI: true,
},
"siliconflow": {
ID: "siliconflow",
DisplayName: "SiliconFlow",
Domain: "siliconflow.cn",
DefaultAPIBase: "https://api.siliconflow.cn/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 43.5,
httpAPI: true,
},
"vivgrid": {
ID: "vivgrid",
DisplayName: "Vivgrid",
Domain: "vivgrid.com",
DefaultAPIBase: "https://api.vivgrid.com/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 43,
httpAPI: true,
},
"minimax": {
ID: "minimax",
DisplayName: "MiniMax",
Domain: "minimaxi.com",
DefaultAPIBase: "https://api.minimaxi.com/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 42,
httpAPI: true,
},
"longcat": {
ID: "longcat",
DisplayName: "LongCat",
Domain: "longcat.chat",
DefaultAPIBase: "https://api.longcat.chat/openai",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 41,
httpAPI: true,
},
"modelscope": {
ID: "modelscope",
DisplayName: "ModelScope",
Domain: "modelscope.cn",
DefaultAPIBase: "https://api-inference.modelscope.cn/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 40,
httpAPI: true,
},
"mimo": {
ID: "mimo",
DisplayName: "Xiaomi MiMo",
IconSlug: "xiaomi",
Domain: "xiaomi.com",
DefaultAPIBase: "https://api.xiaomimimo.com/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 39,
httpAPI: true,
},
"avian": {
ID: "avian",
DisplayName: "Avian",
Domain: "avian.io",
DefaultAPIBase: "https://api.avian.io/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 38,
httpAPI: true,
},
"zai": {
ID: "zai",
DisplayName: "Z.ai",
Domain: "z.ai",
DefaultAPIBase: "https://api.z.ai/api/coding/paas/v4",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 37,
CommonModels: []string{"glm-5", "glm-4.7", "glm-4.5-air", "glm-4-flash-250414"},
Aliases: []string{"z.ai", "z-ai"},
httpAPI: true,
},
"alibaba-coding": {
ID: "alibaba-coding",
DisplayName: "Alibaba Coding Plan",
IconSlug: "alibabacloud",
Domain: "alibabacloud.com",
DefaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
Priority: 36.5,
CommonModels: []string{"qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5"},
Aliases: []string{"coding-plan", "qwen-coding"},
httpAPI: true,
},
"alibaba-coding-anthropic": {
ID: "alibaba-coding-anthropic",
DisplayName: "Alibaba Coding Plan (Anthropic)",
IconSlug: "alibabacloud",
Domain: "alibabacloud.com",
DefaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic",
CreateAllowed: true,
DefaultModelAllowed: true,
Priority: 36.25,
CommonModels: []string{"qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5"},
Aliases: []string{"coding-plan-anthropic"},
httpAPI: true,
},
"novita": {
ID: "novita",
DisplayName: "Novita AI",
Domain: "novita.ai",
DefaultAPIBase: "https://api.novita.ai/openai",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 36,
httpAPI: true,
},
"litellm": {
ID: "litellm",
DisplayName: "LiteLLM",
Domain: "litellm.ai",
DefaultAPIBase: "http://localhost:4000/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
SupportsFetch: true,
Priority: 35,
httpAPI: true,
},
"qwen-us": {
ID: "qwen-us",
DisplayName: "Qwen US",
IconSlug: "alibabacloud",
Domain: "alibabacloud.com",
DefaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
CreateAllowed: true,
DefaultModelAllowed: true,
Priority: 34,
CommonModels: []string{"qwen3.6-max-preview", "qwen3.6-plus", "qwen3.6-flash", "qwen3-coder-next"},
Aliases: []string{"dashscope-us"},
httpAPI: true,
},
}
var normalizedModelProviderAliasesByName = buildModelProviderAliasMap()
func buildModelProviderAliasMap() map[string]string {
totalAliases := 0
for _, option := range modelProviderOptionsByName {
totalAliases += len(option.Aliases)
}
aliases := make(map[string]string, len(modelProviderOptionsByName)+totalAliases)
for provider, option := range modelProviderOptionsByName {
aliases[provider] = provider
for _, alias := range option.Aliases {
normalized := strings.ToLower(strings.TrimSpace(alias))
if normalized == "" {
continue
}
aliases[normalized] = provider
}
}
return aliases
}
func modelProviderOptionForName(provider string) (ModelProviderOption, bool) {
normalized := NormalizeProvider(provider)
if normalized == "" {
return ModelProviderOption{}, false
}
option, ok := modelProviderOptionsByName[normalized]
return option, ok
}
+4 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/providers"
) )
// CatalogModel represents a single model entry in a saved catalog. // CatalogModel represents a single model entry in a saved catalog.
@@ -42,7 +43,7 @@ func catalogFilePath() string {
// generateCatalogKey creates a deterministic key for a provider+base+key combination. // generateCatalogKey creates a deterministic key for a provider+base+key combination.
func generateCatalogKey(provider, apiBase, apiKey string) string { func generateCatalogKey(provider, apiBase, apiKey string) string {
provider = strings.ToLower(strings.TrimSpace(provider)) provider = providers.NormalizeProvider(provider)
apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/")
hash := sha256.Sum256([]byte(apiKey)) hash := sha256.Sum256([]byte(apiKey))
return fmt.Sprintf("%s|%s|%x", provider, apiBase, hash[:6]) return fmt.Sprintf("%s|%s|%x", provider, apiBase, hash[:6])
@@ -104,9 +105,10 @@ func SaveCatalog(provider, apiBase, apiKey string, models []CatalogModel) error
return err return err
} }
key := generateCatalogKey(provider, apiBase, apiKey) key := generateCatalogKey(provider, apiBase, apiKey)
provider = providers.NormalizeProvider(provider)
store.Entries[key] = &CatalogEntry{ store.Entries[key] = &CatalogEntry{
ID: key, ID: key,
Provider: strings.ToLower(strings.TrimSpace(provider)), Provider: provider,
APIBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"), APIBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"),
APIKeyMask: maskAPIKeyValue(apiKey), APIKeyMask: maskAPIKeyValue(apiKey),
Models: models, Models: models,
+8 -8
View File
@@ -126,7 +126,7 @@ func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) {
func providerUsesImplicitOAuth(protocol string) bool { func providerUsesImplicitOAuth(protocol string) bool {
switch protocol { switch protocol {
case "antigravity", "google-antigravity": case "antigravity":
return true return true
default: default:
return false return false
@@ -168,11 +168,11 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
protocol := modelProtocol(m) protocol := modelProtocol(m)
switch protocol { switch protocol {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": case "claude-cli", "codex-cli", "github-copilot":
return true return true
} }
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { if providers.IsHTTPAPIProtocol(protocol) && providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
apiBase := strings.TrimSpace(m.APIBase) apiBase := strings.TrimSpace(m.APIBase)
return apiBase == "" || hasLocalAPIBase(apiBase) return apiBase == "" || hasLocalAPIBase(apiBase)
} }
@@ -220,11 +220,11 @@ func runLocalModelProbe(m *config.ModelConfig) bool {
return probeOllamaModelFunc(apiBase, modelID) return probeOllamaModelFunc(apiBase, modelID)
case "vllm", "lmstudio": case "vllm", "lmstudio":
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
case "github-copilot", "copilot": case "github-copilot":
return probeTCPServiceFunc(apiBase) return probeTCPServiceFunc(apiBase)
case "claude-cli", "claudecli": case "claude-cli":
return probeCommandAvailableFunc("claude") return probeCommandAvailableFunc("claude")
case "codex-cli", "codexcli": case "codex-cli":
return probeCommandAvailableFunc("codex") return probeCommandAvailableFunc("codex")
default: default:
if hasLocalAPIBase(apiBase) { if hasLocalAPIBase(apiBase) {
@@ -442,7 +442,7 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
} }
switch protocol { switch protocol {
case "github-copilot", "copilot": case "github-copilot":
return "localhost:4321" return "localhost:4321"
default: default:
return "" return ""
@@ -477,7 +477,7 @@ func oauthProviderForModel(m *config.ModelConfig) (string, bool) {
return oauthProviderOpenAI, true return oauthProviderOpenAI, true
case "anthropic": case "anthropic":
return oauthProviderAnthropic, true return oauthProviderAnthropic, true
case "antigravity", "google-antigravity": case "antigravity":
return oauthProviderGoogleAntigravity, true return oauthProviderGoogleAntigravity, true
default: default:
return "", false return "", false
+4 -17
View File
@@ -18,19 +18,6 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// fetchableProviders lists providers that support OpenAI-compatible /models listing.
var fetchableProviders = map[string]bool{
"openai": true, "deepseek": true, "openrouter": true,
"qwen-portal": true, "qwen-intl": true, "moonshot": true,
"volcengine": true, "zhipu": true, "groq": true,
"mistral": true, "nvidia": true, "cerebras": true,
"venice": true, "shengsuanyun": true, "vivgrid": true,
"siliconflow": true,
"minimax": true, "longcat": true, "modelscope": true,
"mimo": true, "avian": true, "zai": true, "novita": true,
"litellm": true, "vllm": true, "lmstudio": true, "ollama": true,
}
// registerModelRoutes binds model list management endpoints to the ServeMux. // registerModelRoutes binds model list management endpoints to the ServeMux.
func (h *Handler) registerModelRoutes(mux *http.ServeMux) { func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/models", h.handleListModels) mux.HandleFunc("GET /api/models", h.handleListModels)
@@ -667,7 +654,7 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) {
return return
} }
if !fetchableProviders[strings.ToLower(req.Provider)] { if !providers.IsModelProviderFetchable(req.Provider) {
http.Error(w, fmt.Sprintf("provider %q does not support model listing", req.Provider), http.StatusBadRequest) http.Error(w, fmt.Sprintf("provider %q does not support model listing", req.Provider), http.StatusBadRequest)
return return
} }
@@ -1012,11 +999,11 @@ func probeModelConnectivity(m *config.ModelConfig) bool {
return probeOllamaModel(apiBase, modelID) return probeOllamaModel(apiBase, modelID)
case "vllm", "lmstudio": case "vllm", "lmstudio":
return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey())
case "github-copilot", "copilot": case "github-copilot":
return probeTCPService(apiBase) return probeTCPService(apiBase)
case "claude-cli", "claudecli": case "claude-cli":
return probeCommandAvailable("claude") return probeCommandAvailable("claude")
case "codex-cli", "codexcli": case "codex-cli":
return probeCommandAvailable("codex") return probeCommandAvailable("codex")
default: default:
// For remote providers (OpenAI, Anthropic, Gemini, DeepSeek, etc.), // For remote providers (OpenAI, Anthropic, Gemini, DeepSeek, etc.),
+13
View File
@@ -1900,6 +1900,12 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration
t.Fatal("openai provider option missing") t.Fatal("openai provider option missing")
} else if option.DefaultAPIBase != "https://api.openai.com/v1" { } else if option.DefaultAPIBase != "https://api.openai.com/v1" {
t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1") t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1")
} else if !option.SupportsFetch {
t.Fatal("openai provider option should report supports_fetch")
} else if option.DisplayName != "OpenAI" {
t.Fatalf("openai display_name = %q, want %q", option.DisplayName, "OpenAI")
} else if len(option.CommonModels) == 0 {
t.Fatal("openai common_models should not be empty")
} }
if option, ok := optionsByID["anthropic"]; !ok { if option, ok := optionsByID["anthropic"]; !ok {
t.Fatal("anthropic provider option missing") t.Fatal("anthropic provider option missing")
@@ -1913,6 +1919,8 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration
t.Fatal("github-copilot provider option missing") t.Fatal("github-copilot provider option missing")
} else if option.DefaultAPIBase != "localhost:4321" { } else if option.DefaultAPIBase != "localhost:4321" {
t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321")
} else if !option.Local {
t.Fatal("github-copilot should be marked local")
} }
if option, ok := optionsByID["elevenlabs"]; !ok { if option, ok := optionsByID["elevenlabs"]; !ok {
t.Fatal("elevenlabs provider option missing") t.Fatal("elevenlabs provider option missing")
@@ -1953,6 +1961,11 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration
t.Fatal("antigravity auth method should be locked") t.Fatal("antigravity auth method should be locked")
} }
} }
if option, ok := optionsByID["qwen-portal"]; !ok {
t.Fatal("qwen-portal provider option missing")
} else if len(option.Aliases) == 0 || option.Aliases[0] != "qwen" {
t.Fatalf("qwen-portal aliases = %#v, want to include qwen", option.Aliases)
}
updated, err := config.LoadConfig(configPath) updated, err := config.LoadConfig(configPath)
if err != nil { if err != nil {
+1 -1
View File
@@ -767,7 +767,7 @@ func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool
case oauthProviderAnthropic: case oauthProviderAnthropic:
return protocol == "anthropic" return protocol == "anthropic"
case oauthProviderGoogleAntigravity: case oauthProviderGoogleAntigravity:
return protocol == "antigravity" || protocol == "google-antigravity" return protocol == "antigravity"
default: default:
return false return false
} }
+8
View File
@@ -36,12 +36,20 @@ export interface ModelInfo {
export interface ModelProviderOption { export interface ModelProviderOption {
id: string id: string
display_name?: string
icon_slug?: string
domain?: string
default_api_base: string default_api_base: string
empty_api_key_allowed: boolean empty_api_key_allowed: boolean
create_allowed: boolean create_allowed: boolean
default_model_allowed: boolean default_model_allowed: boolean
supports_fetch?: boolean
default_auth_method?: string default_auth_method?: string
auth_method_locked?: boolean auth_method_locked?: boolean
local?: boolean
priority?: number
common_models?: string[]
aliases?: string[]
} }
interface ModelsListResponse { interface ModelsListResponse {
@@ -36,10 +36,22 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway" import { refreshGatewayState } from "@/store/gateway"
import { FetchModelsDialog } from "./fetch-models-dialog" import { FetchModelsDialog } from "./fetch-models-dialog"
import {
getEffectiveAPIBase,
getSubmittedAPIBase,
normalizeApiBase,
} from "./model-provider-form-shared"
import { type FieldValidation, validateModelField } from "./model-validation" import { type FieldValidation, validateModelField } from "./model-validation"
import { ProviderCombobox } from "./provider-combobox" import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label" import {
import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry" getCanonicalProviderKey,
getProviderCatalogEntry,
getProviderCatalogMap,
getProviderDefaultAPIBase,
getProviderDefaultAuthMethod,
isProviderAuthMethodLocked,
providerSupportsFetch,
} from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog" import { TestModelDialog } from "./test-model-dialog"
interface AddForm { interface AddForm {
@@ -82,37 +94,6 @@ const EMPTY_ADD_FORM: AddForm = {
customHeaders: "", customHeaders: "",
} }
function normalizeApiBase(value: string): string {
return value.trim().replace(/\/+$/, "")
}
function getNextApiBaseForProviderChange(
currentApiBase: string,
currentProvider: string,
nextProvider: string,
): string {
const normalizedCurrentApiBase = normalizeApiBase(currentApiBase)
const currentDefaultApiBase = normalizeApiBase(
PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "",
)
const nextDefaultApiBase =
PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? ""
if (!normalizedCurrentApiBase) {
return nextDefaultApiBase
}
if (
normalizedCurrentApiBase &&
currentDefaultApiBase &&
normalizedCurrentApiBase === currentDefaultApiBase
) {
return nextDefaultApiBase
}
return currentApiBase
}
interface AddModelSheetProps { interface AddModelSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
@@ -144,6 +125,7 @@ export function AddModelSheet({
const [catalogModels, setCatalogModels] = useState<string[]>([]) const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined) const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null) const scrollContainerRef = useRef<HTMLDivElement>(null)
const providerMap = getProviderCatalogMap(providerOptions)
const apiKeyPlaceholder = maskedSecretPlaceholder( const apiKeyPlaceholder = maskedSecretPlaceholder(
form.apiKey, form.apiKey,
@@ -166,8 +148,12 @@ export function AddModelSheet({
// Load catalog models when provider or apiBase changes // Load catalog models when provider or apiBase changes
useEffect(() => { useEffect(() => {
const providerKey = getProviderKey(form.provider || undefined) const providerKey = getCanonicalProviderKey(form.provider, providerOptions)
const apiBase = form.apiBase.trim().replace(/\/+$/, "") const apiBase = getEffectiveAPIBase(
form.provider,
form.apiBase,
providerOptions,
)
if (!form.provider.trim()) { if (!form.provider.trim()) {
setCatalogModels([]) setCatalogModels([])
return return
@@ -177,7 +163,7 @@ export function AddModelSheet({
.then((res) => { .then((res) => {
if (cancelled) return if (cancelled) return
const matched = (res.entries || []).filter((e) => { const matched = (res.entries || []).filter((e) => {
const ep = getProviderKey(e.provider || undefined) const ep = getCanonicalProviderKey(e.provider, providerOptions)
const eb = (e.api_base ?? "").trim().replace(/\/+$/, "") const eb = (e.api_base ?? "").trim().replace(/\/+$/, "")
return ep === providerKey && eb === apiBase return ep === providerKey && eb === apiBase
}) })
@@ -189,7 +175,7 @@ export function AddModelSheet({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [form.provider, form.apiBase]) }, [form.provider, form.apiBase, providerOptions])
const validate = (): boolean => { const validate = (): boolean => {
const errors: Partial<Record<keyof AddForm, string>> = {} const errors: Partial<Record<keyof AddForm, string>> = {}
@@ -199,6 +185,9 @@ export function AddModelSheet({
} else if (existingModelNames.some((name) => name.trim() === modelName)) { } else if (existingModelNames.some((name) => name.trim() === modelName)) {
errors.modelName = t("models.add.errorDuplicateModelName") errors.modelName = t("models.add.errorDuplicateModelName")
} }
if (!providerDef) {
errors.provider = t("models.field.providerInvalid")
}
if (!form.model.trim()) errors.model = t("models.add.errorRequired") if (!form.model.trim()) errors.model = t("models.add.errorRequired")
if (modelValidation?.level === "error") { if (modelValidation?.level === "error") {
errors.model = t( errors.model = t(
@@ -223,11 +212,15 @@ export function AddModelSheet({
(value: string, provider: string) => { (value: string, provider: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current) if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
const result = validateModelField(value, provider || undefined) const result = validateModelField(
value,
provider || undefined,
providerOptions,
)
setModelValidation(result) setModelValidation(result)
}, 300) }, 300)
}, },
[], [providerOptions],
) )
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -241,14 +234,41 @@ export function AddModelSheet({
const handleProviderChange = (provider: string) => { const handleProviderChange = (provider: string) => {
setForm((f) => { setForm((f) => {
const previousOption = getProviderCatalogEntry(
f.provider,
providerOptions,
)
const nextOption = getProviderCatalogEntry(provider, providerOptions)
const previousDefaultBase = normalizeApiBase(
getProviderDefaultAPIBase(f.provider, providerOptions),
)
const nextDefaultBase = normalizeApiBase(
getProviderDefaultAPIBase(provider, providerOptions),
)
const currentApiBase = normalizeApiBase(f.apiBase)
let authMethod = f.authMethod
let apiBase = f.apiBase
if (nextOption?.authMethodLocked) {
authMethod = nextOption.defaultAuthMethod ?? ""
} else if (
previousOption?.authMethodLocked &&
f.authMethod === (previousOption.defaultAuthMethod ?? "")
) {
authMethod = ""
}
if (
currentApiBase &&
previousDefaultBase &&
currentApiBase === previousDefaultBase &&
currentApiBase !== nextDefaultBase
) {
apiBase = ""
}
return { return {
...f, ...f,
provider, provider: getCanonicalProviderKey(provider, providerOptions),
apiBase: getNextApiBaseForProviderChange( apiBase,
f.apiBase, authMethod,
f.provider,
provider,
),
} }
}) })
// Re-validate model with new provider context // Re-validate model with new provider context
@@ -257,11 +277,14 @@ export function AddModelSheet({
} }
// Clear setAsDefault if the new provider doesn't support being default // Clear setAsDefault if the new provider doesn't support being default
const allowed = const allowed =
providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? getProviderCatalogEntry(provider, providerOptions)?.defaultModelAllowed ??
false false
if (!allowed) { if (!allowed) {
setSetAsDefault(false) setSetAsDefault(false)
} }
if (fieldErrors.provider) {
setFieldErrors((prev) => ({ ...prev, provider: undefined }))
}
} }
const applyFix = () => { const applyFix = () => {
@@ -290,12 +313,38 @@ export function AddModelSheet({
} }
} }
const providerDef = PROVIDER_MAP.get(form.provider) const canonicalProvider = getCanonicalProviderKey(
form.provider,
providerOptions,
)
const providerDef = canonicalProvider
? providerMap.get(canonicalProvider)
: undefined
const commonModels = providerDef?.commonModels || [] const commonModels = providerDef?.commonModels || []
const defaultModelAllowed = form.provider const authMethodLocked = isProviderAuthMethodLocked(
? (providerOptions?.find((o) => o.id === form.provider) form.provider,
?.default_model_allowed ?? false) providerOptions,
: false )
const defaultAuthMethod = getProviderDefaultAuthMethod(
form.provider,
providerOptions,
)
const effectiveAuthMethod = (
authMethodLocked ? defaultAuthMethod : form.authMethod
)
.trim()
.toLowerCase()
const isOAuth = effectiveAuthMethod === "oauth"
const defaultModelAllowed = providerDef?.defaultModelAllowed === true
const apiBasePlaceholder =
getProviderDefaultAPIBase(form.provider, providerOptions) ||
"https://api.example.com/v1"
const effectiveApiBase = getEffectiveAPIBase(
form.provider,
form.apiBase,
providerOptions,
)
const submittedApiBase = getSubmittedAPIBase(form.apiBase)
const handleSave = async () => { const handleSave = async () => {
if (!validate()) return if (!validate()) return
@@ -331,16 +380,18 @@ export function AddModelSheet({
setServerError("") setServerError("")
try { try {
const modelName = form.modelName.trim() const modelName = form.modelName.trim()
const provider = form.provider.trim() const provider = canonicalProvider
const modelId = form.model.trim() const modelId = form.model.trim()
await addModel({ await addModel({
model_name: modelName, model_name: modelName,
provider: provider || undefined, provider: provider || undefined,
model: modelId, model: modelId,
api_base: form.apiBase.trim() || undefined, api_base: submittedApiBase,
api_key: form.apiKey.trim() || undefined, api_key: form.apiKey.trim() || undefined,
proxy: form.proxy.trim() || undefined, proxy: form.proxy.trim() || undefined,
auth_method: form.authMethod.trim() || undefined, auth_method: authMethodLocked
? defaultAuthMethod || undefined
: form.authMethod.trim() || undefined,
connect_mode: form.connectMode.trim() || undefined, connect_mode: form.connectMode.trim() || undefined,
workspace: form.workspace.trim() || undefined, workspace: form.workspace.trim() || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined, rpm: form.rpm ? Number(form.rpm) : undefined,
@@ -414,6 +465,8 @@ export function AddModelSheet({
<Field <Field
label={t("models.field.provider")} label={t("models.field.provider")}
hint={t("models.field.providerHint")} hint={t("models.field.providerHint")}
error={fieldErrors.provider}
required
> >
<ProviderCombobox <ProviderCombobox
value={form.provider} value={form.provider}
@@ -517,18 +570,17 @@ export function AddModelSheet({
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{form.provider && {providerSupportsFetch(form.provider, providerOptions) && (
FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" className="h-7 text-xs"
className="h-7 text-xs" onClick={() => setFetchOpen(true)}
onClick={() => setFetchOpen(true)} >
> <IconDownload className="size-3" />
<IconDownload className="size-3" /> {t("models.fetch.title")}
{t("models.fetch.title")} </Button>
</Button> )}
)}
{!form.provider && ( {!form.provider && (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{t("models.field.selectProviderFirst")} {t("models.field.selectProviderFirst")}
@@ -537,19 +589,25 @@ export function AddModelSheet({
</div> </div>
</Field> </Field>
<Field label={t("models.field.apiKey")}> {!isOAuth && (
<KeyInput <Field label={t("models.field.apiKey")}>
value={form.apiKey} <KeyInput
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))} value={form.apiKey}
placeholder={apiKeyPlaceholder} onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
/> placeholder={apiKeyPlaceholder}
</Field> />
</Field>
)}
<Field label={t("models.field.apiBase")}> <Field
label={t("models.field.apiBase")}
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
>
<Input <Input
value={form.apiBase} value={form.apiBase}
onChange={setField("apiBase")} onChange={setField("apiBase")}
placeholder="https://api.example.com/v1" placeholder={apiBasePlaceholder}
disabled={isOAuth}
/> />
</Field> </Field>
@@ -591,12 +649,19 @@ export function AddModelSheet({
<Field <Field
label={t("models.field.authMethod")} label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")} hint={
authMethodLocked
? t("models.field.authMethodManagedHint")
: t("models.field.authMethodHint")
}
> >
<Input <Input
value={form.authMethod} value={
authMethodLocked ? defaultAuthMethod : form.authMethod
}
onChange={setField("authMethod")} onChange={setField("authMethod")}
placeholder="oauth" placeholder="oauth"
disabled={authMethodLocked}
/> />
</Field> </Field>
@@ -751,9 +816,10 @@ export function AddModelSheet({
open={fetchOpen} open={fetchOpen}
onClose={() => setFetchOpen(false)} onClose={() => setFetchOpen(false)}
onFill={handleFetchFill} onFill={handleFetchFill}
provider={form.provider} provider={canonicalProvider}
apiKey={form.apiKey} apiKey={form.apiKey}
apiBase={form.apiBase} apiBase={effectiveApiBase}
backendOptions={providerOptions}
/> />
<TestModelDialog <TestModelDialog
@@ -761,11 +827,11 @@ export function AddModelSheet({
open={testOpen} open={testOpen}
onClose={() => setTestOpen(false)} onClose={() => setTestOpen(false)}
inlineParams={{ inlineParams={{
provider: form.provider, provider: canonicalProvider,
model: form.model, model: form.model,
apiBase: form.apiBase, apiBase: effectiveApiBase,
apiKey: form.apiKey, apiKey: form.apiKey,
authMethod: form.authMethod, authMethod: effectiveAuthMethod,
}} }}
/> />
</Sheet> </Sheet>
@@ -11,6 +11,7 @@ import { toast } from "sonner"
import { import {
type CatalogEntry, type CatalogEntry,
type CatalogModel, type CatalogModel,
type ModelProviderOption,
addModel, addModel,
deleteCatalog, deleteCatalog,
getCatalogs, getCatalogs,
@@ -27,21 +28,26 @@ import {
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { refreshGatewayState } from "@/store/gateway" import { refreshGatewayState } from "@/store/gateway"
import { getProviderLabel } from "./provider-label" import {
import { PROVIDER_MAP } from "./provider-registry" getCanonicalProviderKey,
getProviderCatalogMap,
} from "./provider-registry"
interface CatalogDialogProps { interface CatalogDialogProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
onModelAdded: () => void onModelAdded: () => void
providerOptions?: ModelProviderOption[]
} }
export function CatalogDialog({ export function CatalogDialog({
open, open,
onClose, onClose,
onModelAdded, onModelAdded,
providerOptions,
}: CatalogDialogProps) { }: CatalogDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const providerMap = getProviderCatalogMap(providerOptions)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [entries, setEntries] = useState<CatalogEntry[]>([]) const [entries, setEntries] = useState<CatalogEntry[]>([])
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -188,6 +194,11 @@ export function CatalogDialog({
const isExpanded = expandedId === entry.id const isExpanded = expandedId === entry.id
const entrySelected = selected.get(entry.id) || new Set() const entrySelected = selected.get(entry.id) || new Set()
const filteredModels = getFilteredModels(entry.models) const filteredModels = getFilteredModels(entry.models)
const providerKey = getCanonicalProviderKey(
entry.provider,
providerOptions,
)
const providerDef = providerMap.get(providerKey)
return ( return (
<div <div
@@ -206,7 +217,7 @@ export function CatalogDialog({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{getProviderLabel(entry.provider)} {providerDef?.label || providerKey}
</span> </span>
<span className="text-muted-foreground font-mono text-xs"> <span className="text-muted-foreground font-mono text-xs">
{entry.api_key_mask} {entry.api_key_mask}
@@ -290,7 +301,7 @@ export function CatalogDialog({
</div> </div>
{entrySelected.size > 0 && ( {entrySelected.size > 0 && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{PROVIDER_MAP.get(entry.provider)?.requiresApiKey !== {providerDef?.requiresApiKey !==
false && ( false && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-2 text-xs text-yellow-700 dark:text-yellow-400"> <div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-2 text-xs text-yellow-700 dark:text-yellow-400">
{t("models.catalog.needApiKey")} {t("models.catalog.needApiKey")}
@@ -37,13 +37,21 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway" import { refreshGatewayState } from "@/store/gateway"
import { FetchModelsDialog } from "./fetch-models-dialog" import { FetchModelsDialog } from "./fetch-models-dialog"
import {
getEffectiveAPIBase,
getSubmittedAPIBase,
normalizeApiBase,
} from "./model-provider-form-shared"
import { type FieldValidation, validateModelField } from "./model-validation" import { type FieldValidation, validateModelField } from "./model-validation"
import { ProviderCombobox } from "./provider-combobox" import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label"
import { import {
FETCHABLE_PROVIDER_KEYS, getCanonicalProviderKey,
PROVIDER_API_BASES, getProviderCatalogEntry,
PROVIDER_MAP, getProviderCatalogMap,
getProviderDefaultAPIBase,
getProviderDefaultAuthMethod,
isProviderAuthMethodLocked,
providerSupportsFetch,
} from "./provider-registry" } from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog" import { TestModelDialog } from "./test-model-dialog"
@@ -74,39 +82,9 @@ interface EditModelSheetProps {
providerOptions?: ModelProviderOption[] providerOptions?: ModelProviderOption[]
} }
function normalizeApiBase(value: string): string {
return value.trim().replace(/\/+$/, "")
}
function getNextApiBaseForProviderChange(
currentApiBase: string,
currentProvider: string,
nextProvider: string,
): string {
const normalizedCurrentApiBase = normalizeApiBase(currentApiBase)
const currentDefaultApiBase = normalizeApiBase(
PROVIDER_API_BASES[currentProvider] || "",
)
const nextDefaultApiBase = PROVIDER_API_BASES[nextProvider] || ""
if (!normalizedCurrentApiBase) {
return nextDefaultApiBase
}
if (
normalizedCurrentApiBase &&
currentDefaultApiBase &&
normalizedCurrentApiBase === currentDefaultApiBase
) {
return nextDefaultApiBase
}
return currentApiBase
}
function buildInitialEditForm(model: ModelInfo): EditForm { function buildInitialEditForm(model: ModelInfo): EditForm {
return { return {
provider: model.provider ?? "", provider: getCanonicalProviderKey(model.provider),
modelId: model.model, modelId: model.model,
apiKey: "", apiKey: "",
apiBase: model.api_base ?? "", apiBase: model.api_base ?? "",
@@ -166,6 +144,7 @@ export function EditModelSheet({
const [catalogModels, setCatalogModels] = useState<string[]>([]) const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined) const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null) const scrollContainerRef = useRef<HTMLDivElement>(null)
const providerMap = getProviderCatalogMap(providerOptions)
const initialForm = model ? buildInitialEditForm(model) : null const initialForm = model ? buildInitialEditForm(model) : null
const isDirty = const isDirty =
@@ -182,12 +161,19 @@ export function EditModelSheet({
setFetchedModels([]) setFetchedModels([])
setCatalogModels([]) setCatalogModels([])
// Load matching catalog models // Load matching catalog models
const providerKey = getProviderKey(model.provider || undefined) const providerKey = getCanonicalProviderKey(
const apiBase = (model.api_base ?? "").trim().replace(/\/+$/, "") model.provider,
providerOptions,
)
const apiBase = getEffectiveAPIBase(
model.provider ?? "",
model.api_base ?? "",
providerOptions,
)
getCatalogs() getCatalogs()
.then((res) => { .then((res) => {
const matched = (res.entries || []).filter((e) => { const matched = (res.entries || []).filter((e) => {
const ep = getProviderKey(e.provider || undefined) const ep = getCanonicalProviderKey(e.provider, providerOptions)
const eb = (e.api_base ?? "").trim().replace(/\/+$/, "") const eb = (e.api_base ?? "").trim().replace(/\/+$/, "")
return ep === providerKey && eb === apiBase return ep === providerKey && eb === apiBase
}) })
@@ -197,22 +183,28 @@ export function EditModelSheet({
}) })
.catch(() => {}) .catch(() => {})
} }
}, [model]) }, [model, providerOptions])
const setField = const setField =
(key: keyof EditForm) => (key: keyof EditForm) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (error) setError("")
setForm((f) => ({ ...f, [key]: e.target.value })) setForm((f) => ({ ...f, [key]: e.target.value }))
}
const debouncedValidateModel = useCallback( const debouncedValidateModel = useCallback(
(value: string, provider: string) => { (value: string, provider: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current) if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
const result = validateModelField(value, provider || undefined) const result = validateModelField(
value,
provider || undefined,
providerOptions,
)
setModelValidation(result) setModelValidation(result)
}, 300) }, 300)
}, },
[], [providerOptions],
) )
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -222,16 +214,50 @@ export function EditModelSheet({
} }
const handleProviderChange = (provider: string) => { const handleProviderChange = (provider: string) => {
setForm((f) => ({ if (error) setError("")
...f, setForm((f) => {
provider, const previousOption = getProviderCatalogEntry(
apiBase: getNextApiBaseForProviderChange(f.apiBase, f.provider, provider), f.provider,
})) providerOptions,
)
const nextOption = getProviderCatalogEntry(provider, providerOptions)
const previousDefaultBase = normalizeApiBase(
getProviderDefaultAPIBase(f.provider, providerOptions),
)
const nextDefaultBase = normalizeApiBase(
getProviderDefaultAPIBase(provider, providerOptions),
)
const currentApiBase = normalizeApiBase(f.apiBase)
let authMethod = f.authMethod
let apiBase = f.apiBase
if (nextOption?.authMethodLocked) {
authMethod = nextOption.defaultAuthMethod ?? ""
} else if (
previousOption?.authMethodLocked &&
f.authMethod === (previousOption.defaultAuthMethod ?? "")
) {
authMethod = ""
}
if (
currentApiBase &&
previousDefaultBase &&
currentApiBase === previousDefaultBase &&
currentApiBase !== nextDefaultBase
) {
apiBase = ""
}
return {
...f,
provider: getCanonicalProviderKey(provider, providerOptions),
apiBase,
authMethod,
}
})
if (form.modelId) { if (form.modelId) {
debouncedValidateModel(form.modelId, provider) debouncedValidateModel(form.modelId, provider)
} }
const allowed = const allowed =
providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? getProviderCatalogEntry(provider, providerOptions)?.defaultModelAllowed ??
false false
if (!allowed) { if (!allowed) {
setSetAsDefault(false) setSetAsDefault(false)
@@ -258,15 +284,45 @@ export function EditModelSheet({
} }
} }
const providerDef = PROVIDER_MAP.get(form.provider) const canonicalProvider = getCanonicalProviderKey(
form.provider,
providerOptions,
)
const providerDef = canonicalProvider
? providerMap.get(canonicalProvider)
: undefined
const commonModels = providerDef?.commonModels || [] const commonModels = providerDef?.commonModels || []
const defaultModelAllowed = form.provider const authMethodLocked = isProviderAuthMethodLocked(
? (providerOptions?.find((o) => o.id === form.provider) form.provider,
?.default_model_allowed ?? false) providerOptions,
: false )
const defaultAuthMethod = getProviderDefaultAuthMethod(
form.provider,
providerOptions,
)
const effectiveAuthMethod = (
authMethodLocked ? defaultAuthMethod : form.authMethod
)
.trim()
.toLowerCase()
const isOAuth = effectiveAuthMethod === "oauth"
const defaultModelAllowed = providerDef?.defaultModelAllowed === true
const apiBasePlaceholder =
getProviderDefaultAPIBase(form.provider, providerOptions) ||
"https://api.example.com/v1"
const effectiveApiBase = getEffectiveAPIBase(
form.provider,
form.apiBase,
providerOptions,
)
const submittedApiBase = getSubmittedAPIBase(form.apiBase)
const handleSave = async () => { const handleSave = async () => {
if (!model) return if (!model) return
if (!providerDef) {
setError(t("models.field.providerInvalid"))
return
}
if (!form.modelId.trim()) { if (!form.modelId.trim()) {
setError(t("models.add.errorRequired")) setError(t("models.add.errorRequired"))
return return
@@ -304,7 +360,7 @@ export function EditModelSheet({
setError("") setError("")
try { try {
const modelId = form.modelId.trim() const modelId = form.modelId.trim()
const provider = form.provider.trim() const provider = canonicalProvider
const streaming = const streaming =
model.streaming?.enabled === true || form.streamingEnabled model.streaming?.enabled === true || form.streamingEnabled
? { enabled: form.streamingEnabled } ? { enabled: form.streamingEnabled }
@@ -313,18 +369,20 @@ export function EditModelSheet({
model_name: model.model_name, model_name: model.model_name,
provider: provider, provider: provider,
model: modelId, model: modelId,
api_base: form.apiBase || undefined, api_base: submittedApiBase,
api_key: form.apiKey || undefined, api_key: form.apiKey.trim() || undefined,
proxy: form.proxy || undefined, proxy: form.proxy.trim() || undefined,
auth_method: form.authMethod || undefined, auth_method: authMethodLocked
connect_mode: form.connectMode || undefined, ? defaultAuthMethod || undefined
workspace: form.workspace || undefined, : form.authMethod.trim() || undefined,
connect_mode: form.connectMode.trim() || undefined,
workspace: form.workspace.trim() || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined, rpm: form.rpm ? Number(form.rpm) : undefined,
max_tokens_field: form.maxTokensField || undefined, max_tokens_field: form.maxTokensField.trim() || undefined,
request_timeout: form.requestTimeout request_timeout: form.requestTimeout
? Number(form.requestTimeout) ? Number(form.requestTimeout)
: undefined, : undefined,
thinking_level: form.thinkingLevel || undefined, thinking_level: form.thinkingLevel.trim() || undefined,
tool_schema_transform: form.toolSchemaTransform.trim() || undefined, tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
streaming, streaming,
extra_body: extraBody, extra_body: extraBody,
@@ -349,7 +407,6 @@ export function EditModelSheet({
} }
} }
const isOAuth = model?.auth_method === "oauth"
const hasSavedAPIKey = Boolean(model?.api_key) const hasSavedAPIKey = Boolean(model?.api_key)
const apiKeyPlaceholder = hasSavedAPIKey const apiKeyPlaceholder = hasSavedAPIKey
? maskedSecretPlaceholder( ? maskedSecretPlaceholder(
@@ -382,6 +439,12 @@ export function EditModelSheet({
<Field <Field
label={t("models.field.provider")} label={t("models.field.provider")}
hint={t("models.field.providerHint")} hint={t("models.field.providerHint")}
error={
!providerDef && form.provider
? t("models.field.providerInvalid")
: undefined
}
required
> >
<ProviderCombobox <ProviderCombobox
value={form.provider} value={form.provider}
@@ -477,18 +540,17 @@ export function EditModelSheet({
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{form.provider && {providerSupportsFetch(form.provider, providerOptions) && (
FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" className="h-7 text-xs"
className="h-7 text-xs" onClick={() => setFetchOpen(true)}
onClick={() => setFetchOpen(true)} >
> <IconDownload className="size-3" />
<IconDownload className="size-3" /> {t("models.fetch.title")}
{t("models.fetch.title")} </Button>
</Button> )}
)}
</div> </div>
</Field> </Field>
@@ -514,7 +576,7 @@ export function EditModelSheet({
<Input <Input
value={form.apiBase} value={form.apiBase}
onChange={setField("apiBase")} onChange={setField("apiBase")}
placeholder="https://api.example.com/v1" placeholder={apiBasePlaceholder}
disabled={isOAuth} disabled={isOAuth}
/> />
</Field> </Field>
@@ -557,12 +619,19 @@ export function EditModelSheet({
<Field <Field
label={t("models.field.authMethod")} label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")} hint={
authMethodLocked
? t("models.field.authMethodManagedHint")
: t("models.field.authMethodHint")
}
> >
<Input <Input
value={form.authMethod} value={
authMethodLocked ? defaultAuthMethod : form.authMethod
}
onChange={setField("authMethod")} onChange={setField("authMethod")}
placeholder="oauth" placeholder="oauth"
disabled={authMethodLocked}
/> />
</Field> </Field>
@@ -719,11 +788,11 @@ export function EditModelSheet({
open={testOpen} open={testOpen}
onClose={() => setTestOpen(false)} onClose={() => setTestOpen(false)}
inlineParams={{ inlineParams={{
provider: form.provider, provider: canonicalProvider,
model: form.modelId, model: form.modelId,
apiBase: form.apiBase, apiBase: effectiveApiBase,
apiKey: form.apiKey, apiKey: form.apiKey,
authMethod: form.authMethod, authMethod: effectiveAuthMethod,
modelIndex: model?.index, modelIndex: model?.index,
}} }}
/> />
@@ -732,9 +801,10 @@ export function EditModelSheet({
open={fetchOpen} open={fetchOpen}
onClose={() => setFetchOpen(false)} onClose={() => setFetchOpen(false)}
onFill={handleFetchFill} onFill={handleFetchFill}
provider={form.provider} provider={canonicalProvider}
apiKey={form.apiKey} apiKey={form.apiKey}
apiBase={form.apiBase} apiBase={effectiveApiBase}
backendOptions={providerOptions}
/> />
</> </>
) )
@@ -2,7 +2,11 @@ import { IconDownload, IconLoader2 } from "@tabler/icons-react"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { type UpstreamModel, fetchUpstreamModels } from "@/api/models" import {
type ModelProviderOption,
type UpstreamModel,
fetchUpstreamModels,
} from "@/api/models"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -14,7 +18,10 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PROVIDER_MAP } from "./provider-registry" import {
getCanonicalProviderKey,
getProviderCatalogMap,
} from "./provider-registry"
interface FetchModelsDialogProps { interface FetchModelsDialogProps {
open: boolean open: boolean
@@ -23,6 +30,7 @@ interface FetchModelsDialogProps {
provider: string provider: string
apiKey: string apiKey: string
apiBase: string apiBase: string
backendOptions?: ModelProviderOption[]
} }
export function FetchModelsDialog({ export function FetchModelsDialog({
@@ -32,6 +40,7 @@ export function FetchModelsDialog({
provider, provider,
apiKey, apiKey,
apiBase, apiBase,
backendOptions,
}: FetchModelsDialogProps) { }: FetchModelsDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [fetching, setFetching] = useState(false) const [fetching, setFetching] = useState(false)
@@ -40,7 +49,8 @@ export function FetchModelsDialog({
const [error, setError] = useState("") const [error, setError] = useState("")
const [filter, setFilter] = useState("") const [filter, setFilter] = useState("")
const providerDef = PROVIDER_MAP.get(provider) const canonicalProvider = getCanonicalProviderKey(provider, backendOptions)
const providerDef = getProviderCatalogMap(backendOptions).get(canonicalProvider)
const needsKey = providerDef?.requiresApiKey !== false const needsKey = providerDef?.requiresApiKey !== false
const handleFetch = useCallback(async () => { const handleFetch = useCallback(async () => {
@@ -50,7 +60,7 @@ export function FetchModelsDialog({
setSelected(new Set()) setSelected(new Set())
try { try {
const res = await fetchUpstreamModels({ const res = await fetchUpstreamModels({
provider, provider: canonicalProvider,
api_key: apiKey, api_key: apiKey,
api_base: apiBase, api_base: apiBase,
}) })
@@ -62,7 +72,7 @@ export function FetchModelsDialog({
} finally { } finally {
setFetching(false) setFetching(false)
} }
}, [provider, apiKey, apiBase, t]) }, [canonicalProvider, apiKey, apiBase, t])
// Auto-fetch when dialog opens (skip if provider requires API key but none is set) // Auto-fetch when dialog opens (skip if provider requires API key but none is set)
useEffect(() => { useEffect(() => {
@@ -122,7 +132,7 @@ export function FetchModelsDialog({
{t("models.fetch.description")} {t("models.fetch.description")}
{provider && ( {provider && (
<span className="mt-1 block font-mono text-xs"> <span className="mt-1 block font-mono text-xs">
{t("models.fetch.providerLabel")} {provider} {t("models.fetch.providerLabel")} {canonicalProvider}
{apiBase && ` | ${apiBase}`} {apiBase && ` | ${apiBase}`}
</span> </span>
)} )}
@@ -0,0 +1,21 @@
import type { ModelProviderOption } from "@/api/models"
import { getProviderDefaultAPIBase } from "./provider-registry"
export function normalizeApiBase(value: string): string {
return value.trim().replace(/\/+$/, "")
}
export function getEffectiveAPIBase(
provider: string,
apiBase: string,
providerOptions?: ModelProviderOption[],
): string {
return normalizeApiBase(
apiBase || getProviderDefaultAPIBase(provider, providerOptions),
)
}
export function getSubmittedAPIBase(apiBase: string): string | undefined {
return normalizeApiBase(apiBase) || undefined
}
@@ -5,10 +5,12 @@
* Messages use i18n keys with interpolation params callers must * Messages use i18n keys with interpolation params callers must
* translate them via t(key, params). * translate them via t(key, params).
*/ */
import type { ModelProviderOption } from "@/api/models"
import { import {
KNOWN_PROVIDER_KEYS,
PROVIDER_ALIASES,
findClosestProvider, findClosestProvider,
getCanonicalProviderKey,
getKnownProviderKeys,
} from "./provider-registry" } from "./provider-registry"
export type ValidationLevel = "error" | "warning" | "success" export type ValidationLevel = "error" | "warning" | "success"
@@ -27,9 +29,11 @@ export interface FieldValidation {
export function validateModelField( export function validateModelField(
input: string, input: string,
selectedProvider?: string, selectedProvider?: string,
backendOptions?: ModelProviderOption[],
): FieldValidation { ): FieldValidation {
const trimmed = input.trim() const trimmed = input.trim()
if (!trimmed) return { level: "success", messageKey: "" } if (!trimmed) return { level: "success", messageKey: "" }
const knownProviderKeys = getKnownProviderKeys(backendOptions)
// Hard errors // Hard errors
if (/\s/.test(trimmed)) { if (/\s/.test(trimmed)) {
@@ -78,10 +82,10 @@ export function validateModelField(
return { level: "error", messageKey: "models.validation.emptyModel" } return { level: "error", messageKey: "models.validation.emptyModel" }
} }
if (!KNOWN_PROVIDER_KEYS.has(provider)) { if (!knownProviderKeys.has(provider)) {
// Check aliases // Check aliases
const alias = PROVIDER_ALIASES[provider] const alias = getCanonicalProviderKey(provider, backendOptions)
if (alias) { if (alias && alias !== provider) {
return { return {
level: "warning", level: "warning",
messageKey: "models.validation.shouldUse", messageKey: "models.validation.shouldUse",
@@ -90,7 +94,7 @@ export function validateModelField(
} }
} }
// Typo check // Typo check
const closest = findClosestProvider(provider) const closest = findClosestProvider(provider, backendOptions)
if (closest) { if (closest) {
return { return {
level: "warning", level: "warning",
@@ -23,13 +23,16 @@ import { AddModelSheet } from "./add-model-sheet"
import { CatalogDialog } from "./catalog-dialog" import { CatalogDialog } from "./catalog-dialog"
import { DeleteModelDialog } from "./delete-model-dialog" import { DeleteModelDialog } from "./delete-model-dialog"
import { EditModelSheet } from "./edit-model-sheet" import { EditModelSheet } from "./edit-model-sheet"
import { getProviderKey, getProviderLabel } from "./provider-label" import {
import { PROVIDER_PRIORITY } from "./provider-registry" getCanonicalProviderKey,
getProviderCatalogMap,
} from "./provider-registry"
import { ProviderSection } from "./provider-section" import { ProviderSection } from "./provider-section"
import type { ProviderCatalogEntry } from "./provider-registry"
interface ProviderGroup { interface ProviderGroup {
key: string key: string
label: string provider: Pick<ProviderCatalogEntry, "key" | "label" | "iconSlug" | "domain">
models: ModelInfo[] models: ModelInfo[]
hasDefault: boolean hasDefault: boolean
availableCount: number availableCount: number
@@ -51,8 +54,10 @@ export function ModelsPage() {
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>( const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
null, null,
) )
const providerMap = getProviderCatalogMap(providerOptions)
const fetchModels = useCallback(async () => { const fetchModels = useCallback(async () => {
setLoading(true)
try { try {
const data = await getModels() const data = await getModels()
const sorted = [...data.models].sort((a, b) => { const sorted = [...data.models].sort((a, b) => {
@@ -97,12 +102,21 @@ export function ModelsPage() {
} }
} }
const grouped: Record<string, { label: string; models: ModelInfo[] }> = {} const grouped: Record<
string,
{ provider: Pick<ProviderCatalogEntry, "key" | "label" | "iconSlug" | "domain">; models: ModelInfo[] }
> = {}
for (const model of models) { for (const model of models) {
const providerKey = getProviderKey(model.provider) const providerKey = getCanonicalProviderKey(model.provider, providerOptions)
const providerDef = providerKey ? providerMap.get(providerKey) : undefined
if (!grouped[providerKey]) { if (!grouped[providerKey]) {
grouped[providerKey] = { grouped[providerKey] = {
label: getProviderLabel(model.provider), provider: {
key: providerKey,
label: providerDef?.label || providerKey,
iconSlug: providerDef?.iconSlug,
domain: providerDef?.domain,
},
models: [], models: [],
} }
} }
@@ -116,7 +130,7 @@ export function ModelsPage() {
).length ).length
return { return {
key, key,
label: group.label, provider: group.provider,
models: group.models, models: group.models,
hasDefault: group.models.some((model) => model.is_default), hasDefault: group.models.some((model) => model.is_default),
availableCount, availableCount,
@@ -130,13 +144,13 @@ export function ModelsPage() {
return b.availableCount - a.availableCount return b.availableCount - a.availableCount
} }
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER const aPriority = -(providerMap.get(a.key)?.priority ?? 0)
const bPriority = PROVIDER_PRIORITY[b.key] ?? Number.MAX_SAFE_INTEGER const bPriority = -(providerMap.get(b.key)?.priority ?? 0)
if (aPriority !== bPriority) { if (aPriority !== bPriority) {
return aPriority - bPriority return aPriority - bPriority
} }
return a.label.localeCompare(b.label) return a.provider.label.localeCompare(b.provider.label)
}) })
const defaultModel = models.find((model) => model.is_default) const defaultModel = models.find((model) => model.is_default)
@@ -149,11 +163,17 @@ export function ModelsPage() {
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setCatalogOpen(true)} onClick={() => setCatalogOpen(true)}
disabled={providerOptions.length === 0}
> >
<IconDatabase className="size-4" /> <IconDatabase className="size-4" />
{t("models.catalog.button")} {t("models.catalog.button")}
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}> <Button
size="sm"
variant="outline"
onClick={() => setAddOpen(true)}
disabled={providerOptions.length === 0}
>
<IconPlus className="size-4" /> <IconPlus className="size-4" />
{t("models.add.button")} {t("models.add.button")}
</Button> </Button>
@@ -172,6 +192,11 @@ export function ModelsPage() {
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
{t("models.description")} {t("models.description")}
</p> </p>
{!loading && providerOptions.length === 0 && (
<p className="text-muted-foreground mt-1 text-sm">
{t("models.providerCatalogUnavailable")}
</p>
)}
</div> </div>
{loading && ( {loading && (
@@ -181,8 +206,19 @@ export function ModelsPage() {
)} )}
{fetchError && ( {fetchError && (
<div className="text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm"> <div className="bg-destructive/10 rounded-lg px-4 py-3 text-sm">
{fetchError} <p className="text-destructive">{fetchError}</p>
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
void fetchModels()
}}
>
{t("models.retry")}
</Button>
</div>
</div> </div>
)} )}
@@ -191,8 +227,7 @@ export function ModelsPage() {
{providerGroups.map((providerGroup) => ( {providerGroups.map((providerGroup) => (
<ProviderSection <ProviderSection
key={providerGroup.key} key={providerGroup.key}
provider={providerGroup.label} provider={providerGroup.provider}
providerKey={providerGroup.key}
models={providerGroup.models} models={providerGroup.models}
onEdit={setEditingModel} onEdit={setEditingModel}
onSetDefault={handleSetDefault} onSetDefault={handleSetDefault}
@@ -230,6 +265,7 @@ export function ModelsPage() {
open={catalogOpen} open={catalogOpen}
onClose={() => setCatalogOpen(false)} onClose={() => setCatalogOpen(false)}
onModelAdded={fetchModels} onModelAdded={fetchModels}
providerOptions={providerOptions}
/> />
</div> </div>
) )
@@ -11,7 +11,6 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command" } from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -21,9 +20,9 @@ import { cn } from "@/lib/utils"
import { ProviderIcon } from "./provider-icon" import { ProviderIcon } from "./provider-icon"
import { import {
type MergedProvider, getCanonicalProviderKey,
PROVIDERS, type ProviderCatalogEntry,
mergeWithBackendOptions, getProviderCatalog,
} from "./provider-registry" } from "./provider-registry"
import type { ModelProviderOption } from "@/api/models" import type { ModelProviderOption } from "@/api/models"
@@ -48,47 +47,23 @@ export function ProviderCombobox({
}: ProviderComboboxProps) { }: ProviderComboboxProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [customMode, setCustomMode] = useState(false)
const [customValue, setCustomValue] = useState("")
const [containerEl, setContainerEl] = useState<HTMLElement | null>(null) const [containerEl, setContainerEl] = useState<HTMLElement | null>(null)
useEffect(() => { useEffect(() => {
setContainerEl(containerRef?.current ?? null) setContainerEl(containerRef?.current ?? null)
}, [containerRef]) }, [containerRef])
const allProviders: MergedProvider[] = backendOptions const canonicalValue = getCanonicalProviderKey(value, backendOptions)
? mergeWithBackendOptions(backendOptions) const allProviders: ProviderCatalogEntry[] = getProviderCatalog(backendOptions)
: [...PROVIDERS]
.sort((a, b) => b.priority - a.priority)
.map((p) => ({
...p,
createAllowed: true,
defaultModelAllowed: false,
}))
const visible = filterCreateAllowed const visible = filterCreateAllowed
? allProviders.filter((p) => p.createAllowed) ? allProviders.filter((p) => p.createAllowed || p.key === canonicalValue)
: allProviders : allProviders
const allKeys = new Set(allProviders.map((p) => p.key)) const allKeys = new Set(allProviders.map((p) => p.key))
const selected = allProviders.find((p) => p.key === value) const selected = allProviders.find((p) => p.key === canonicalValue)
const isCustom = value && !allKeys.has(value) const showUnknownValue = value && !allKeys.has(canonicalValue)
const handleSelect = (currentValue: string) => { const handleSelect = (currentValue: string) => {
if (currentValue === "__custom__") { onChange(currentValue === canonicalValue ? "" : currentValue)
setCustomMode(true)
setCustomValue(isCustom ? value : "")
return
}
onChange(currentValue === value ? "" : currentValue)
setCustomMode(false)
setOpen(false)
}
const handleCustomConfirm = () => {
const trimmed = customValue.trim()
if (trimmed) {
onChange(trimmed)
}
setCustomMode(false)
setOpen(false) setOpen(false)
} }
@@ -97,7 +72,6 @@ export function ProviderCombobox({
open={open} open={open}
onOpenChange={(isOpen: boolean) => { onOpenChange={(isOpen: boolean) => {
setOpen(isOpen) setOpen(isOpen)
if (!isOpen) setCustomMode(false)
}} }}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -110,12 +84,11 @@ export function ProviderCombobox({
{selected ? ( {selected ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<ProviderIcon <ProviderIcon
providerKey={selected.key} provider={selected}
providerLabel={selected.label}
/> />
{selected.labelZh || selected.label} {selected.label}
</span> </span>
) : isCustom ? ( ) : showUnknownValue ? (
<span className="flex items-center gap-2 font-mono text-sm"> <span className="flex items-center gap-2 font-mono text-sm">
{value} {value}
</span> </span>
@@ -128,97 +101,52 @@ export function ProviderCombobox({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" container={containerEl}> <PopoverContent className="w-[--radix-popover-trigger-width] p-0" container={containerEl}>
{customMode ? ( <Command>
<div className="flex flex-col gap-2 p-2"> <CommandInput placeholder={t("models.combobox.searchProvider")} />
<Input <CommandList>
value={customValue} <CommandEmpty>
onChange={(e) => setCustomValue(e.target.value)} {backendOptions && backendOptions.length > 0
placeholder={t("models.combobox.customPlaceholder")} ? t("models.combobox.noProvider")
className="h-8 font-mono text-sm" : t("models.combobox.noCatalog")}
autoFocus </CommandEmpty>
onKeyDown={(e) => { <CommandGroup>
if (e.key === "Enter") handleCustomConfirm() {visible.map((provider) => {
if (e.key === "Escape") { const disabled = !provider.createAllowed && provider.key !== value
setCustomMode(false)
setOpen(false) return (
}
}}
/>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 flex-1 text-xs"
onClick={() => {
setCustomMode(false)
setOpen(false)
}}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="h-7 flex-1 text-xs"
onClick={handleCustomConfirm}
disabled={!customValue.trim()}
>
{t("common.confirm")}
</Button>
</div>
</div>
) : (
<Command>
<CommandInput placeholder={t("models.combobox.searchProvider")} />
<CommandList>
<CommandEmpty>{t("models.combobox.noProvider")}</CommandEmpty>
<CommandGroup>
{visible.map((provider) => (
<CommandItem <CommandItem
key={provider.key} key={provider.key}
value={provider.key} value={provider.key}
keywords={[ keywords={[
provider.label, provider.label,
provider.labelZh || "", ...provider.aliases,
...(provider.aliases || []),
]} ]}
onSelect={handleSelect} onSelect={handleSelect}
disabled={disabled}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<ProviderIcon <ProviderIcon
providerKey={provider.key} provider={provider}
providerLabel={provider.label}
/> />
<span>{provider.labelZh || provider.label}</span> <span>{provider.label}</span>
{provider.isLocal && ( {provider.isLocal && (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{t("models.combobox.local")} {t("models.combobox.local")}
</span> </span>
)} )}
</span> </span>
<IconCheck <IconCheck
className={cn( className={cn(
"ml-auto size-4", "ml-auto size-4",
value === provider.key ? "opacity-100" : "opacity-0", canonicalValue === provider.key ? "opacity-100" : "opacity-0",
)} )}
/> />
</CommandItem> </CommandItem>
))} )
<CommandItem })}
value="__custom__" </CommandGroup>
keywords={["custom", "自定义"]} </CommandList>
onSelect={handleSelect} </Command>
>
<span className="text-muted-foreground italic">
{t("models.combobox.custom")}
</span>
{isCustom && (
<IconCheck className="ml-auto size-4 opacity-100" />
)}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
)}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
@@ -1,22 +1,18 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { PROVIDER_DOMAINS, PROVIDER_ICON_SLUGS } from "./provider-registry" import type { ProviderCatalogEntry } from "./provider-registry"
interface ProviderIconProps { interface ProviderIconProps {
providerKey: string provider: Pick<ProviderCatalogEntry, "key" | "label" | "iconSlug" | "domain">
providerLabel: string
} }
export function ProviderIcon({ export function ProviderIcon({ provider }: ProviderIconProps) {
providerKey,
providerLabel,
}: ProviderIconProps) {
const [sourceIndex, setSourceIndex] = useState(0) const [sourceIndex, setSourceIndex] = useState(0)
const [loadFailed, setLoadFailed] = useState(false) const [loadFailed, setLoadFailed] = useState(false)
const initial = providerLabel.trim().charAt(0).toUpperCase() || "?" const initial = provider.label.trim().charAt(0).toUpperCase() || "?"
const iconUrls = useMemo(() => { const iconUrls = useMemo(() => {
const slug = PROVIDER_ICON_SLUGS[providerKey] const slug = provider.iconSlug
const domain = PROVIDER_DOMAINS[providerKey] const domain = provider.domain
const urls: string[] = [] const urls: string[] = []
if (slug) { if (slug) {
urls.push(`https://cdn.simpleicons.org/${slug}`) urls.push(`https://cdn.simpleicons.org/${slug}`)
@@ -25,7 +21,7 @@ export function ProviderIcon({
urls.push(`https://www.google.com/s2/favicons?domain=${domain}&sz=64`) urls.push(`https://www.google.com/s2/favicons?domain=${domain}&sz=64`)
} }
return urls return urls
}, [providerKey]) }, [provider.domain, provider.iconSlug])
const iconUrl = iconUrls[sourceIndex] const iconUrl = iconUrls[sourceIndex]
@@ -41,7 +37,7 @@ export function ProviderIcon({
<span className="inline-flex size-4 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-black/10 bg-white p-0.5 dark:border-white/20"> <span className="inline-flex size-4 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-black/10 bg-white p-0.5 dark:border-white/20">
<img <img
src={iconUrl} src={iconUrl}
alt={`${providerLabel} logo`} alt={`${provider.label} logo`}
className="size-full object-contain" className="size-full object-contain"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
@@ -1,14 +0,0 @@
import { PROVIDER_ALIASES, PROVIDER_LABELS } from "./provider-registry"
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
}
export { PROVIDER_LABELS, PROVIDER_ALIASES }
@@ -1,459 +1,175 @@
/**
* Unified provider registry single source of truth for all provider metadata.
* All consumer files (provider-label, provider-icon, models-page, add/edit sheets)
* should derive their data from this registry.
*/
import type { ModelProviderOption } from "@/api/models" import type { ModelProviderOption } from "@/api/models"
export interface ProviderDefinition { export interface ProviderCatalogEntry {
key: string key: string
label: string label: string
labelZh?: string
iconSlug?: string iconSlug?: string
domain?: string domain?: string
priority: number
isLocal: boolean
defaultApiBase?: string defaultApiBase?: string
requiresApiKey: boolean requiresApiKey: boolean
isLocal: boolean createAllowed: boolean
priority: number defaultModelAllowed: boolean
commonModels?: string[] supportsFetch: boolean
aliases?: string[] defaultAuthMethod?: string
/** Whether this provider supports the OpenAI-compatible /models listing endpoint. */ authMethodLocked?: boolean
supportsFetch?: boolean emptyApiKeyAllowed?: boolean
commonModels: string[]
aliases: string[]
} }
export const PROVIDERS: ProviderDefinition[] = [ // Frontend still needs the same trim/lower normalization as the backend
{ // NormalizeProvider before it can look up canonical IDs in provider_options.
key: "openai", // This helper does not define provider semantics; aliases and canonical IDs
label: "OpenAI", // still come entirely from the backend payload.
iconSlug: "openai", function normalizeProvider(provider?: string): string {
domain: "openai.com", return provider?.trim().toLowerCase() || ""
defaultApiBase: "https://api.openai.com/v1", }
requiresApiKey: true,
isLocal: false,
priority: 100,
commonModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o3-mini"],
aliases: ["gpt"],
supportsFetch: true,
},
{
key: "anthropic",
label: "Anthropic",
iconSlug: "anthropic",
domain: "anthropic.com",
defaultApiBase: "https://api.anthropic.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 95,
commonModels: [
"claude-sonnet-4-20250514",
"claude-haiku-4-20250414",
"claude-3-5-sonnet-20241022",
],
aliases: ["claude"],
},
{
key: "gemini",
label: "Google Gemini",
iconSlug: "googlegemini",
domain: "gemini.google.com",
defaultApiBase: "https://generativelanguage.googleapis.com/v1beta",
requiresApiKey: true,
isLocal: false,
priority: 90,
commonModels: ["gemini-2.0-flash", "gemini-2.5-pro", "gemini-1.5-flash"],
aliases: ["google"],
},
{
key: "deepseek",
label: "DeepSeek",
iconSlug: "deepseek",
domain: "deepseek.com",
defaultApiBase: "https://api.deepseek.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 85,
commonModels: ["deepseek-chat", "deepseek-reasoner"],
supportsFetch: true,
},
{
key: "openrouter",
label: "OpenRouter",
iconSlug: "openrouter",
domain: "openrouter.ai",
defaultApiBase: "https://openrouter.ai/api/v1",
requiresApiKey: true,
isLocal: false,
priority: 80,
commonModels: [
"openai/gpt-4o",
"anthropic/claude-sonnet-4",
"google/gemini-2.0-flash",
],
supportsFetch: true,
},
{
key: "qwen-portal",
label: "Qwen",
labelZh: "Qwen (阿里云)",
iconSlug: "alibabacloud",
domain: "qwenlm.ai",
defaultApiBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
requiresApiKey: true,
isLocal: false,
priority: 75,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen"],
supportsFetch: true,
},
{
key: "qwen-intl",
label: "Qwen International",
iconSlug: "alibabacloud",
domain: "alibabacloud.com",
defaultApiBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
requiresApiKey: true,
isLocal: false,
priority: 74,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen-international", "dashscope-intl"],
supportsFetch: true,
},
{
key: "moonshot",
label: "Moonshot",
labelZh: "Moonshot (月之暗面)",
domain: "moonshot.ai",
defaultApiBase: "https://api.moonshot.cn/v1",
requiresApiKey: true,
isLocal: false,
priority: 70,
commonModels: ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"],
supportsFetch: true,
},
{
key: "volcengine",
label: "Volcengine",
labelZh: "Volcengine (火山引擎)",
iconSlug: "bytedance",
domain: "volcengine.com",
defaultApiBase: "https://ark.cn-beijing.volces.com/api/v3",
requiresApiKey: true,
isLocal: false,
priority: 69,
commonModels: ["doubao-1.5-pro", "doubao-1.5-lite"],
supportsFetch: true,
},
{
key: "zhipu",
label: "Zhipu AI",
labelZh: "Zhipu AI (智谱)",
iconSlug: "zhipu",
domain: "zhipuai.cn",
defaultApiBase: "https://open.bigmodel.cn/api/paas/v4",
requiresApiKey: true,
isLocal: false,
priority: 68,
commonModels: ["glm-4-plus", "glm-4-flash"],
supportsFetch: true,
},
{
key: "groq",
label: "Groq",
iconSlug: "groq",
domain: "groq.com",
defaultApiBase: "https://api.groq.com/openai/v1",
requiresApiKey: true,
isLocal: false,
priority: 65,
commonModels: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
supportsFetch: true,
},
{
key: "mistral",
label: "Mistral AI",
iconSlug: "mistralai",
domain: "mistral.ai",
defaultApiBase: "https://api.mistral.ai/v1",
requiresApiKey: true,
isLocal: false,
priority: 64,
commonModels: ["mistral-large-latest", "mistral-small-latest"],
supportsFetch: true,
},
{
key: "nvidia",
label: "NVIDIA",
iconSlug: "nvidia",
domain: "nvidia.com",
defaultApiBase: "https://integrate.api.nvidia.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 63,
commonModels: ["meta/llama-3.1-405b-instruct"],
supportsFetch: true,
},
{
key: "cerebras",
label: "Cerebras",
iconSlug: "cerebras",
domain: "cerebras.ai",
defaultApiBase: "https://api.cerebras.ai/v1",
requiresApiKey: true,
isLocal: false,
priority: 62,
commonModels: ["llama3.1-8b", "llama3.1-70b"],
supportsFetch: true,
},
{
key: "azure",
label: "Azure OpenAI",
iconSlug: "microsoftazure",
domain: "azure.com",
requiresApiKey: true,
isLocal: false,
priority: 61,
commonModels: ["gpt-4o", "gpt-4o-mini"],
},
{
key: "github-copilot",
label: "GitHub Copilot",
iconSlug: "githubcopilot",
domain: "github.com",
requiresApiKey: false,
isLocal: true,
priority: 55,
},
{
key: "antigravity",
label: "Google Code Assist",
domain: "antigravity.google",
requiresApiKey: false,
isLocal: false,
priority: 54,
},
{
key: "ollama",
label: "Ollama",
labelZh: "Ollama (本地)",
iconSlug: "ollama",
domain: "ollama.com",
defaultApiBase: "http://localhost:11434/v1",
requiresApiKey: false,
isLocal: true,
priority: 50,
commonModels: ["llama3", "mistral", "codellama", "qwen2.5"],
supportsFetch: true,
},
{
key: "vllm",
label: "VLLM",
labelZh: "VLLM (本地)",
domain: "vllm.ai",
defaultApiBase: "http://localhost:8000/v1",
requiresApiKey: false,
isLocal: true,
priority: 49,
supportsFetch: true,
},
{
key: "lmstudio",
label: "LM Studio",
labelZh: "LM Studio (本地)",
domain: "lmstudio.ai",
defaultApiBase: "http://localhost:1234/v1",
requiresApiKey: false,
isLocal: true,
priority: 48,
supportsFetch: true,
},
{
key: "venice",
label: "Venice AI",
iconSlug: "venice",
domain: "venice.ai",
defaultApiBase: "https://api.venice.ai/api/v1",
requiresApiKey: true,
isLocal: false,
priority: 45,
supportsFetch: true,
},
{
key: "shengsuanyun",
label: "ShengsuanYun",
labelZh: "ShengsuanYun (神算云)",
domain: "shengsuanyun.com",
defaultApiBase: "https://router.shengsuanyun.com/api/v1",
requiresApiKey: true,
isLocal: false,
priority: 44,
supportsFetch: true,
},
{
key: "siliconflow",
label: "SiliconFlow",
labelZh: "硅基流动",
domain: "siliconflow.cn",
defaultApiBase: "https://api.siliconflow.cn/v1",
requiresApiKey: true,
isLocal: false,
priority: 43.5,
supportsFetch: true,
},
{
key: "vivgrid",
label: "Vivgrid",
domain: "vivgrid.com",
defaultApiBase: "https://api.vivgrid.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 43,
supportsFetch: true,
},
{
key: "minimax",
label: "MiniMax",
domain: "minimaxi.com",
defaultApiBase: "https://api.minimaxi.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 42,
supportsFetch: true,
},
{
key: "longcat",
label: "LongCat",
domain: "longcat.chat",
defaultApiBase: "https://api.longcat.chat/openai",
requiresApiKey: true,
isLocal: false,
priority: 41,
supportsFetch: true,
},
{
key: "modelscope",
label: "ModelScope",
labelZh: "ModelScope (魔搭社区)",
domain: "modelscope.cn",
defaultApiBase: "https://api-inference.modelscope.cn/v1",
requiresApiKey: true,
isLocal: false,
priority: 40,
supportsFetch: true,
},
{
key: "mimo",
label: "Xiaomi MiMo",
iconSlug: "xiaomi",
domain: "xiaomi.com",
defaultApiBase: "https://api.xiaomimimo.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 39,
supportsFetch: true,
},
{
key: "avian",
label: "Avian",
domain: "avian.io",
defaultApiBase: "https://api.avian.io/v1",
requiresApiKey: true,
isLocal: false,
priority: 38,
supportsFetch: true,
},
{
key: "zai",
label: "Z.ai",
domain: "z.ai",
defaultApiBase: "https://api.z.ai/api/coding/paas/v4",
requiresApiKey: true,
isLocal: false,
priority: 37,
aliases: ["z.ai", "z-ai"],
supportsFetch: true,
},
{
key: "novita",
label: "Novita AI",
domain: "novita.ai",
defaultApiBase: "https://api.novita.ai/openai",
requiresApiKey: true,
isLocal: false,
priority: 36,
supportsFetch: true,
},
{
key: "litellm",
label: "LiteLLM",
domain: "litellm.ai",
defaultApiBase: "http://localhost:4000/v1",
requiresApiKey: true,
isLocal: false,
priority: 35,
supportsFetch: true,
},
]
// ── Derived data for consumers ─────────────────────────────────────────────── function toCatalogEntry(option: ModelProviderOption): ProviderCatalogEntry {
const defaultApiBase = option.default_api_base || undefined
return {
key: option.id,
label: option.display_name || option.id,
iconSlug: option.icon_slug || undefined,
domain: option.domain || undefined,
priority: option.priority ?? 0,
isLocal: option.local === true,
defaultApiBase,
requiresApiKey: !option.empty_api_key_allowed,
createAllowed: option.create_allowed,
defaultModelAllowed: option.default_model_allowed,
supportsFetch: option.supports_fetch === true,
defaultAuthMethod: option.default_auth_method || undefined,
authMethodLocked: option.auth_method_locked,
emptyApiKeyAllowed: option.empty_api_key_allowed,
commonModels: option.common_models || [],
aliases: option.aliases || [],
}
}
export const PROVIDER_MAP = new Map(PROVIDERS.map((p) => [p.key, p])) function buildAliasMap(
backendOptions?: ModelProviderOption[],
): Record<string, string> {
const aliases: Record<string, string> = {}
for (const option of backendOptions || []) {
const key = normalizeProvider(option.id)
if (!key) continue
aliases[key] = option.id
for (const alias of option.aliases || []) {
const normalized = normalizeProvider(alias)
if (normalized) {
aliases[normalized] = option.id
}
}
}
return aliases
}
export const PROVIDER_LABELS: Record<string, string> = Object.fromEntries( export function getProviderAliasMap(
PROVIDERS.map((p) => [p.key, p.labelZh || p.label]), backendOptions?: ModelProviderOption[],
) ): Record<string, string> {
return buildAliasMap(backendOptions)
}
export const PROVIDER_ALIASES: Record<string, string> = Object.fromEntries( export function getCanonicalProviderKey(
PROVIDERS.flatMap((p) => (p.aliases || []).map((a) => [a, p.key])), provider?: string,
) backendOptions?: ModelProviderOption[],
): string {
const normalized = normalizeProvider(provider)
if (!normalized) return ""
return getProviderAliasMap(backendOptions)[normalized] ?? normalized
}
export const KNOWN_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key)) export function getKnownProviderKeys(
backendOptions?: ModelProviderOption[],
): Set<string> {
return new Set(getProviderCatalog(backendOptions).map((p) => p.key))
}
export const FETCHABLE_PROVIDER_KEYS = new Set( export function getProviderCatalog(
PROVIDERS.filter((p) => p.supportsFetch).map((p) => p.key), backendOptions?: ModelProviderOption[],
) ): ProviderCatalogEntry[] {
if (!backendOptions || backendOptions.length === 0) {
return []
}
export const PROVIDER_ICON_SLUGS: Record<string, string> = Object.fromEntries( return [...backendOptions]
PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]), .map(toCatalogEntry)
) .sort((a, b) => b.priority - a.priority)
}
export const PROVIDER_DOMAINS: Record<string, string> = Object.fromEntries( export function getProviderCatalogMap(
PROVIDERS.filter((p) => p.domain).map((p) => [p.key, p.domain!]), backendOptions?: ModelProviderOption[],
) ): Map<string, ProviderCatalogEntry> {
return new Map(getProviderCatalog(backendOptions).map((p) => [p.key, p]))
}
export const PROVIDER_PRIORITY: Record<string, number> = Object.fromEntries( export function getProviderCatalogEntry(
PROVIDERS.map((p) => [p.key, p.priority]), provider: string | undefined,
) backendOptions?: ModelProviderOption[],
): ProviderCatalogEntry | undefined {
const key = getCanonicalProviderKey(provider, backendOptions)
if (!key) return undefined
return getProviderCatalogMap(backendOptions).get(key)
}
export const PROVIDER_API_BASES: Record<string, string> = Object.fromEntries( export function getProviderDefaultAPIBase(
PROVIDERS.filter((p) => p.defaultApiBase).map((p) => [ provider: string | undefined,
p.key, backendOptions?: ModelProviderOption[],
p.defaultApiBase!, ): string {
]), return getProviderCatalogEntry(provider, backendOptions)?.defaultApiBase ?? ""
) }
export function getProviderDefaultAuthMethod(
provider: string | undefined,
backendOptions?: ModelProviderOption[],
): string {
return getProviderCatalogEntry(provider, backendOptions)?.defaultAuthMethod ?? ""
}
export function isProviderAuthMethodLocked(
provider: string | undefined,
backendOptions?: ModelProviderOption[],
): boolean {
return getProviderCatalogEntry(provider, backendOptions)?.authMethodLocked === true
}
export function providerSupportsFetch(
provider: string | undefined,
backendOptions?: ModelProviderOption[],
): boolean {
const key = getCanonicalProviderKey(provider, backendOptions)
if (!key) return false
return getProviderCatalogMap(backendOptions).get(key)?.supportsFetch === true
}
/** /**
* Find the closest known provider key by edit distance. * Find the closest known provider key by edit distance.
* Returns the key if distance <= 2, otherwise undefined. * Returns the key if distance <= 2, otherwise undefined.
*/ */
export function findClosestProvider(input: string): string | undefined { export function findClosestProvider(
input: string,
backendOptions?: ModelProviderOption[],
): string | undefined {
const lower = input.toLowerCase() const lower = input.toLowerCase()
let best: string | undefined let best: string | undefined
let bestDist = 3 // only accept distance <= 2 let bestDist = 3
for (const key of KNOWN_PROVIDER_KEYS) { for (const key of getKnownProviderKeys(backendOptions)) {
const dist = editDistance(lower, key) const dist = editDistance(lower, key)
if (dist < bestDist) { if (dist < bestDist) {
bestDist = dist bestDist = dist
best = key best = key
} }
} }
// Also check aliases
for (const alias of Object.keys(PROVIDER_ALIASES)) { for (const alias of Object.keys(getProviderAliasMap(backendOptions))) {
const dist = editDistance(lower, alias) const dist = editDistance(lower, alias)
if (dist < bestDist) { if (dist < bestDist) {
bestDist = dist bestDist = dist
best = PROVIDER_ALIASES[alias] best = getProviderAliasMap(backendOptions)[alias]
} }
} }
return best return best
@@ -477,55 +193,3 @@ function editDistance(a: string, b: string): number {
} }
return dp[m][n] return dp[m][n]
} }
// ── Backend options merge ────────────────────────────────────────────────────
export interface MergedProvider extends ProviderDefinition {
createAllowed: boolean
defaultModelAllowed: boolean
defaultAuthMethod?: string
authMethodLocked?: boolean
}
/**
* Merge the frontend PROVIDERS registry with backend provider_options.
* Frontend provides presentation data (labels, icons, priority, etc.).
* Backend provides authoritative availability and policy fields.
*/
export function mergeWithBackendOptions(
backendOptions: ModelProviderOption[],
): MergedProvider[] {
const backendMap = new Map(backendOptions.map((o) => [o.id, o]))
const merged: MergedProvider[] = []
// Start with frontend providers, enriched with backend policy
for (const p of PROVIDERS) {
const backend = backendMap.get(p.key)
merged.push({
...p,
createAllowed: backend?.create_allowed ?? false,
defaultModelAllowed: backend?.default_model_allowed ?? false,
defaultAuthMethod: backend?.default_auth_method,
authMethodLocked: backend?.auth_method_locked,
})
if (backend) backendMap.delete(p.key)
}
// Add providers only known to the backend
for (const [key, backend] of backendMap) {
merged.push({
key,
label: key,
requiresApiKey: !backend.empty_api_key_allowed,
isLocal: backend.empty_api_key_allowed,
priority: 0,
createAllowed: backend.create_allowed,
defaultModelAllowed: backend.default_model_allowed,
defaultAuthMethod: backend.default_auth_method,
authMethodLocked: backend.auth_method_locked,
defaultApiBase: backend.default_api_base || undefined,
})
}
return merged.sort((a, b) => b.priority - a.priority)
}
@@ -5,10 +5,10 @@ import type { ModelInfo } from "@/api/models"
import { ModelCard } from "./model-card" import { ModelCard } from "./model-card"
import { ProviderIcon } from "./provider-icon" import { ProviderIcon } from "./provider-icon"
import type { ProviderCatalogEntry } from "./provider-registry"
interface ProviderSectionProps { interface ProviderSectionProps {
provider: string provider: Pick<ProviderCatalogEntry, "key" | "label" | "iconSlug" | "domain">
providerKey: string
models: ModelInfo[] models: ModelInfo[]
onEdit: (model: ModelInfo) => void onEdit: (model: ModelInfo) => void
onSetDefault: (model: ModelInfo) => void onSetDefault: (model: ModelInfo) => void
@@ -18,7 +18,6 @@ interface ProviderSectionProps {
export function ProviderSection({ export function ProviderSection({
provider, provider,
providerKey,
models, models,
onEdit, onEdit,
onSetDefault, onSetDefault,
@@ -38,8 +37,8 @@ export function ProviderSection({
<div className="border-border/40 border-t" /> <div className="border-border/40 border-t" />
<span className="text-foreground/80 text-center text-xs font-semibold tracking-wide uppercase"> <span className="text-foreground/80 text-center text-xs font-semibold tracking-wide uppercase">
<span className="bg-background inline-flex items-center gap-1.5 px-2"> <span className="bg-background inline-flex items-center gap-1.5 px-2">
<ProviderIcon providerKey={providerKey} providerLabel={provider} /> <ProviderIcon provider={provider} />
{provider} {provider.label}
</span> </span>
</span> </span>
<div className="border-border/40 border-t" /> <div className="border-border/40 border-t" />
+10 -6
View File
@@ -232,6 +232,8 @@
"unsavedPrompt": "This change has not been saved yet. Save to write it into the model configuration.", "unsavedPrompt": "This change has not been saved yet. Save to write it into the model configuration.",
"restartHint": "Model configuration changes take effect after the gateway restarts.", "restartHint": "Model configuration changes take effect after the gateway restarts.",
"loadError": "Failed to load models", "loadError": "Failed to load models",
"retry": "Retry",
"providerCatalogUnavailable": "The backend provider catalog is unavailable. New provider selections are disabled until the Models API loads successfully.",
"noDefaultHintPrefix": "No default model set yet. Click", "noDefaultHintPrefix": "No default model set yet. Click",
"noDefaultHintSuffix": "to set one.", "noDefaultHintSuffix": "to set one.",
"status": { "status": {
@@ -251,7 +253,8 @@
"setting": "Setting as default...", "setting": "Setting as default...",
"unavailable": "Cannot set unavailable model as default", "unavailable": "Cannot set unavailable model as default",
"isDefault": "Already the default model", "isDefault": "Already the default model",
"isVirtual": "Cannot set virtual model as default" "isVirtual": "Cannot set virtual model as default",
"unsupportedProvider": "This provider cannot be used as the default chat model."
}, },
"deleteDisabled": { "deleteDisabled": {
"isDefault": "Cannot delete the default model" "isDefault": "Cannot delete the default model"
@@ -288,8 +291,9 @@
}, },
"field": { "field": {
"provider": "Provider", "provider": "Provider",
"providerPlaceholder": "e.g. openai", "providerPlaceholder": "Select a provider",
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.", "providerHint": "Choose a provider from the backend catalog; Model Identifier will be interpreted as that provider's canonical model ID.",
"providerInvalid": "The current provider is invalid. Please choose a supported provider.",
"selectProviderFirst": "Select a provider first", "selectProviderFirst": "Select a provider first",
"apiBase": "API Base URL", "apiBase": "API Base URL",
"apiKey": "API Key", "apiKey": "API Key",
@@ -299,6 +303,7 @@
"proxyHint": "Optional. e.g. http://127.0.0.1:7890", "proxyHint": "Optional. e.g. http://127.0.0.1:7890",
"authMethod": "Auth Method", "authMethod": "Auth Method",
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.", "authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
"authMethodManagedHint": "This provider's auth method is managed by the system.",
"connectMode": "Connect Mode", "connectMode": "Connect Mode",
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.", "connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
"workspace": "Workspace Path", "workspace": "Workspace Path",
@@ -395,9 +400,8 @@
"selectProvider": "Select provider...", "selectProvider": "Select provider...",
"searchProvider": "Search provider...", "searchProvider": "Search provider...",
"noProvider": "No provider found.", "noProvider": "No provider found.",
"local": "local", "noCatalog": "Provider catalog unavailable.",
"custom": "Custom provider...", "local": "local"
"customPlaceholder": "Enter provider name..."
} }
}, },
"channels": { "channels": {
+83 -5
View File
@@ -142,10 +142,12 @@
}, },
"common": { "common": {
"cancel": "Cancelar", "cancel": "Cancelar",
"close": "Fechar",
"save": "Salvar", "save": "Salvar",
"saving": "Salvando...", "saving": "Salvando...",
"reset": "Redefinir", "reset": "Redefinir",
"confirm": "Confirmar", "confirm": "Confirmar",
"fix": "Corrigir",
"saveChangesTitle": "Você tem alterações de configuração não salvas", "saveChangesTitle": "Você tem alterações de configuração não salvas",
"restartRequiredTitle": "Reinício do gateway necessário", "restartRequiredTitle": "Reinício do gateway necessário",
"restartRequiredDesc": "A configuração mais recente de {{name}} foi salva. Reinicie o gateway para que tenha efeito." "restartRequiredDesc": "A configuração mais recente de {{name}} foi salva. Reinicie o gateway para que tenha efeito."
@@ -230,6 +232,8 @@
"unsavedPrompt": "Esta alteração ainda não foi salva. Salve para gravá-la na configuração do modelo.", "unsavedPrompt": "Esta alteração ainda não foi salva. Salve para gravá-la na configuração do modelo.",
"restartHint": "Alterações na configuração de modelos só têm efeito após o gateway reiniciar.", "restartHint": "Alterações na configuração de modelos só têm efeito após o gateway reiniciar.",
"loadError": "Falha ao carregar modelos", "loadError": "Falha ao carregar modelos",
"retry": "Tentar novamente",
"providerCatalogUnavailable": "O catálogo de providers do backend está indisponível. A seleção de novos providers fica desabilitada até a API de Modelos carregar com sucesso.",
"noDefaultHintPrefix": "Nenhum modelo padrão definido ainda. Clique em", "noDefaultHintPrefix": "Nenhum modelo padrão definido ainda. Clique em",
"noDefaultHintSuffix": "para definir um.", "noDefaultHintSuffix": "para definir um.",
"status": { "status": {
@@ -286,8 +290,10 @@
}, },
"field": { "field": {
"provider": "Provider", "provider": "Provider",
"providerPlaceholder": "ex: openai", "providerPlaceholder": "Selecione um provider",
"providerHint": "Opcional. Se especificado, este valor é usado como o provider efetivo, e Identificador do Modelo é interpretado como o ID canônico do modelo.", "providerHint": "Escolha um provider do catálogo do backend; o Identificador do Modelo será interpretado como o ID canônico desse provider.",
"providerInvalid": "O provider atual é inválido. Selecione um provider suportado.",
"selectProviderFirst": "Selecione um provider primeiro",
"apiBase": "URL Base da API", "apiBase": "URL Base da API",
"apiKey": "API Key", "apiKey": "API Key",
"apiKeyPlaceholder": "Digite sua API Key", "apiKeyPlaceholder": "Digite sua API Key",
@@ -296,6 +302,7 @@
"proxyHint": "Opcional. ex: http://127.0.0.1:7890", "proxyHint": "Opcional. ex: http://127.0.0.1:7890",
"authMethod": "Método de Autenticação", "authMethod": "Método de Autenticação",
"authMethodHint": "Método de autenticação: oauth, token. Deixe em branco para autenticação por API Key.", "authMethodHint": "Método de autenticação: oauth, token. Deixe em branco para autenticação por API Key.",
"authMethodManagedHint": "O método de autenticação deste provider é gerenciado pelo sistema.",
"connectMode": "Modo de Conexão", "connectMode": "Modo de Conexão",
"connectModeHint": "Modo de conexão para providers baseados em CLI: stdio ou grpc.", "connectModeHint": "Modo de conexão para providers baseados em CLI: stdio ou grpc.",
"workspace": "Caminho do Workspace", "workspace": "Caminho do Workspace",
@@ -308,14 +315,15 @@
"thinkingLevelHint": "Orçamento de pensamento estendido: off, low, medium, high, xhigh, adaptive.", "thinkingLevelHint": "Orçamento de pensamento estendido: off, low, medium, high, xhigh, adaptive.",
"maxTokensField": "Campo de Max Tokens", "maxTokensField": "Campo de Max Tokens",
"maxTokensFieldHint": "Sobrescreve o nome do campo de max tokens na requisição, ex: max_completion_tokens.", "maxTokensFieldHint": "Sobrescreve o nome do campo de max tokens na requisição, ex: max_completion_tokens.",
"toolSchemaTransform": "Transformação de Schema de Tool", "toolSchemaTransform": "Transformação de Schema de Ferramentas",
"toolSchemaTransformHint": "Transformação opcional de compatibilidade para schemas JSON de tools. Deixe em branco para comportamento nativo. Valores suportados: simple.", "toolSchemaTransformHint": "Transformação opcional de compatibilidade para schemas JSON de ferramentas. Deixe em branco para o comportamento nativo. Valor suportado: simple.",
"streamingEnabled": "Saída Streaming", "streamingEnabled": "Saída Streaming",
"streamingEnabledHint": "Permite que esta entrada de modelo tente requisições de provider streaming. O switch de streaming do canal atual também precisa estar habilitado.", "streamingEnabledHint": "Permite que esta entrada de modelo tente requisições de provider streaming. O switch de streaming do canal atual também precisa estar habilitado.",
"extraBody": "Body Extra", "extraBody": "Body Extra",
"extraBodyHint": "Campos JSON adicionais para injetar no body da requisição, ex: {\"reasoning_split\": true}.", "extraBodyHint": "Campos JSON adicionais para injetar no body da requisição, ex: {\"reasoning_split\": true}.",
"customHeaders": "Headers Customizados", "customHeaders": "Headers Customizados",
"customHeadersHint": "Headers HTTP adicionais para injetar em cada requisição, ex: {\"X-Source\": \"coding-plan\"}." "customHeadersHint": "Headers HTTP adicionais para injetar em cada requisição, ex: {\"X-Source\": \"coding-plan\"}.",
"invalidJson": "Formato JSON inválido"
}, },
"edit": { "edit": {
"title": "Configurar {{name}}", "title": "Configurar {{name}}",
@@ -323,6 +331,76 @@
"oauthNote": "Este provider usa OAuth — não é necessária API Key.", "oauthNote": "Este provider usa OAuth — não é necessária API Key.",
"saveError": "Falha ao salvar", "saveError": "Falha ao salvar",
"saveSuccess": "Configuração do modelo salva." "saveSuccess": "Configuração do modelo salva."
},
"fetch": {
"title": "Buscar Modelos Disponíveis",
"description": "Busque a lista de modelos do provider upstream.",
"providerLabel": "Provider:",
"needApiKey": "Digite primeiro uma API Key para buscar modelos.",
"fetching": "Buscando modelos...",
"retry": "Tentar novamente",
"filterPlaceholder": "Filtrar modelos...",
"found": "Encontrado {{count}} modelo",
"found_plural": "Encontrados {{count}} modelos",
"shown": "({{count}} exibidos)",
"selectAll": "Selecionar todos",
"deselectAll": "Desmarcar todos",
"fill": "Preencher {{count}} Modelo Selecionado",
"fill_plural": "Preencher {{count}} Modelos Selecionados",
"failed": "Falha ao buscar modelos"
},
"catalog": {
"button": "Catálogos Salvos",
"title": "Catálogos de Modelos Salvos",
"description": "Listas de modelos buscadas anteriormente, armazenadas por API key. Selecione modelos para adicionar à sua configuração.",
"loading": "Carregando catálogos...",
"empty": "Ainda não há catálogos salvos. Busque modelos de um provider para salvar um catálogo.",
"filterPlaceholder": "Filtrar modelos...",
"models": "modelos",
"fetchedAt": "Obtido em",
"delete": "Excluir catálogo",
"refresh": "Atualizar do upstream",
"found": "Encontrado {{count}} modelo",
"found_plural": "Encontrados {{count}} modelos",
"selectAll": "Selecionar todos",
"deselectAll": "Desmarcar todos",
"addSelected": "Adicionar {{count}} Selecionados",
"addSuccess": "{{count}} modelo(s) adicionados à configuração.",
"needApiKey": "Esses modelos exigem uma API key. Será necessário configurar as credenciais após a importação."
},
"test": {
"title": "Testar Conectividade do Modelo",
"description": "Verifique se o endpoint do modelo está acessível e configurado corretamente.",
"modelLabel": "Modelo:",
"identifierLabel": "Identificador:",
"endpointLabel": "Endpoint:",
"testConnection": "Testar Conexão",
"testing": "Testando conexão...",
"success": "Conexão bem-sucedida",
"responseTime": "Tempo de resposta: {{ms}}ms",
"failed": "Falha na conexão",
"status": "Status: {{status}}",
"testFailed": "Falha no teste",
"testAgain": "Testar novamente"
},
"validation": {
"whitespace": "O identificador do modelo não pode conter espaços",
"leadingSlash": "Não deve começar com /",
"consecutiveSlash": "Não deve conter // consecutivos",
"useProvider": "Usará \"{{provider}}\" como provider",
"defaultToOpenAI": "Nenhum provider especificado, o padrão será OpenAI",
"emptyModel": "O nome do modelo não pode estar vazio",
"shouldUse": "\"{{provider}}\" deve usar \"{{alias}}\"",
"didYouMean": "Você quis dizer \"{{closest}}\"?",
"unknownProvider": "Provider desconhecido \"{{provider}}\"",
"parsed": "provider={{provider}}, model={{model}}"
},
"combobox": {
"selectProvider": "Selecionar provider...",
"searchProvider": "Buscar provider...",
"noProvider": "Nenhum provider encontrado.",
"noCatalog": "Catálogo de providers indisponível.",
"local": "local"
} }
}, },
"channels": { "channels": {
+3
View File
@@ -232,6 +232,8 @@
"unsavedPrompt": "当前修改尚未保存,保存后才会写入模型配置。", "unsavedPrompt": "当前修改尚未保存,保存后才会写入模型配置。",
"restartHint": "模型配置保存后需要重启服务才能生效。", "restartHint": "模型配置保存后需要重启服务才能生效。",
"loadError": "加载模型列表失败", "loadError": "加载模型列表失败",
"retry": "重试",
"providerCatalogUnavailable": "后端 Provider catalog 暂不可用,待模型 API 成功加载后才能选择新的 Provider。",
"noDefaultHintPrefix": "尚未设置默认模型,点击", "noDefaultHintPrefix": "尚未设置默认模型,点击",
"noDefaultHintSuffix": "设为默认。", "noDefaultHintSuffix": "设为默认。",
"status": { "status": {
@@ -396,6 +398,7 @@
"selectProvider": "选择服务商...", "selectProvider": "选择服务商...",
"searchProvider": "搜索服务商...", "searchProvider": "搜索服务商...",
"noProvider": "未找到服务商。", "noProvider": "未找到服务商。",
"noCatalog": "Provider catalog 暂不可用。",
"local": "本地", "local": "本地",
"custom": "自定义服务商...", "custom": "自定义服务商...",
"customPlaceholder": "输入服务商名称..." "customPlaceholder": "输入服务商名称..."