mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(models): unify provider metadata around backend catalog (#2896)
* feat(models): unify provider metadata around backend catalog - Move shared provider metadata and alias normalization into backend-owned provider catalog - Expose display, fetch, auth, and default model metadata through /api/models provider_options - Replace frontend static provider registry with catalog-driven selection, validation, grouping, and fallback rendering - Treat provider default api_base as placeholder and effective fetch/test base while keep submitted api_base separate from derived defaults - Add model page retry handling, touched locale updates, and provider metadata assertions in backend tests * fix(models): canonicalize backend provider aliases and common models * fix(models): restore deepseek common model recommendations
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user