Files
picoclaw/web/backend/api/model_catalog.go
T
LC 548dc15acd 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
2026-05-20 11:50:34 +08:00

172 lines
4.7 KiB
Go

package api
import (
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"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.
type CatalogModel struct {
ID string `json:"id"`
OwnedBy string `json:"owned_by,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// CatalogEntry is a saved list of upstream models fetched for a specific provider+key combination.
type CatalogEntry struct {
ID string `json:"id"`
Provider string `json:"provider"`
APIBase string `json:"api_base"`
APIKeyMask string `json:"api_key_mask"`
Models []CatalogModel `json:"models"`
FetchedAt string `json:"fetched_at"`
}
// CatalogStore holds all saved model catalogs.
type CatalogStore struct {
Entries map[string]*CatalogEntry `json:"entries"`
}
func catalogFilePath() string {
return filepath.Join(config.GetHome(), "model_catalogs.json")
}
// generateCatalogKey creates a deterministic key for a provider+base+key combination.
func generateCatalogKey(provider, apiBase, apiKey string) string {
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])
}
// maskAPIKeyValue masks an API key for display.
// Keys longer than 12 chars show prefix + last 4 chars: "sk-****abcd".
// Keys 9-12 chars show prefix + last 2 chars: "sk-****cd".
// Shorter keys are fully masked as "****".
// Empty keys return empty string.
// Ensure at least 40% of the key will not be displayed.
func maskAPIKeyValue(key string) string {
key = strings.TrimSpace(key)
if key == "" {
return ""
}
if len(key) <= 8 {
return "****"
}
if len(key) <= 12 {
return key[:3] + "****" + key[len(key)-2:]
}
return key[:3] + "****" + key[len(key)-4:]
}
func loadCatalogs() (*CatalogStore, error) {
path := catalogFilePath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &CatalogStore{Entries: make(map[string]*CatalogEntry)}, nil
}
return nil, err
}
var store CatalogStore
if err := json.Unmarshal(data, &store); err != nil {
return nil, err
}
if store.Entries == nil {
store.Entries = make(map[string]*CatalogEntry)
}
return &store, nil
}
func saveCatalogs(store *CatalogStore) error {
path := catalogFilePath()
data, err := json.MarshalIndent(store, "", " ")
if err != nil {
return err
}
return fileutil.WriteFileAtomic(path, data, 0o600)
}
// SaveCatalog persists a fetched model list for a given provider+key combination.
// If a catalog with the same key already exists, it is updated.
func SaveCatalog(provider, apiBase, apiKey string, models []CatalogModel) error {
store, err := loadCatalogs()
if err != nil {
return err
}
key := generateCatalogKey(provider, apiBase, apiKey)
provider = providers.NormalizeProvider(provider)
store.Entries[key] = &CatalogEntry{
ID: key,
Provider: provider,
APIBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"),
APIKeyMask: maskAPIKeyValue(apiKey),
Models: models,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
}
return saveCatalogs(store)
}
// handleListCatalogs returns all saved model catalogs.
//
// GET /api/models/catalog
func (h *Handler) handleListCatalogs(w http.ResponseWriter, r *http.Request) {
store, err := loadCatalogs()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load catalogs: %v", err), http.StatusInternalServerError)
return
}
entries := make([]*CatalogEntry, 0, len(store.Entries))
for _, e := range store.Entries {
entries = append(entries, e)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"entries": entries,
"total": len(entries),
})
}
// handleDeleteCatalog deletes a saved model catalog by ID.
//
// DELETE /api/models/catalog/{id}
func (h *Handler) handleDeleteCatalog(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
store, err := loadCatalogs()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load catalogs: %v", err), http.StatusInternalServerError)
return
}
if _, ok := store.Entries[id]; !ok {
http.Error(w, "catalog not found", http.StatusNotFound)
return
}
delete(store.Entries, id)
if err := saveCatalogs(store); err != nil {
http.Error(w, fmt.Sprintf("Failed to save catalogs: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}