Files
new-api/model/model_metadata_sync.go
CaIon 0c76e4dae7 feat(models): rework model/vendor management and pricing
Introduce a unified model management experience: catalog metadata
validation, vendor management, batch delete with channel/pricing
cleanup, model pricing snapshot editing with optimistic concurrency,
and an upstream ratio-sync flow with price cells. Move configuration
into dedicated pricing config/metadata-sync/vendor-management backend
services and add audit records for model/vendor/pricing mutations.

Rework the models page around vendors and model connections, add
model-pricing and vendor-management dialogs, and replace the shared
Select usages with the Combobox component across subscriptions,
plugins, OAuth presets, audit filters, and settings. Add the model
pricing panel and verify behavior with focused tests.
2026-09-06 22:49:02 +08:00

279 lines
8.6 KiB
Go

package model
import (
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var metadataMutationMu sync.Mutex
// metadataTransaction serializes changes to vendors and model ownership before
// acquiring model/vendor row locks. The option anchor also covers empty tables.
func metadataTransaction(change func(*gorm.DB) error) error {
metadataMutationMu.Lock()
defer metadataMutationMu.Unlock()
return DB.Transaction(func(tx *gorm.DB) error {
if err := lockMetadataMutation(tx); err != nil {
return err
}
return change(tx)
})
}
func lockMetadataMutation(tx *gorm.DB) error {
anchor := Option{Key: "metadata_sync_lock", Value: ""}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&anchor).Error; err != nil {
return err
}
return lockForUpdate(tx).Where(commonKeyCol+" = ?", anchor.Key).First(&anchor).Error
}
var ErrMetadataSyncConflict = errors.New("metadata changed; preview again before applying")
var MetadataSyncFields = []string{"description", "icon", "tags", "vendor", "endpoints", "name_rule", "status"}
type MetadataValues struct {
Description string `json:"description"`
Icon string `json:"icon"`
Tags string `json:"tags"`
Vendor string `json:"vendor"`
Endpoints string `json:"endpoints"`
NameRule int `json:"name_rule"`
Status int `json:"status"`
}
type MetadataSyncSelection struct {
ModelName string `json:"model_name"`
RecordVersion string `json:"record_version"`
Create bool `json:"create"`
Fields []string `json:"fields"`
}
type MetadataSyncUpdate struct {
MetadataSyncSelection
Values MetadataValues
}
type MetadataSyncResult struct {
CreatedModels []string `json:"created_models"`
UpdatedModels []MetadataSyncSelection `json:"updated_models"`
CreatedVendors []string `json:"created_vendors"`
}
func MetadataRecordVersion(local *Model, localVendor, upstreamVendor *Vendor) string {
encoded, _ := common.Marshal([]any{local, localVendor, upstreamVendor})
return fmt.Sprintf("%x", sha256.Sum256(encoded))
}
func GetMetadataSyncState(db *gorm.DB) (map[string]*Model, map[string]*Vendor, error) {
var models []*Model
var vendors []*Vendor
if err := db.Session(&gorm.Session{}).Order("id").Find(&models).Error; err != nil {
return nil, nil, err
}
if err := db.Session(&gorm.Session{}).Order("id").Find(&vendors).Error; err != nil {
return nil, nil, err
}
modelMap := make(map[string]*Model, len(models))
vendorMap := make(map[string]*Vendor, len(vendors))
for _, item := range models {
modelMap[item.ModelName] = item
}
for _, item := range vendors {
vendorMap[item.Name] = item
}
return modelMap, vendorMap, nil
}
func ValidateMetadataValues(values MetadataValues) error {
if values.NameRule < NameRuleExact || values.NameRule > NameRuleSuffix {
return errors.New("invalid metadata matching rule")
}
if values.Status != 0 && values.Status != 1 {
return errors.New("invalid catalog visibility")
}
return ValidateModelEndpoints(values.Endpoints)
}
// ValidateModelEndpoints accepts the existing map form (custom paths) and
// type-array form (declared protocols), but never arbitrary JSON scalars.
func ValidateModelEndpoints(raw string) error {
if strings.TrimSpace(raw) == "" {
return nil
}
var value any
if err := common.UnmarshalJsonStr(raw, &value); err != nil {
return fmt.Errorf("invalid endpoints: %w", err)
}
switch endpoints := value.(type) {
case []any:
for _, endpoint := range endpoints {
if text, ok := endpoint.(string); !ok || strings.TrimSpace(text) == "" {
return errors.New("endpoint types must be non-empty strings")
}
}
case map[string]any:
for key, endpoint := range endpoints {
if strings.TrimSpace(key) == "" {
return errors.New("endpoint type is required")
}
switch details := endpoint.(type) {
case string:
if !strings.HasPrefix(details, "/") {
return errors.New("endpoint paths must start with /")
}
case map[string]any:
path, _ := details["path"].(string)
if !strings.HasPrefix(path, "/") {
return errors.New("endpoint paths must start with /")
}
if method, exists := details["method"]; exists {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS":
default:
return errors.New("invalid endpoint HTTP method")
}
}
default:
return errors.New("endpoint configuration must be a path or object")
}
}
default:
return errors.New("endpoints must be a JSON object or array")
}
return nil
}
func ApplyMetadataSync(updates []MetadataSyncUpdate, upstreamVendors map[string]Vendor) (*MetadataSyncResult, error) {
if len(updates) == 0 {
return nil, errors.New("select metadata changes before applying")
}
seen := make(map[string]bool)
for _, update := range updates {
if seen[update.ModelName] || strings.TrimSpace(update.ModelName) == "" {
return nil, errors.New("invalid or duplicate model selection")
}
seen[update.ModelName] = true
if update.RecordVersion == "" {
return nil, ErrMetadataSyncConflict
}
if err := ValidateMetadataValues(update.Values); err != nil {
return nil, err
}
if !update.Create && len(update.Fields) == 0 {
return nil, errors.New("select fields to update")
}
allowed := make(map[string]bool)
for _, field := range MetadataSyncFields {
allowed[field] = true
}
for _, field := range update.Fields {
if !allowed[field] {
return nil, fmt.Errorf("unsupported metadata field: %s", field)
}
}
}
result := &MetadataSyncResult{CreatedModels: []string{}, UpdatedModels: []MetadataSyncSelection{}, CreatedVendors: []string{}}
err := metadataTransaction(func(tx *gorm.DB) error {
locals, vendors, err := GetMetadataSyncState(lockForUpdate(tx))
if err != nil {
return err
}
vendorByID := make(map[int]*Vendor)
for _, vendor := range vendors {
vendorByID[vendor.Id] = vendor
}
// Verify the entire selection before any write, including shared vendors.
for _, update := range updates {
local := locals[update.ModelName]
if local != nil && local.SyncOfficial == 0 {
return fmt.Errorf("metadata sync is disabled for %s", update.ModelName)
}
var localVendor *Vendor
if local != nil {
localVendor = vendorByID[local.VendorID]
}
if MetadataRecordVersion(local, localVendor, FindMetadataVendor(vendors, update.Values.Vendor)) != update.RecordVersion {
return fmt.Errorf("%w: %s", ErrMetadataSyncConflict, update.ModelName)
}
if update.Create && local != nil || !update.Create && local == nil {
return ErrMetadataSyncConflict
}
}
for _, update := range updates {
fields := make(map[string]any)
selected := update.Fields
if update.Create {
selected = MetadataSyncFields
}
for _, field := range selected {
switch field {
case "description":
fields[field] = update.Values.Description
case "icon":
fields[field] = update.Values.Icon
case "tags":
fields[field] = update.Values.Tags
case "endpoints":
fields[field] = update.Values.Endpoints
case "name_rule":
fields[field] = update.Values.NameRule
case "status":
fields[field] = update.Values.Status
case "vendor":
vendorID := 0
name := update.Values.Vendor
if name != "" {
vendor := FindMetadataVendor(vendors, name)
if vendor == nil {
up, ok := upstreamVendors[name]
if !ok {
return fmt.Errorf("upstream vendor not found: %s", name)
}
vendor = &Vendor{Name: name, Description: up.Description, Icon: up.Icon, Status: 1, CreatedTime: common.GetTimestamp(), UpdatedTime: common.GetTimestamp()}
if err := validateVendorMetadata(tx, vendor); err != nil {
return err
}
if err := tx.Create(vendor).Error; err != nil {
return err
}
vendors[name] = vendor
result.CreatedVendors = append(result.CreatedVendors, name)
}
vendorID = vendor.Id
}
fields["vendor_id"] = vendorID
}
}
fields["updated_time"] = common.GetTimestamp()
if update.Create {
fields["model_name"] = update.ModelName
fields["sync_official"] = 1
fields["created_time"] = common.GetTimestamp()
if err := tx.Model(&Model{}).Create(fields).Error; err != nil {
return err
}
result.CreatedModels = append(result.CreatedModels, update.ModelName)
} else {
if err := tx.Model(&Model{}).Where("id = ?", locals[update.ModelName].Id).Updates(fields).Error; err != nil {
return err
}
result.UpdatedModels = append(result.UpdatedModels, update.MetadataSyncSelection)
}
}
return nil
})
if err != nil {
return nil, err
}
RefreshPricing()
return result, nil
}