mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": "输入服务商名称..."
|
||||||
|
|||||||
Reference in New Issue
Block a user