Files
picoclaw/web/backend/api/model_catalog.go
T
肆月 f6190b54de feat(web,api): fetch models and saved catalog support (#2832)
* feat(web,api): add fetch models and saved catalog support

Split from PR #2752 (part 2 of 3).

Backend:
- /api/models/catalog endpoint for browsing remote model catalogs
- /api/models/fetch endpoint for fetching available models from providers
- Credential reuse with provider/API base matching for security
- Default API base resolution for providers without explicit base

Frontend:
- FetchModelsDialog for importing models from remote providers
- CatalogDialog for browsing and importing from model catalogs
- Static import for FetchModelsDialog (replaces dynamic import from PR1)
- Dynamic import retained for TestModelDialog (PR3 territory)

* fix(web,api): support bare-array responses in fetchOpenAICompatibleModels

* fix(web,api): tighten maskAPIKeyValue to match maskAPIKey policy

For 9-12 character keys, maskAPIKeyValue exposed first 4 + last 4
chars (only 1 char masked for a 9-char key). Now uses the same
policy as maskAPIKey: first 3 + last 2 for 9-12 chars, first 3 +
last 4 for longer keys. Adds tests covering all key length boundaries.
2026-05-15 09:49:03 +08:00

170 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"
)
// 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 = strings.ToLower(strings.TrimSpace(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)
store.Entries[key] = &CatalogEntry{
ID: key,
Provider: strings.ToLower(strings.TrimSpace(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"})
}