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
+21 -70
View File
@@ -18,54 +18,6 @@ import (
"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.
func createClaudeAuthProvider() (LLMProvider, error) {
cred, err := getCredential("anthropic")
@@ -184,7 +136,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
provider.SetProviderName(protocol)
return finalizeProviderFromConfig(provider, modelID, cfg)
case "azure", "azure-openai":
case "azure":
// Azure OpenAI uses deployment-based URLs, api-key header auth,
// and always sends max_completion_tokens.
if cfg.APIKey() == "" {
@@ -241,9 +193,8 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo":
"vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral",
"avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@@ -355,7 +306,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout,
), modelID, cfg)
case "coding-plan-anthropic", "alibaba-coding-anthropic":
case "alibaba-coding-anthropic":
// Alibaba Coding Plan with Anthropic-compatible API
apiBase := cfg.APIBase
if apiBase == "" {
@@ -374,21 +325,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "antigravity":
return finalizeProviderFromConfig(NewAntigravityProvider(), modelID, cfg)
case "claude-cli", "claudecli":
case "claude-cli":
workspace := cfg.Workspace
if workspace == "" {
workspace = "."
}
return finalizeProviderFromConfig(NewClaudeCliProvider(workspace), modelID, cfg)
case "codex-cli", "codexcli":
case "codex-cli":
workspace := cfg.Workspace
if workspace == "" {
workspace = "."
}
return finalizeProviderFromConfig(NewCodexCliProvider(workspace), modelID, cfg)
case "github-copilot", "copilot":
case "github-copilot":
apiBase := cfg.APIBase
if apiBase == "" {
apiBase = "localhost:4321"
@@ -421,8 +372,8 @@ func finalizeProviderFromConfig(
}
func isEmptyAPIKeyAllowed(protocol string) bool {
meta, ok := protocolMetaForName(protocol)
return ok && meta.emptyAPIKeyAllowed
option, ok := modelProviderOptionForName(protocol)
return ok && option.EmptyAPIKeyAllowed
}
// IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests
@@ -432,6 +383,16 @@ func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool {
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.
// It returns empty string if the protocol has no default base.
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.
func getDefaultAPIBase(protocol string) string {
meta, ok := protocolMetaForName(protocol)
option, ok := modelProviderOptionForName(protocol)
if !ok {
return ""
}
return meta.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
return option.DefaultAPIBase
}
+73 -1
View File
@@ -946,7 +946,7 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {
if 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
var _ LLMProvider = provider
})
@@ -998,6 +998,13 @@ func TestModelProviderOptions(t *testing.T) {
if option, ok := seen["openai"]; ok && !option.CreateAllowed {
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 {
t.Fatal("lmstudio option missing")
} else if !option.EmptyAPIKeyAllowed {
@@ -1052,6 +1059,71 @@ func TestModelProviderOptions(t *testing.T) {
t.Fatal("github-copilot option missing")
} else if 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.
func NormalizeProvider(provider string) string {
p := strings.ToLower(strings.TrimSpace(provider))
switch p {
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"
normalized := strings.ToLower(strings.TrimSpace(provider))
if normalized == "" {
return ""
}
return p
if canonical, ok := normalizedModelProviderAliasesByName[normalized]; ok {
return canonical
}
return normalized
}
// 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"},
{"opencode-zen", "opencode"},
{"qwen", "qwen-portal"},
{"kimi-code", "kimi-coding"},
{"gpt", "openai"},
{"claude", "anthropic"},
{"glm", "zhipu"},
@@ -79,9 +77,11 @@ func TestNormalizeProvider(t *testing.T) {
{"codexcli", "codex-cli"},
{"copilot", "github-copilot"},
// Alibaba Coding Plan aliases
{"alibaba-coding", "coding-plan"},
{"qwen-coding", "coding-plan"},
{"alibaba-coding-anthropic", "coding-plan-anthropic"},
{"alibaba-coding", "alibaba-coding"},
{"coding-plan", "alibaba-coding"},
{"qwen-coding", "alibaba-coding"},
{"alibaba-coding-anthropic", "alibaba-coding-anthropic"},
{"coding-plan-anthropic", "alibaba-coding-anthropic"},
// Qwen international aliases
{"qwen-international", "qwen-intl"},
{"dashscope-intl", "qwen-intl"},
+14 -115
View File
@@ -5,97 +5,10 @@ import (
"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.
func ModelProviderOptions() []ModelProviderOption {
optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName))
for provider := range protocolMetaByName {
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 := make([]ModelProviderOption, 0, len(modelProviderOptionsByName))
for _, option := range modelProviderOptionsByName {
options = append(options, option)
}
sort.Slice(options, func(i, j int) bool {
@@ -107,44 +20,30 @@ func ModelProviderOptions() []ModelProviderOption {
// IsSupportedModelProvider reports whether provider resolves to a provider ID
// returned by ModelProviderOptions.
func IsSupportedModelProvider(provider string) bool {
normalized := NormalizeProvider(provider)
if normalized == "" {
return false
}
if _, ok := protocolMetaByName[normalized]; ok {
return true
}
_, ok := attachedModelProviderMetaByName[normalized]
_, ok := modelProviderOptionForName(provider)
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
// model entry from the Web UI.
func IsCreatableModelProvider(provider string) bool {
normalized := NormalizeProvider(provider)
if normalized == "" {
return false
}
if _, ok := protocolMetaByName[normalized]; ok {
return true
}
meta, ok := attachedModelProviderMetaByName[normalized]
return ok && meta.createAllowed
option, ok := modelProviderOptionForName(provider)
return ok && option.CreateAllowed
}
// IsDefaultModelProvider reports whether provider can be used as the default
// chat model. Some providers such as ASR-only entries are intentionally
// exposed in model_list management but cannot drive the gateway default model.
func IsDefaultModelProvider(provider string) bool {
normalized := NormalizeProvider(provider)
if normalized == "" {
return false
}
if _, ok := protocolMetaByName[normalized]; ok {
return true
}
meta, ok := attachedModelProviderMetaByName[normalized]
return ok && meta.defaultModelAllowed
option, ok := modelProviderOptionForName(provider)
return ok && option.DefaultModelAllowed
}
// 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
}