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

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

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

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

* fix(models): restore deepseek common model recommendations
This commit is contained in:
LC
2026-05-20 11:50:34 +08:00
committed by GitHub
parent 639b32703a
commit 548dc15acd
28 changed files with 1441 additions and 1084 deletions
+4 -2
View File
@@ -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,
+8 -8
View File
@@ -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
+4 -17
View File
@@ -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.),
+13
View File
@@ -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 {
+1 -1
View File
@@ -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
}