diff --git a/pkg/audio/asr/asr.go b/pkg/audio/asr/asr.go index a7c93e578..94ede23ee 100644 --- a/pkg/audio/asr/asr.go +++ b/pkg/audio/asr/asr.go @@ -29,12 +29,12 @@ func supportsAudioTranscription(modelCfg *config.ModelConfig) bool { protocol, _ := providers.ExtractProtocol(modelCfg) switch protocol { - case "openai", "azure", "azure-openai", + case "openai", "azure", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding", "zai": + "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", + "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "alibaba-coding", "zai": // These protocols all go through the OpenAI-compatible or Azure provider path in // providers.CreateProviderFromConfig, so they are the only ones that can supply // the audio media payload shape expected by NewAudioModelTranscriber. @@ -53,9 +53,9 @@ func supportsWhisperTranscription(modelCfg *config.ModelConfig) bool { switch protocol { case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": + "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", + "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "alibaba-coding", "zai", "mimo": return true default: return false diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 034c80508..667c6da6a 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -18,54 +18,6 @@ import ( "github.com/sipeed/picoclaw/pkg/providers/common" ) -type protocolMeta struct { - defaultAPIBase string - emptyAPIKeyAllowed bool -} - -var protocolMetaByName = map[string]protocolMeta{ - "openai": {defaultAPIBase: "https://api.openai.com/v1"}, - "venice": {defaultAPIBase: "https://api.venice.ai/api/v1"}, - "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"}, - "litellm": {defaultAPIBase: "http://localhost:4000/v1"}, - "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true}, - "novita": {defaultAPIBase: "https://api.novita.ai/openai"}, - "groq": {defaultAPIBase: "https://api.groq.com/openai/v1"}, - "zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"}, - "gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"}, - "nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"}, - "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true}, - "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"}, - "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"}, - "siliconflow": {defaultAPIBase: "https://api.siliconflow.cn/v1"}, - "deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"}, - "cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"}, - "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"}, - "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"}, - "qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, - "qwen-portal": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, - "qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, - "qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, - "dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, - "qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, - "dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, - "coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, - "alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, - "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, - "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, - "alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, - "zai": {defaultAPIBase: "https://api.z.ai/api/coding/paas/v4"}, - "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true}, - "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"}, - "avian": {defaultAPIBase: "https://api.avian.io/v1"}, - "minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"}, - "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"}, - "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"}, - "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"}, - "anthropic": {defaultAPIBase: "https://api.anthropic.com/v1"}, - "anthropic-messages": {defaultAPIBase: "https://api.anthropic.com/v1"}, -} - // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. func createClaudeAuthProvider() (LLMProvider, error) { cred, err := getCredential("anthropic") @@ -184,7 +136,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err provider.SetProviderName(protocol) return finalizeProviderFromConfig(provider, modelID, cfg) - case "azure", "azure-openai": + case "azure": // Azure OpenAI uses deployment-based URLs, api-key header auth, // and always sends max_completion_tokens. if cfg.APIKey() == "" { @@ -241,9 +193,8 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": + "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral", + "avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo": // All other OpenAI-compatible HTTP providers if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -355,7 +306,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, ), modelID, cfg) - case "coding-plan-anthropic", "alibaba-coding-anthropic": + case "alibaba-coding-anthropic": // Alibaba Coding Plan with Anthropic-compatible API apiBase := cfg.APIBase if apiBase == "" { @@ -374,21 +325,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "antigravity": return finalizeProviderFromConfig(NewAntigravityProvider(), modelID, cfg) - case "claude-cli", "claudecli": + case "claude-cli": workspace := cfg.Workspace if workspace == "" { workspace = "." } return finalizeProviderFromConfig(NewClaudeCliProvider(workspace), modelID, cfg) - case "codex-cli", "codexcli": + case "codex-cli": workspace := cfg.Workspace if workspace == "" { workspace = "." } return finalizeProviderFromConfig(NewCodexCliProvider(workspace), modelID, cfg) - case "github-copilot", "copilot": + case "github-copilot": apiBase := cfg.APIBase if apiBase == "" { apiBase = "localhost:4321" @@ -421,8 +372,8 @@ func finalizeProviderFromConfig( } func isEmptyAPIKeyAllowed(protocol string) bool { - meta, ok := protocolMetaForName(protocol) - return ok && meta.emptyAPIKeyAllowed + option, ok := modelProviderOptionForName(protocol) + return ok && option.EmptyAPIKeyAllowed } // IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests @@ -432,6 +383,16 @@ func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool { return isEmptyAPIKeyAllowed(protocol) } +// IsHTTPAPIProtocol reports whether a provider uses an HTTP API base in the +// model configuration path. This excludes providers such as Bedrock, CLI +// bridges, and OAuth-only managed providers even if they do not require an +// explicit api_key field. +func IsHTTPAPIProtocol(protocol string) bool { + protocol = NormalizeProvider(protocol) + option, ok := modelProviderOptionsByName[protocol] + return ok && option.httpAPI +} + // DefaultAPIBaseForProtocol returns the configured default API base for a protocol. // It returns empty string if the protocol has no default base. func DefaultAPIBaseForProtocol(protocol string) string { @@ -441,19 +402,9 @@ func DefaultAPIBaseForProtocol(protocol string) string { // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { - meta, ok := protocolMetaForName(protocol) + option, ok := modelProviderOptionForName(protocol) if !ok { return "" } - return meta.defaultAPIBase -} - -func protocolMetaForName(protocol string) (protocolMeta, bool) { - if meta, ok := protocolMetaByName[protocol]; ok { - return meta, true - } - if meta, ok := attachedModelProviderMetaByName[protocol]; ok { - return meta.protocolMeta, true - } - return protocolMeta{}, false + return option.DefaultAPIBase } diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 69903711f..09dbf8b47 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -946,7 +946,7 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { if modelID != wantModelID { t.Errorf("modelID = %q, want %q", modelID, wantModelID) } - // coding-plan-anthropic uses Anthropic Messages provider + // alibaba-coding-anthropic uses Anthropic Messages provider // Verify it's the anthropic messages provider by checking interface var _ LLMProvider = provider }) @@ -998,6 +998,13 @@ func TestModelProviderOptions(t *testing.T) { if option, ok := seen["openai"]; ok && !option.CreateAllowed { t.Fatal("openai should be creatable") } + if option, ok := seen["openai"]; ok && !option.SupportsFetch { + t.Fatal("openai should support upstream model listing") + } else if option.DisplayName != "OpenAI" { + t.Fatalf("openai display_name = %q, want %q", option.DisplayName, "OpenAI") + } else if len(option.CommonModels) == 0 { + t.Fatal("openai common_models should not be empty") + } if option, ok := seen["lmstudio"]; !ok { t.Fatal("lmstudio option missing") } else if !option.EmptyAPIKeyAllowed { @@ -1052,6 +1059,71 @@ func TestModelProviderOptions(t *testing.T) { t.Fatal("github-copilot option missing") } else if option.DefaultAPIBase != "localhost:4321" { t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } else if !option.Local { + t.Fatal("github-copilot should be marked local") + } + if option, ok := seen["qwen-portal"]; !ok { + t.Fatal("qwen-portal option missing") + } else if len(option.Aliases) == 0 || option.Aliases[0] != "qwen" { + t.Fatalf("qwen-portal aliases = %#v, want to include qwen", option.Aliases) + } + + for _, option := range options { + if len(option.CommonModels) > 6 { + t.Fatalf("provider %q exposes %d common_models, want at most 6", option.ID, len(option.CommonModels)) + } + if option.Local && len(option.CommonModels) > 0 { + t.Fatalf("local provider %q should not expose common_models", option.ID) + } + seenModels := make(map[string]struct{}, len(option.CommonModels)) + for _, model := range option.CommonModels { + if strings.TrimSpace(model) == "" { + t.Fatalf("provider %q includes an empty common_model entry", option.ID) + } + if _, exists := seenModels[model]; exists { + t.Fatalf("provider %q includes duplicate common_model %q", option.ID, model) + } + seenModels[model] = struct{}{} + } + } +} + +func TestBuildModelProviderAliasMap(t *testing.T) { + aliases := buildModelProviderAliasMap() + if len(aliases) == 0 { + t.Fatal("buildModelProviderAliasMap() returned empty map") + } + + seenAliases := make(map[string]string, len(aliases)) + for provider, option := range modelProviderOptionsByName { + got, ok := aliases[provider] + if !ok { + t.Fatalf("canonical provider %q missing from alias map", provider) + } + if got != provider { + t.Fatalf("canonical provider %q mapped to %q", provider, got) + } + if existing, ok := seenAliases[provider]; ok { + t.Fatalf("canonical provider key %q collides with provider %q", provider, existing) + } + seenAliases[provider] = provider + for _, alias := range option.Aliases { + normalized := strings.ToLower(strings.TrimSpace(alias)) + if normalized == "" { + t.Fatalf("provider %q includes empty alias", provider) + } + if existing, ok := seenAliases[normalized]; ok && existing != provider { + t.Fatalf("alias %q for provider %q collides with provider %q", alias, provider, existing) + } + seenAliases[normalized] = provider + got, ok := aliases[normalized] + if !ok { + t.Fatalf("alias %q for provider %q missing from alias map", alias, provider) + } + if got != provider { + t.Fatalf("alias %q normalized to %q, want %q", alias, got, provider) + } + } } } diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go index 48e3fb4cb..ac7cae78e 100644 --- a/pkg/providers/model_ref.go +++ b/pkg/providers/model_ref.go @@ -29,46 +29,14 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef { // NormalizeProvider normalizes provider identifiers to canonical form. func NormalizeProvider(provider string) string { - p := strings.ToLower(strings.TrimSpace(provider)) - - switch p { - case "z.ai", "z-ai": - return "zai" - case "opencode-zen": - return "opencode" - case "qwen": - return "qwen-portal" - case "kimi-code": - return "kimi-coding" - case "gpt": - return "openai" - case "claude": - return "anthropic" - case "glm": - return "zhipu" - case "google": - return "gemini" - case "google-antigravity": - return "antigravity" - case "alibaba-coding", "qwen-coding": - return "coding-plan" - case "alibaba-coding-anthropic": - return "coding-plan-anthropic" - case "qwen-international", "dashscope-intl": - return "qwen-intl" - case "dashscope-us": - return "qwen-us" - case "azure-openai": - return "azure" - case "claudecli": - return "claude-cli" - case "codexcli": - return "codex-cli" - case "copilot": - return "github-copilot" + normalized := strings.ToLower(strings.TrimSpace(provider)) + if normalized == "" { + return "" } - - return p + if canonical, ok := normalizedModelProviderAliasesByName[normalized]; ok { + return canonical + } + return normalized } // ModelKey returns a canonical "provider/model" key for deduplication. diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 9a164bf48..80ea8bb62 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -65,9 +65,7 @@ func TestNormalizeProvider(t *testing.T) { {"z.ai", "zai"}, {"z-ai", "zai"}, {"Z.AI", "zai"}, - {"opencode-zen", "opencode"}, {"qwen", "qwen-portal"}, - {"kimi-code", "kimi-coding"}, {"gpt", "openai"}, {"claude", "anthropic"}, {"glm", "zhipu"}, @@ -79,9 +77,11 @@ func TestNormalizeProvider(t *testing.T) { {"codexcli", "codex-cli"}, {"copilot", "github-copilot"}, // Alibaba Coding Plan aliases - {"alibaba-coding", "coding-plan"}, - {"qwen-coding", "coding-plan"}, - {"alibaba-coding-anthropic", "coding-plan-anthropic"}, + {"alibaba-coding", "alibaba-coding"}, + {"coding-plan", "alibaba-coding"}, + {"qwen-coding", "alibaba-coding"}, + {"alibaba-coding-anthropic", "alibaba-coding-anthropic"}, + {"coding-plan-anthropic", "alibaba-coding-anthropic"}, // Qwen international aliases {"qwen-international", "qwen-intl"}, {"dashscope-intl", "qwen-intl"}, diff --git a/pkg/providers/provider_catalog.go b/pkg/providers/provider_catalog.go index a9178cb81..b20565631 100644 --- a/pkg/providers/provider_catalog.go +++ b/pkg/providers/provider_catalog.go @@ -5,97 +5,10 @@ import ( "strings" ) -// ModelProviderOption describes a canonical provider entry exposed to the Web UI. -type ModelProviderOption struct { - ID string `json:"id"` - DefaultAPIBase string `json:"default_api_base"` - EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"` - CreateAllowed bool `json:"create_allowed"` - DefaultModelAllowed bool `json:"default_model_allowed"` - DefaultAuthMethod string `json:"default_auth_method,omitempty"` - AuthMethodLocked bool `json:"auth_method_locked,omitempty"` -} - -type attachedModelProviderMeta struct { - protocolMeta - createAllowed bool - defaultModelAllowed bool - defaultAuthMethod string - authMethodLocked bool -} - -// attachedModelProviderMetaByName augments protocolMetaByName for provider -// families that are implemented in CreateProviderFromConfig but intentionally -// kept out of the core HTTP metadata map because they have special auth/runtime -// semantics. -var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{ - "azure": {createAllowed: true, defaultModelAllowed: true}, - "anthropic": { - protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, - createAllowed: true, - defaultModelAllowed: true, - }, - "anthropic-messages": { - protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, - createAllowed: true, - defaultModelAllowed: true, - }, - "bedrock": {createAllowed: true, defaultModelAllowed: true}, - "antigravity": { - createAllowed: true, - defaultModelAllowed: true, - defaultAuthMethod: "oauth", - authMethodLocked: true, - }, - "claude-cli": {createAllowed: true, defaultModelAllowed: true}, - "codex-cli": {createAllowed: true, defaultModelAllowed: true}, - "github-copilot": { - protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"}, - createAllowed: true, - defaultModelAllowed: true, - }, - // ElevenLabs is intentionally exposed only as an ASR-capable provider. It - // belongs in the shared model catalog because ASR is configured via - // model_list, but it must not be selectable as the default chat model. - "elevenlabs": { - protocolMeta: protocolMeta{defaultAPIBase: "https://api.elevenlabs.io"}, - createAllowed: true, - defaultModelAllowed: false, - }, -} - // ModelProviderOptions returns the canonical provider catalog exposed to the Web UI. func ModelProviderOptions() []ModelProviderOption { - optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName)) - for provider := range protocolMetaByName { - if NormalizeProvider(provider) != provider { - continue - } - optionsByID[provider] = ModelProviderOption{ - ID: provider, - DefaultAPIBase: DefaultAPIBaseForProtocol(provider), - EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), - CreateAllowed: true, - DefaultModelAllowed: true, - } - } - for provider, meta := range attachedModelProviderMetaByName { - if NormalizeProvider(provider) != provider { - continue - } - optionsByID[provider] = ModelProviderOption{ - ID: provider, - DefaultAPIBase: meta.defaultAPIBase, - EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, - CreateAllowed: meta.createAllowed, - DefaultModelAllowed: meta.defaultModelAllowed, - DefaultAuthMethod: meta.defaultAuthMethod, - AuthMethodLocked: meta.authMethodLocked, - } - } - - options := make([]ModelProviderOption, 0, len(optionsByID)) - for _, option := range optionsByID { + options := make([]ModelProviderOption, 0, len(modelProviderOptionsByName)) + for _, option := range modelProviderOptionsByName { options = append(options, option) } sort.Slice(options, func(i, j int) bool { @@ -107,44 +20,30 @@ func ModelProviderOptions() []ModelProviderOption { // IsSupportedModelProvider reports whether provider resolves to a provider ID // returned by ModelProviderOptions. func IsSupportedModelProvider(provider string) bool { - normalized := NormalizeProvider(provider) - if normalized == "" { - return false - } - if _, ok := protocolMetaByName[normalized]; ok { - return true - } - _, ok := attachedModelProviderMetaByName[normalized] + _, ok := modelProviderOptionForName(provider) return ok } +// IsModelProviderFetchable reports whether provider supports upstream /models +// listing through the launcher fetch endpoint. +func IsModelProviderFetchable(provider string) bool { + option, ok := modelProviderOptionForName(provider) + return ok && option.SupportsFetch +} + // IsCreatableModelProvider reports whether provider can be selected for a new // model entry from the Web UI. func IsCreatableModelProvider(provider string) bool { - normalized := NormalizeProvider(provider) - if normalized == "" { - return false - } - if _, ok := protocolMetaByName[normalized]; ok { - return true - } - meta, ok := attachedModelProviderMetaByName[normalized] - return ok && meta.createAllowed + option, ok := modelProviderOptionForName(provider) + return ok && option.CreateAllowed } // IsDefaultModelProvider reports whether provider can be used as the default // chat model. Some providers such as ASR-only entries are intentionally // exposed in model_list management but cannot drive the gateway default model. func IsDefaultModelProvider(provider string) bool { - normalized := NormalizeProvider(provider) - if normalized == "" { - return false - } - if _, ok := protocolMetaByName[normalized]; ok { - return true - } - meta, ok := attachedModelProviderMetaByName[normalized] - return ok && meta.defaultModelAllowed + option, ok := modelProviderOptionForName(provider) + return ok && option.DefaultModelAllowed } // SplitModelProviderAndID separates a legacy "provider/model" string into its diff --git a/pkg/providers/provider_metadata.go b/pkg/providers/provider_metadata.go new file mode 100644 index 000000000..52d27dad2 --- /dev/null +++ b/pkg/providers/provider_metadata.go @@ -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 +} diff --git a/web/backend/api/model_catalog.go b/web/backend/api/model_catalog.go index ce50deafe..edce03cf3 100644 --- a/web/backend/api/model_catalog.go +++ b/web/backend/api/model_catalog.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/providers" ) // 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. func generateCatalogKey(provider, apiBase, apiKey string) string { - provider = strings.ToLower(strings.TrimSpace(provider)) + provider = providers.NormalizeProvider(provider) apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") hash := sha256.Sum256([]byte(apiKey)) 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 } key := generateCatalogKey(provider, apiBase, apiKey) + provider = providers.NormalizeProvider(provider) store.Entries[key] = &CatalogEntry{ ID: key, - Provider: strings.ToLower(strings.TrimSpace(provider)), + Provider: provider, APIBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"), APIKeyMask: maskAPIKeyValue(apiKey), Models: models, diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 6cfda501d..435664d70 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -126,7 +126,7 @@ func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) { func providerUsesImplicitOAuth(protocol string) bool { switch protocol { - case "antigravity", "google-antigravity": + case "antigravity": return true default: return false @@ -168,11 +168,11 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool { protocol := modelProtocol(m) switch protocol { - case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": + case "claude-cli", "codex-cli", "github-copilot": return true } - if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { + if providers.IsHTTPAPIProtocol(protocol) && providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { apiBase := strings.TrimSpace(m.APIBase) return apiBase == "" || hasLocalAPIBase(apiBase) } @@ -220,11 +220,11 @@ func runLocalModelProbe(m *config.ModelConfig) bool { return probeOllamaModelFunc(apiBase, modelID) case "vllm", "lmstudio": return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) - case "github-copilot", "copilot": + case "github-copilot": return probeTCPServiceFunc(apiBase) - case "claude-cli", "claudecli": + case "claude-cli": return probeCommandAvailableFunc("claude") - case "codex-cli", "codexcli": + case "codex-cli": return probeCommandAvailableFunc("codex") default: if hasLocalAPIBase(apiBase) { @@ -442,7 +442,7 @@ func modelProbeAPIBase(m *config.ModelConfig) string { } switch protocol { - case "github-copilot", "copilot": + case "github-copilot": return "localhost:4321" default: return "" @@ -477,7 +477,7 @@ func oauthProviderForModel(m *config.ModelConfig) (string, bool) { return oauthProviderOpenAI, true case "anthropic": return oauthProviderAnthropic, true - case "antigravity", "google-antigravity": + case "antigravity": return oauthProviderGoogleAntigravity, true default: return "", false diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 4fea86cd8..5c1eb23ad 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -18,19 +18,6 @@ import ( "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. func (h *Handler) registerModelRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/models", h.handleListModels) @@ -667,7 +654,7 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { 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) return } @@ -1012,11 +999,11 @@ func probeModelConnectivity(m *config.ModelConfig) bool { return probeOllamaModel(apiBase, modelID) case "vllm", "lmstudio": return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) - case "github-copilot", "copilot": + case "github-copilot": return probeTCPService(apiBase) - case "claude-cli", "claudecli": + case "claude-cli": return probeCommandAvailable("claude") - case "codex-cli", "codexcli": + case "codex-cli": return probeCommandAvailable("codex") default: // For remote providers (OpenAI, Anthropic, Gemini, DeepSeek, etc.), diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 1ac5b6267..94a3da3b3 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -1900,6 +1900,12 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration t.Fatal("openai provider option missing") } 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") + } 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 { t.Fatal("anthropic provider option missing") @@ -1913,6 +1919,8 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration t.Fatal("github-copilot provider option missing") } else if option.DefaultAPIBase != "localhost:4321" { t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } else if !option.Local { + t.Fatal("github-copilot should be marked local") } if option, ok := optionsByID["elevenlabs"]; !ok { t.Fatal("elevenlabs provider option missing") @@ -1953,6 +1961,11 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration 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) if err != nil { diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go index 116e304b1..7586a45b1 100644 --- a/web/backend/api/oauth.go +++ b/web/backend/api/oauth.go @@ -767,7 +767,7 @@ func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool case oauthProviderAnthropic: return protocol == "anthropic" case oauthProviderGoogleAntigravity: - return protocol == "antigravity" || protocol == "google-antigravity" + return protocol == "antigravity" default: return false } diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 4ed0e0764..5f89cbfcf 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -36,12 +36,20 @@ export interface ModelInfo { export interface ModelProviderOption { id: string + display_name?: string + icon_slug?: string + domain?: string default_api_base: string empty_api_key_allowed: boolean create_allowed: boolean default_model_allowed: boolean + supports_fetch?: boolean default_auth_method?: string auth_method_locked?: boolean + local?: boolean + priority?: number + common_models?: string[] + aliases?: string[] } interface ModelsListResponse { diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 2059199b4..453bbe255 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -36,10 +36,22 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import { FetchModelsDialog } from "./fetch-models-dialog" +import { + getEffectiveAPIBase, + getSubmittedAPIBase, + normalizeApiBase, +} from "./model-provider-form-shared" import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" -import { getProviderKey } from "./provider-label" -import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry" +import { + getCanonicalProviderKey, + getProviderCatalogEntry, + getProviderCatalogMap, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + isProviderAuthMethodLocked, + providerSupportsFetch, +} from "./provider-registry" import { TestModelDialog } from "./test-model-dialog" interface AddForm { @@ -82,37 +94,6 @@ const EMPTY_ADD_FORM: AddForm = { 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 { open: boolean onClose: () => void @@ -144,6 +125,7 @@ export function AddModelSheet({ const [catalogModels, setCatalogModels] = useState([]) const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) + const providerMap = getProviderCatalogMap(providerOptions) const apiKeyPlaceholder = maskedSecretPlaceholder( form.apiKey, @@ -166,8 +148,12 @@ export function AddModelSheet({ // Load catalog models when provider or apiBase changes useEffect(() => { - const providerKey = getProviderKey(form.provider || undefined) - const apiBase = form.apiBase.trim().replace(/\/+$/, "") + const providerKey = getCanonicalProviderKey(form.provider, providerOptions) + const apiBase = getEffectiveAPIBase( + form.provider, + form.apiBase, + providerOptions, + ) if (!form.provider.trim()) { setCatalogModels([]) return @@ -177,7 +163,7 @@ export function AddModelSheet({ .then((res) => { if (cancelled) return 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(/\/+$/, "") return ep === providerKey && eb === apiBase }) @@ -189,7 +175,7 @@ export function AddModelSheet({ return () => { cancelled = true } - }, [form.provider, form.apiBase]) + }, [form.provider, form.apiBase, providerOptions]) const validate = (): boolean => { const errors: Partial> = {} @@ -199,6 +185,9 @@ export function AddModelSheet({ } else if (existingModelNames.some((name) => name.trim() === modelName)) { 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 (modelValidation?.level === "error") { errors.model = t( @@ -223,11 +212,15 @@ export function AddModelSheet({ (value: string, provider: string) => { if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { - const result = validateModelField(value, provider || undefined) + const result = validateModelField( + value, + provider || undefined, + providerOptions, + ) setModelValidation(result) }, 300) }, - [], + [providerOptions], ) const handleModelChange = (e: React.ChangeEvent) => { @@ -241,14 +234,41 @@ export function AddModelSheet({ const handleProviderChange = (provider: string) => { 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 { ...f, - provider, - apiBase: getNextApiBaseForProviderChange( - f.apiBase, - f.provider, - provider, - ), + provider: getCanonicalProviderKey(provider, providerOptions), + apiBase, + authMethod, } }) // 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 const allowed = - providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? + getProviderCatalogEntry(provider, providerOptions)?.defaultModelAllowed ?? false if (!allowed) { setSetAsDefault(false) } + if (fieldErrors.provider) { + setFieldErrors((prev) => ({ ...prev, provider: undefined })) + } } 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 defaultModelAllowed = form.provider - ? (providerOptions?.find((o) => o.id === form.provider) - ?.default_model_allowed ?? false) - : false + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + 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 () => { if (!validate()) return @@ -331,16 +380,18 @@ export function AddModelSheet({ setServerError("") try { const modelName = form.modelName.trim() - const provider = form.provider.trim() + const provider = canonicalProvider const modelId = form.model.trim() await addModel({ model_name: modelName, provider: provider || undefined, model: modelId, - api_base: form.apiBase.trim() || undefined, + api_base: submittedApiBase, api_key: form.apiKey.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, workspace: form.workspace.trim() || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -414,6 +465,8 @@ export function AddModelSheet({ )}
- {form.provider && - FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( - - )} + {providerSupportsFetch(form.provider, providerOptions) && ( + + )} {!form.provider && ( {t("models.field.selectProviderFirst")} @@ -537,19 +589,25 @@ export function AddModelSheet({
- - setForm((f) => ({ ...f, apiKey: v }))} - placeholder={apiKeyPlaceholder} - /> - + {!isOAuth && ( + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={apiKeyPlaceholder} + /> + + )} - + @@ -591,12 +649,19 @@ export function AddModelSheet({ @@ -751,9 +816,10 @@ export function AddModelSheet({ open={fetchOpen} onClose={() => setFetchOpen(false)} onFill={handleFetchFill} - provider={form.provider} + provider={canonicalProvider} apiKey={form.apiKey} - apiBase={form.apiBase} + apiBase={effectiveApiBase} + backendOptions={providerOptions} /> setTestOpen(false)} inlineParams={{ - provider: form.provider, + provider: canonicalProvider, model: form.model, - apiBase: form.apiBase, + apiBase: effectiveApiBase, apiKey: form.apiKey, - authMethod: form.authMethod, + authMethod: effectiveAuthMethod, }} /> diff --git a/web/frontend/src/components/models/catalog-dialog.tsx b/web/frontend/src/components/models/catalog-dialog.tsx index 9fe7283c4..dcca1f717 100644 --- a/web/frontend/src/components/models/catalog-dialog.tsx +++ b/web/frontend/src/components/models/catalog-dialog.tsx @@ -11,6 +11,7 @@ import { toast } from "sonner" import { type CatalogEntry, type CatalogModel, + type ModelProviderOption, addModel, deleteCatalog, getCatalogs, @@ -27,21 +28,26 @@ import { import { Input } from "@/components/ui/input" import { refreshGatewayState } from "@/store/gateway" -import { getProviderLabel } from "./provider-label" -import { PROVIDER_MAP } from "./provider-registry" +import { + getCanonicalProviderKey, + getProviderCatalogMap, +} from "./provider-registry" interface CatalogDialogProps { open: boolean onClose: () => void onModelAdded: () => void + providerOptions?: ModelProviderOption[] } export function CatalogDialog({ open, onClose, onModelAdded, + providerOptions, }: CatalogDialogProps) { const { t } = useTranslation() + const providerMap = getProviderCatalogMap(providerOptions) const [loading, setLoading] = useState(false) const [entries, setEntries] = useState([]) const [expandedId, setExpandedId] = useState(null) @@ -188,6 +194,11 @@ export function CatalogDialog({ const isExpanded = expandedId === entry.id const entrySelected = selected.get(entry.id) || new Set() const filteredModels = getFilteredModels(entry.models) + const providerKey = getCanonicalProviderKey( + entry.provider, + providerOptions, + ) + const providerDef = providerMap.get(providerKey) return (
- {getProviderLabel(entry.provider)} + {providerDef?.label || providerKey} {entry.api_key_mask} @@ -290,7 +301,7 @@ export function CatalogDialog({
{entrySelected.size > 0 && (
- {PROVIDER_MAP.get(entry.provider)?.requiresApiKey !== + {providerDef?.requiresApiKey !== false && (
{t("models.catalog.needApiKey")} diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index 7aec4d698..c01732bed 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -37,13 +37,21 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import { FetchModelsDialog } from "./fetch-models-dialog" +import { + getEffectiveAPIBase, + getSubmittedAPIBase, + normalizeApiBase, +} from "./model-provider-form-shared" import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" -import { getProviderKey } from "./provider-label" import { - FETCHABLE_PROVIDER_KEYS, - PROVIDER_API_BASES, - PROVIDER_MAP, + getCanonicalProviderKey, + getProviderCatalogEntry, + getProviderCatalogMap, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + isProviderAuthMethodLocked, + providerSupportsFetch, } from "./provider-registry" import { TestModelDialog } from "./test-model-dialog" @@ -74,39 +82,9 @@ interface EditModelSheetProps { 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 { return { - provider: model.provider ?? "", + provider: getCanonicalProviderKey(model.provider), modelId: model.model, apiKey: "", apiBase: model.api_base ?? "", @@ -166,6 +144,7 @@ export function EditModelSheet({ const [catalogModels, setCatalogModels] = useState([]) const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) + const providerMap = getProviderCatalogMap(providerOptions) const initialForm = model ? buildInitialEditForm(model) : null const isDirty = @@ -182,12 +161,19 @@ export function EditModelSheet({ setFetchedModels([]) setCatalogModels([]) // Load matching catalog models - const providerKey = getProviderKey(model.provider || undefined) - const apiBase = (model.api_base ?? "").trim().replace(/\/+$/, "") + const providerKey = getCanonicalProviderKey( + model.provider, + providerOptions, + ) + const apiBase = getEffectiveAPIBase( + model.provider ?? "", + model.api_base ?? "", + providerOptions, + ) getCatalogs() .then((res) => { 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(/\/+$/, "") return ep === providerKey && eb === apiBase }) @@ -197,22 +183,28 @@ export function EditModelSheet({ }) .catch(() => {}) } - }, [model]) + }, [model, providerOptions]) const setField = (key: keyof EditForm) => - (e: React.ChangeEvent) => + (e: React.ChangeEvent) => { + if (error) setError("") setForm((f) => ({ ...f, [key]: e.target.value })) + } const debouncedValidateModel = useCallback( (value: string, provider: string) => { if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { - const result = validateModelField(value, provider || undefined) + const result = validateModelField( + value, + provider || undefined, + providerOptions, + ) setModelValidation(result) }, 300) }, - [], + [providerOptions], ) const handleModelChange = (e: React.ChangeEvent) => { @@ -222,16 +214,50 @@ export function EditModelSheet({ } const handleProviderChange = (provider: string) => { - setForm((f) => ({ - ...f, - provider, - apiBase: getNextApiBaseForProviderChange(f.apiBase, f.provider, provider), - })) + if (error) setError("") + 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 { + ...f, + provider: getCanonicalProviderKey(provider, providerOptions), + apiBase, + authMethod, + } + }) if (form.modelId) { debouncedValidateModel(form.modelId, provider) } const allowed = - providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? + getProviderCatalogEntry(provider, providerOptions)?.defaultModelAllowed ?? false if (!allowed) { 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 defaultModelAllowed = form.provider - ? (providerOptions?.find((o) => o.id === form.provider) - ?.default_model_allowed ?? false) - : false + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + 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 () => { if (!model) return + if (!providerDef) { + setError(t("models.field.providerInvalid")) + return + } if (!form.modelId.trim()) { setError(t("models.add.errorRequired")) return @@ -304,7 +360,7 @@ export function EditModelSheet({ setError("") try { const modelId = form.modelId.trim() - const provider = form.provider.trim() + const provider = canonicalProvider const streaming = model.streaming?.enabled === true || form.streamingEnabled ? { enabled: form.streamingEnabled } @@ -313,18 +369,20 @@ export function EditModelSheet({ model_name: model.model_name, provider: provider, model: modelId, - api_base: form.apiBase || undefined, - api_key: form.apiKey || undefined, - proxy: form.proxy || undefined, - auth_method: form.authMethod || undefined, - connect_mode: form.connectMode || undefined, - workspace: form.workspace || undefined, + api_base: submittedApiBase, + api_key: form.apiKey.trim() || undefined, + proxy: form.proxy.trim() || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod.trim() || undefined, + connect_mode: form.connectMode.trim() || undefined, + workspace: form.workspace.trim() || 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 ? Number(form.requestTimeout) : undefined, - thinking_level: form.thinkingLevel || undefined, + thinking_level: form.thinkingLevel.trim() || undefined, tool_schema_transform: form.toolSchemaTransform.trim() || undefined, streaming, extra_body: extraBody, @@ -349,7 +407,6 @@ export function EditModelSheet({ } } - const isOAuth = model?.auth_method === "oauth" const hasSavedAPIKey = Boolean(model?.api_key) const apiKeyPlaceholder = hasSavedAPIKey ? maskedSecretPlaceholder( @@ -382,6 +439,12 @@ export function EditModelSheet({ )}
- {form.provider && - FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( - - )} + {providerSupportsFetch(form.provider, providerOptions) && ( + + )}
@@ -514,7 +576,7 @@ export function EditModelSheet({ @@ -557,12 +619,19 @@ export function EditModelSheet({ @@ -719,11 +788,11 @@ export function EditModelSheet({ open={testOpen} onClose={() => setTestOpen(false)} inlineParams={{ - provider: form.provider, + provider: canonicalProvider, model: form.modelId, - apiBase: form.apiBase, + apiBase: effectiveApiBase, apiKey: form.apiKey, - authMethod: form.authMethod, + authMethod: effectiveAuthMethod, modelIndex: model?.index, }} /> @@ -732,9 +801,10 @@ export function EditModelSheet({ open={fetchOpen} onClose={() => setFetchOpen(false)} onFill={handleFetchFill} - provider={form.provider} + provider={canonicalProvider} apiKey={form.apiKey} - apiBase={form.apiBase} + apiBase={effectiveApiBase} + backendOptions={providerOptions} /> ) diff --git a/web/frontend/src/components/models/fetch-models-dialog.tsx b/web/frontend/src/components/models/fetch-models-dialog.tsx index 09b602e6d..8ea9e52a7 100644 --- a/web/frontend/src/components/models/fetch-models-dialog.tsx +++ b/web/frontend/src/components/models/fetch-models-dialog.tsx @@ -2,7 +2,11 @@ import { IconDownload, IconLoader2 } from "@tabler/icons-react" import { useCallback, useEffect, useState } from "react" 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 { Dialog, @@ -14,7 +18,10 @@ import { } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" -import { PROVIDER_MAP } from "./provider-registry" +import { + getCanonicalProviderKey, + getProviderCatalogMap, +} from "./provider-registry" interface FetchModelsDialogProps { open: boolean @@ -23,6 +30,7 @@ interface FetchModelsDialogProps { provider: string apiKey: string apiBase: string + backendOptions?: ModelProviderOption[] } export function FetchModelsDialog({ @@ -32,6 +40,7 @@ export function FetchModelsDialog({ provider, apiKey, apiBase, + backendOptions, }: FetchModelsDialogProps) { const { t } = useTranslation() const [fetching, setFetching] = useState(false) @@ -40,7 +49,8 @@ export function FetchModelsDialog({ const [error, setError] = 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 handleFetch = useCallback(async () => { @@ -50,7 +60,7 @@ export function FetchModelsDialog({ setSelected(new Set()) try { const res = await fetchUpstreamModels({ - provider, + provider: canonicalProvider, api_key: apiKey, api_base: apiBase, }) @@ -62,7 +72,7 @@ export function FetchModelsDialog({ } finally { 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) useEffect(() => { @@ -122,7 +132,7 @@ export function FetchModelsDialog({ {t("models.fetch.description")} {provider && ( - {t("models.fetch.providerLabel")} {provider} + {t("models.fetch.providerLabel")} {canonicalProvider} {apiBase && ` | ${apiBase}`} )} diff --git a/web/frontend/src/components/models/model-provider-form-shared.ts b/web/frontend/src/components/models/model-provider-form-shared.ts new file mode 100644 index 000000000..103cbe3d3 --- /dev/null +++ b/web/frontend/src/components/models/model-provider-form-shared.ts @@ -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 +} diff --git a/web/frontend/src/components/models/model-validation.ts b/web/frontend/src/components/models/model-validation.ts index 10ac0d274..43d16bb10 100644 --- a/web/frontend/src/components/models/model-validation.ts +++ b/web/frontend/src/components/models/model-validation.ts @@ -5,10 +5,12 @@ * Messages use i18n keys with interpolation params — callers must * translate them via t(key, params). */ +import type { ModelProviderOption } from "@/api/models" + import { - KNOWN_PROVIDER_KEYS, - PROVIDER_ALIASES, findClosestProvider, + getCanonicalProviderKey, + getKnownProviderKeys, } from "./provider-registry" export type ValidationLevel = "error" | "warning" | "success" @@ -27,9 +29,11 @@ export interface FieldValidation { export function validateModelField( input: string, selectedProvider?: string, + backendOptions?: ModelProviderOption[], ): FieldValidation { const trimmed = input.trim() if (!trimmed) return { level: "success", messageKey: "" } + const knownProviderKeys = getKnownProviderKeys(backendOptions) // Hard errors if (/\s/.test(trimmed)) { @@ -78,10 +82,10 @@ export function validateModelField( return { level: "error", messageKey: "models.validation.emptyModel" } } - if (!KNOWN_PROVIDER_KEYS.has(provider)) { + if (!knownProviderKeys.has(provider)) { // Check aliases - const alias = PROVIDER_ALIASES[provider] - if (alias) { + const alias = getCanonicalProviderKey(provider, backendOptions) + if (alias && alias !== provider) { return { level: "warning", messageKey: "models.validation.shouldUse", @@ -90,7 +94,7 @@ export function validateModelField( } } // Typo check - const closest = findClosestProvider(provider) + const closest = findClosestProvider(provider, backendOptions) if (closest) { return { level: "warning", diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index 9c0c400db..ecad762b0 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -23,13 +23,16 @@ import { AddModelSheet } from "./add-model-sheet" import { CatalogDialog } from "./catalog-dialog" import { DeleteModelDialog } from "./delete-model-dialog" import { EditModelSheet } from "./edit-model-sheet" -import { getProviderKey, getProviderLabel } from "./provider-label" -import { PROVIDER_PRIORITY } from "./provider-registry" +import { + getCanonicalProviderKey, + getProviderCatalogMap, +} from "./provider-registry" import { ProviderSection } from "./provider-section" +import type { ProviderCatalogEntry } from "./provider-registry" interface ProviderGroup { key: string - label: string + provider: Pick models: ModelInfo[] hasDefault: boolean availableCount: number @@ -51,8 +54,10 @@ export function ModelsPage() { const [settingDefaultIndex, setSettingDefaultIndex] = useState( null, ) + const providerMap = getProviderCatalogMap(providerOptions) const fetchModels = useCallback(async () => { + setLoading(true) try { const data = await getModels() const sorted = [...data.models].sort((a, b) => { @@ -97,12 +102,21 @@ export function ModelsPage() { } } - const grouped: Record = {} + const grouped: Record< + string, + { provider: Pick; models: ModelInfo[] } + > = {} 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]) { grouped[providerKey] = { - label: getProviderLabel(model.provider), + provider: { + key: providerKey, + label: providerDef?.label || providerKey, + iconSlug: providerDef?.iconSlug, + domain: providerDef?.domain, + }, models: [], } } @@ -116,7 +130,7 @@ export function ModelsPage() { ).length return { key, - label: group.label, + provider: group.provider, models: group.models, hasDefault: group.models.some((model) => model.is_default), availableCount, @@ -130,13 +144,13 @@ export function ModelsPage() { return b.availableCount - a.availableCount } - const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER - const bPriority = PROVIDER_PRIORITY[b.key] ?? Number.MAX_SAFE_INTEGER + const aPriority = -(providerMap.get(a.key)?.priority ?? 0) + const bPriority = -(providerMap.get(b.key)?.priority ?? 0) if (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) @@ -149,11 +163,17 @@ export function ModelsPage() { size="sm" variant="outline" onClick={() => setCatalogOpen(true)} + disabled={providerOptions.length === 0} > {t("models.catalog.button")} - @@ -172,6 +192,11 @@ export function ModelsPage() {

{t("models.description")}

+ {!loading && providerOptions.length === 0 && ( +

+ {t("models.providerCatalogUnavailable")} +

+ )}
{loading && ( @@ -181,8 +206,19 @@ export function ModelsPage() { )} {fetchError && ( -
- {fetchError} +
+

{fetchError}

+
+ +
)} @@ -191,8 +227,7 @@ export function ModelsPage() { {providerGroups.map((providerGroup) => ( setCatalogOpen(false)} onModelAdded={fetchModels} + providerOptions={providerOptions} />
) diff --git a/web/frontend/src/components/models/provider-combobox.tsx b/web/frontend/src/components/models/provider-combobox.tsx index c023694c5..d7f9c15d1 100644 --- a/web/frontend/src/components/models/provider-combobox.tsx +++ b/web/frontend/src/components/models/provider-combobox.tsx @@ -11,7 +11,6 @@ import { CommandItem, CommandList, } from "@/components/ui/command" -import { Input } from "@/components/ui/input" import { Popover, PopoverContent, @@ -21,9 +20,9 @@ import { cn } from "@/lib/utils" import { ProviderIcon } from "./provider-icon" import { - type MergedProvider, - PROVIDERS, - mergeWithBackendOptions, + getCanonicalProviderKey, + type ProviderCatalogEntry, + getProviderCatalog, } from "./provider-registry" import type { ModelProviderOption } from "@/api/models" @@ -48,47 +47,23 @@ export function ProviderCombobox({ }: ProviderComboboxProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) - const [customMode, setCustomMode] = useState(false) - const [customValue, setCustomValue] = useState("") const [containerEl, setContainerEl] = useState(null) useEffect(() => { setContainerEl(containerRef?.current ?? null) }, [containerRef]) - const allProviders: MergedProvider[] = backendOptions - ? mergeWithBackendOptions(backendOptions) - : [...PROVIDERS] - .sort((a, b) => b.priority - a.priority) - .map((p) => ({ - ...p, - createAllowed: true, - defaultModelAllowed: false, - })) + const canonicalValue = getCanonicalProviderKey(value, backendOptions) + const allProviders: ProviderCatalogEntry[] = getProviderCatalog(backendOptions) const visible = filterCreateAllowed - ? allProviders.filter((p) => p.createAllowed) + ? allProviders.filter((p) => p.createAllowed || p.key === canonicalValue) : allProviders const allKeys = new Set(allProviders.map((p) => p.key)) - const selected = allProviders.find((p) => p.key === value) - const isCustom = value && !allKeys.has(value) + const selected = allProviders.find((p) => p.key === canonicalValue) + const showUnknownValue = value && !allKeys.has(canonicalValue) const handleSelect = (currentValue: string) => { - if (currentValue === "__custom__") { - 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) + onChange(currentValue === canonicalValue ? "" : currentValue) setOpen(false) } @@ -97,7 +72,6 @@ export function ProviderCombobox({ open={open} onOpenChange={(isOpen: boolean) => { setOpen(isOpen) - if (!isOpen) setCustomMode(false) }} > @@ -110,12 +84,11 @@ export function ProviderCombobox({ {selected ? ( - {selected.labelZh || selected.label} + {selected.label} - ) : isCustom ? ( + ) : showUnknownValue ? ( {value} @@ -128,97 +101,52 @@ export function ProviderCombobox({ - {customMode ? ( -
- setCustomValue(e.target.value)} - placeholder={t("models.combobox.customPlaceholder")} - className="h-8 font-mono text-sm" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") handleCustomConfirm() - if (e.key === "Escape") { - setCustomMode(false) - setOpen(false) - } - }} - /> -
- - -
-
- ) : ( - - - - {t("models.combobox.noProvider")} - - {visible.map((provider) => ( + + + + + {backendOptions && backendOptions.length > 0 + ? t("models.combobox.noProvider") + : t("models.combobox.noCatalog")} + + + {visible.map((provider) => { + const disabled = !provider.createAllowed && provider.key !== value + + return ( - {provider.labelZh || provider.label} + {provider.label} {provider.isLocal && ( - - {t("models.combobox.local")} - + + {t("models.combobox.local")} + )} - ))} - - - {t("models.combobox.custom")} - - {isCustom && ( - - )} - - - - - )} + ) + })} + + +
) diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx index 6b7f7b6cf..673b1da3d 100644 --- a/web/frontend/src/components/models/provider-icon.tsx +++ b/web/frontend/src/components/models/provider-icon.tsx @@ -1,22 +1,18 @@ import { useMemo, useState } from "react" -import { PROVIDER_DOMAINS, PROVIDER_ICON_SLUGS } from "./provider-registry" +import type { ProviderCatalogEntry } from "./provider-registry" interface ProviderIconProps { - providerKey: string - providerLabel: string + provider: Pick } -export function ProviderIcon({ - providerKey, - providerLabel, -}: ProviderIconProps) { +export function ProviderIcon({ provider }: ProviderIconProps) { const [sourceIndex, setSourceIndex] = useState(0) const [loadFailed, setLoadFailed] = useState(false) - const initial = providerLabel.trim().charAt(0).toUpperCase() || "?" + const initial = provider.label.trim().charAt(0).toUpperCase() || "?" const iconUrls = useMemo(() => { - const slug = PROVIDER_ICON_SLUGS[providerKey] - const domain = PROVIDER_DOMAINS[providerKey] + const slug = provider.iconSlug + const domain = provider.domain const urls: string[] = [] if (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`) } return urls - }, [providerKey]) + }, [provider.domain, provider.iconSlug]) const iconUrl = iconUrls[sourceIndex] @@ -41,7 +37,7 @@ export function ProviderIcon({ {`${providerLabel} [p.key, p])) +function buildAliasMap( + backendOptions?: ModelProviderOption[], +): Record { + const aliases: Record = {} + 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 = Object.fromEntries( - PROVIDERS.map((p) => [p.key, p.labelZh || p.label]), -) +export function getProviderAliasMap( + backendOptions?: ModelProviderOption[], +): Record { + return buildAliasMap(backendOptions) +} -export const PROVIDER_ALIASES: Record = Object.fromEntries( - PROVIDERS.flatMap((p) => (p.aliases || []).map((a) => [a, p.key])), -) +export function getCanonicalProviderKey( + 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 { + return new Set(getProviderCatalog(backendOptions).map((p) => p.key)) +} -export const FETCHABLE_PROVIDER_KEYS = new Set( - PROVIDERS.filter((p) => p.supportsFetch).map((p) => p.key), -) +export function getProviderCatalog( + backendOptions?: ModelProviderOption[], +): ProviderCatalogEntry[] { + if (!backendOptions || backendOptions.length === 0) { + return [] + } -export const PROVIDER_ICON_SLUGS: Record = Object.fromEntries( - PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]), -) + return [...backendOptions] + .map(toCatalogEntry) + .sort((a, b) => b.priority - a.priority) +} -export const PROVIDER_DOMAINS: Record = Object.fromEntries( - PROVIDERS.filter((p) => p.domain).map((p) => [p.key, p.domain!]), -) +export function getProviderCatalogMap( + backendOptions?: ModelProviderOption[], +): Map { + return new Map(getProviderCatalog(backendOptions).map((p) => [p.key, p])) +} -export const PROVIDER_PRIORITY: Record = Object.fromEntries( - PROVIDERS.map((p) => [p.key, p.priority]), -) +export function getProviderCatalogEntry( + 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 = Object.fromEntries( - PROVIDERS.filter((p) => p.defaultApiBase).map((p) => [ - p.key, - p.defaultApiBase!, - ]), -) +export function getProviderDefaultAPIBase( + provider: string | undefined, + backendOptions?: ModelProviderOption[], +): 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. * 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() 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) if (dist < bestDist) { bestDist = dist 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) if (dist < bestDist) { bestDist = dist - best = PROVIDER_ALIASES[alias] + best = getProviderAliasMap(backendOptions)[alias] } } return best @@ -477,55 +193,3 @@ function editDistance(a: string, b: string): number { } 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) -} diff --git a/web/frontend/src/components/models/provider-section.tsx b/web/frontend/src/components/models/provider-section.tsx index a14519265..495c13fb7 100644 --- a/web/frontend/src/components/models/provider-section.tsx +++ b/web/frontend/src/components/models/provider-section.tsx @@ -5,10 +5,10 @@ import type { ModelInfo } from "@/api/models" import { ModelCard } from "./model-card" import { ProviderIcon } from "./provider-icon" +import type { ProviderCatalogEntry } from "./provider-registry" interface ProviderSectionProps { - provider: string - providerKey: string + provider: Pick models: ModelInfo[] onEdit: (model: ModelInfo) => void onSetDefault: (model: ModelInfo) => void @@ -18,7 +18,6 @@ interface ProviderSectionProps { export function ProviderSection({ provider, - providerKey, models, onEdit, onSetDefault, @@ -38,8 +37,8 @@ export function ProviderSection({
- - {provider} + + {provider.label}
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index ab60a8142..f341dcacb 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -232,6 +232,8 @@ "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.", "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", "noDefaultHintSuffix": "to set one.", "status": { @@ -251,7 +253,8 @@ "setting": "Setting as default...", "unavailable": "Cannot set unavailable model as default", "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": { "isDefault": "Cannot delete the default model" @@ -288,8 +291,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "e.g. openai", - "providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.", + "providerPlaceholder": "Select a provider", + "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", "apiBase": "API Base URL", "apiKey": "API Key", @@ -299,6 +303,7 @@ "proxyHint": "Optional. e.g. http://127.0.0.1:7890", "authMethod": "Auth Method", "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", "connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.", "workspace": "Workspace Path", @@ -395,9 +400,8 @@ "selectProvider": "Select provider...", "searchProvider": "Search provider...", "noProvider": "No provider found.", - "local": "local", - "custom": "Custom provider...", - "customPlaceholder": "Enter provider name..." + "noCatalog": "Provider catalog unavailable.", + "local": "local" } }, "channels": { diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index 4af5218f7..1cc9db734 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -142,10 +142,12 @@ }, "common": { "cancel": "Cancelar", + "close": "Fechar", "save": "Salvar", "saving": "Salvando...", "reset": "Redefinir", "confirm": "Confirmar", + "fix": "Corrigir", "saveChangesTitle": "Você tem alterações de configuração não salvas", "restartRequiredTitle": "Reinício do gateway necessário", "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.", "restartHint": "Alterações na configuração de modelos só têm efeito após o gateway reiniciar.", "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", "noDefaultHintSuffix": "para definir um.", "status": { @@ -286,8 +290,10 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "ex: openai", - "providerHint": "Opcional. Se especificado, este valor é usado como o provider efetivo, e Identificador do Modelo é interpretado como o ID canônico do modelo.", + "providerPlaceholder": "Selecione um provider", + "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", "apiKey": "API Key", "apiKeyPlaceholder": "Digite sua API Key", @@ -296,6 +302,7 @@ "proxyHint": "Opcional. ex: http://127.0.0.1:7890", "authMethod": "Método de Autenticação", "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", "connectModeHint": "Modo de conexão para providers baseados em CLI: stdio ou grpc.", "workspace": "Caminho do Workspace", @@ -308,14 +315,15 @@ "thinkingLevelHint": "Orçamento de pensamento estendido: off, low, medium, high, xhigh, adaptive.", "maxTokensField": "Campo de Max Tokens", "maxTokensFieldHint": "Sobrescreve o nome do campo de max tokens na requisição, ex: max_completion_tokens.", - "toolSchemaTransform": "Transformação de Schema de Tool", - "toolSchemaTransformHint": "Transformação opcional de compatibilidade para schemas JSON de tools. Deixe em branco para comportamento nativo. Valores suportados: simple.", + "toolSchemaTransform": "Transformação de Schema de Ferramentas", + "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", "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", "extraBodyHint": "Campos JSON adicionais para injetar no body da requisição, ex: {\"reasoning_split\": true}.", "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": { "title": "Configurar {{name}}", @@ -323,6 +331,76 @@ "oauthNote": "Este provider usa OAuth — não é necessária API Key.", "saveError": "Falha ao salvar", "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": { diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 431dc9927..b8d3c2367 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -232,6 +232,8 @@ "unsavedPrompt": "当前修改尚未保存,保存后才会写入模型配置。", "restartHint": "模型配置保存后需要重启服务才能生效。", "loadError": "加载模型列表失败", + "retry": "重试", + "providerCatalogUnavailable": "后端 Provider catalog 暂不可用,待模型 API 成功加载后才能选择新的 Provider。", "noDefaultHintPrefix": "尚未设置默认模型,点击", "noDefaultHintSuffix": "设为默认。", "status": { @@ -396,6 +398,7 @@ "selectProvider": "选择服务商...", "searchProvider": "搜索服务商...", "noProvider": "未找到服务商。", + "noCatalog": "Provider catalog 暂不可用。", "local": "本地", "custom": "自定义服务商...", "customPlaceholder": "输入服务商名称..."