Files
new-api/controller/model_sync.go
T
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

456 lines
14 KiB
Go

package controller
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// 上游地址
const (
upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
)
func normalizeLocale(locale string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(locale)) {
case "", "zh", "zh-cn":
return "zh", true
case "en":
return "en", true
case "ja":
return "ja", true
default:
return "", false
}
}
func getUpstreamBase() string {
return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata")
}
func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) {
base := strings.TrimRight(getUpstreamBase(), "/")
if l, ok := normalizeLocale(locale); ok && l != "" {
return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l),
fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l)
}
return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base)
}
type upstreamEnvelope[T any] struct {
Success bool `json:"success"`
Message string `json:"message"`
Data []T `json:"data"`
}
type upstreamModel struct {
Description string `json:"description"`
Endpoints json.RawMessage `json:"endpoints"`
Icon string `json:"icon"`
ModelName string `json:"model_name"`
NameRule int `json:"name_rule"`
Status int `json:"status"`
Tags string `json:"tags"`
VendorName string `json:"vendor_name"`
}
type upstreamVendor struct {
Description string `json:"description"`
Icon string `json:"icon"`
Name string `json:"name"`
Status int `json:"status"`
}
var (
etagCache = make(map[string]string)
bodyCache = make(map[string][]byte)
cacheMutex sync.RWMutex
)
func newHTTPClient() *http.Client {
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10)
dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
}
if common.TLSInsecureSkipVerify {
transport.TLSClientConfig = common.InsecureTLSConfig
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if strings.HasSuffix(host, "github.io") {
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
return conn, nil
}
return dialer.DialContext(ctx, "tcp6", addr)
}
return dialer.DialContext(ctx, network, addr)
}
return &http.Client{Transport: transport}
}
var (
httpClientOnce sync.Once
httpClient *http.Client
)
func getHTTPClient() *http.Client {
httpClientOnce.Do(func() {
httpClient = newHTTPClient()
})
return httpClient
}
func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
var lastErr error
attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3)
if attempts < 1 {
attempts = 1
}
baseDelay := 200 * time.Millisecond
maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10)
maxBytes := int64(maxMB) << 20
for attempt := 0; attempt < attempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// ETag conditional request
cacheMutex.RLock()
if et := etagCache[url]; et != "" {
req.Header.Set("If-None-Match", et)
}
cacheMutex.RUnlock()
resp, err := getHTTPClient().Do(req)
if err != nil {
lastErr = err
// backoff with jitter
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
continue
}
func() {
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// read body into buffer for caching and flexible decode
limited := io.LimitReader(resp.Body, maxBytes+1)
buf, err := io.ReadAll(limited)
if err != nil {
lastErr = err
return
}
if int64(len(buf)) > maxBytes {
lastErr = errors.New("upstream metadata exceeds size limit")
return
}
// cache body and ETag
cacheMutex.Lock()
if et := resp.Header.Get("ETag"); et != "" {
etagCache[url] = et
}
bodyCache[url] = buf
cacheMutex.Unlock()
// Try decode as envelope first
if err := common.Unmarshal(buf, out); err != nil {
// Try decode as pure array
var arr []T
if err2 := common.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
case http.StatusNotModified:
// use cache
cacheMutex.RLock()
buf := bodyCache[url]
cacheMutex.RUnlock()
if len(buf) == 0 {
lastErr = errors.New("cache miss for 304 response")
return
}
if err := common.Unmarshal(buf, out); err != nil {
var arr []T
if err2 := common.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
default:
lastErr = errors.New(resp.Status)
}
}()
if lastErr == nil {
return nil
}
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
}
return lastErr
}
type metadataSyncSource struct {
Locale string `json:"locale"`
ModelsURL string `json:"models_url"`
VendorsURL string `json:"vendors_url"`
Version string `json:"version"`
}
type metadataSyncField struct {
Field string `json:"field"`
Local any `json:"local"`
Upstream any `json:"upstream"`
}
type metadataSyncCandidate struct {
ModelName string `json:"model_name"`
Kind string `json:"kind"`
Scope string `json:"scope"`
RecordVersion string `json:"record_version"`
Fields []metadataSyncField `json:"fields"`
Upstream *model.MetadataValues `json:"upstream,omitempty"`
VendorToCreate string `json:"vendor_to_create,omitempty"`
}
func fetchMetadataCatalog(c *gin.Context, locale string) (metadataSyncSource, map[string]model.MetadataValues, map[string]model.Vendor, error) {
resolved, valid := normalizeLocale(locale)
if !valid {
return metadataSyncSource{}, nil, nil, errors.New("unsupported metadata language")
}
modelsURL, vendorsURL := getUpstreamURLs(resolved)
source := metadataSyncSource{Locale: resolved, ModelsURL: modelsURL, VendorsURL: vendorsURL}
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15))*time.Second)
defer cancel()
var modelsEnv upstreamEnvelope[upstreamModel]
var vendorsEnv upstreamEnvelope[upstreamVendor]
var modelsErr, vendorsErr error
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); modelsErr = fetchJSON(ctx, modelsURL, &modelsEnv) }()
go func() { defer wg.Done(); vendorsErr = fetchJSON(ctx, vendorsURL, &vendorsEnv) }()
wg.Wait()
if modelsErr != nil {
return source, nil, nil, fmt.Errorf("fetch models (%s, %s): %w", resolved, modelsURL, modelsErr)
}
if vendorsErr != nil {
return source, nil, nil, fmt.Errorf("fetch vendors (%s, %s): %w", resolved, vendorsURL, vendorsErr)
}
if !modelsEnv.Success || !vendorsEnv.Success {
return source, nil, nil, errors.New("upstream metadata source reported failure")
}
models := make(map[string]model.MetadataValues)
vendors := make(map[string]model.Vendor)
for _, vendor := range vendorsEnv.Data {
vendor.Name = strings.TrimSpace(vendor.Name)
if vendor.Name == "" {
continue
}
vendors[vendor.Name] = model.Vendor{Name: vendor.Name, Description: vendor.Description, Icon: vendor.Icon, Status: vendor.Status}
}
for _, item := range modelsEnv.Data {
if strings.TrimSpace(item.ModelName) == "" {
continue
}
endpoints := ""
if len(item.Endpoints) > 0 && string(item.Endpoints) != "null" {
if err := common.Unmarshal(item.Endpoints, &endpoints); err != nil {
endpoints = string(item.Endpoints)
}
}
values := model.MetadataValues{Description: item.Description, Icon: item.Icon, Tags: item.Tags, Vendor: strings.TrimSpace(item.VendorName), Endpoints: endpoints, NameRule: item.NameRule, Status: item.Status}
if err := model.ValidateMetadataValues(values); err != nil {
return source, nil, nil, fmt.Errorf("model %s: %w", item.ModelName, err)
}
if _, duplicate := models[item.ModelName]; duplicate {
return source, nil, nil, fmt.Errorf("duplicate upstream model: %s", item.ModelName)
}
models[item.ModelName] = values
}
encoded, err := common.Marshal([]any{source.Locale, models, vendors})
if err != nil {
return source, nil, nil, err
}
source.Version = fmt.Sprintf("%x", sha256.Sum256(encoded))
return source, models, vendors, nil
}
func SyncUpstreamPreview(c *gin.Context) {
source, upstream, upstreamVendors, err := fetchMetadataCatalog(c, c.Query("locale"))
if err != nil {
common.ApiError(c, err)
return
}
locals, vendors, err := model.GetMetadataSyncState(model.DB)
if err != nil {
common.ApiError(c, err)
return
}
missing, err := model.GetMissingModels()
if err != nil {
common.ApiError(c, err)
return
}
siteNames := make(map[string]bool)
allNames := make(map[string]bool)
for name := range locals {
siteNames[name] = true
allNames[name] = true
}
for _, name := range missing {
siteNames[name] = true
allNames[name] = true
}
for name := range upstream {
allNames[name] = true
}
names := make([]string, 0, len(allNames))
for name := range allNames {
names = append(names, name)
}
sort.Strings(names)
vendorByID := make(map[int]*model.Vendor)
for _, vendor := range vendors {
vendorByID[vendor.Id] = vendor
}
candidates := make([]metadataSyncCandidate, 0, len(names))
for _, name := range names {
candidate := metadataSyncCandidate{ModelName: name, Scope: "catalog", Kind: "create", Fields: []metadataSyncField{}}
if siteNames[name] {
candidate.Scope = "site"
}
local := locals[name]
up, found := upstream[name]
if !found {
candidate.Kind = "missing_upstream"
candidates = append(candidates, candidate)
continue
}
candidate.Upstream = &up
var localVendor *model.Vendor
if local != nil {
localVendor = vendorByID[local.VendorID]
}
candidate.RecordVersion = model.MetadataRecordVersion(local, localVendor, model.FindMetadataVendor(vendors, up.Vendor))
if local != nil && local.SyncOfficial == 0 {
candidate.Kind = "blocked"
candidates = append(candidates, candidate)
continue
}
if up.Vendor != "" && model.FindMetadataVendor(vendors, up.Vendor) == nil {
if _, exists := upstreamVendors[up.Vendor]; !exists {
candidate.Kind = "missing_vendor"
candidates = append(candidates, candidate)
continue
}
candidate.VendorToCreate = up.Vendor
}
localValues := model.MetadataValues{}
if local != nil {
candidate.Kind = "update"
localValues = model.MetadataValues{Description: local.Description, Icon: local.Icon, Tags: local.Tags, Endpoints: local.Endpoints, NameRule: local.NameRule, Status: local.Status}
if localVendor != nil {
localValues.Vendor = localVendor.Name
}
}
localRaw, _ := common.Marshal(localValues)
upRaw, _ := common.Marshal(up)
var localFields, upFields map[string]any
_ = common.Unmarshal(localRaw, &localFields)
_ = common.Unmarshal(upRaw, &upFields)
for _, field := range model.MetadataSyncFields {
if local == nil || localFields[field] != upFields[field] {
candidate.Fields = append(candidate.Fields, metadataSyncField{Field: field, Local: localFields[field], Upstream: upFields[field]})
}
}
if local != nil && len(candidate.Fields) == 0 {
candidate.Kind = "unchanged"
}
candidates = append(candidates, candidate)
}
common.ApiSuccess(c, gin.H{"source": source, "candidates": candidates})
}
func SyncUpstreamModels(c *gin.Context) {
var request struct {
Locale string `json:"locale"`
SourceVersion string `json:"source_version"`
Selections []model.MetadataSyncSelection `json:"selections"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil || len(request.Selections) == 0 || request.SourceVersion == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Preview and select metadata changes before applying"})
return
}
source, upstream, vendors, err := fetchMetadataCatalog(c, request.Locale)
if err != nil {
common.ApiError(c, err)
return
}
if source.Version != request.SourceVersion {
c.JSON(http.StatusConflict, gin.H{"success": false, "message": "Upstream metadata changed; preview again"})
return
}
updates := make([]model.MetadataSyncUpdate, 0, len(request.Selections))
for _, selection := range request.Selections {
values, exists := upstream[selection.ModelName]
if !exists {
c.JSON(http.StatusConflict, gin.H{"success": false, "message": "Selected upstream model is no longer available"})
return
}
updates = append(updates, model.MetadataSyncUpdate{MetadataSyncSelection: selection, Values: values})
}
result, err := model.ApplyMetadataSync(updates, vendors)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, model.ErrMetadataSyncConflict) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"success": false, "message": err.Error()})
return
}
recordManageAudit(c, "model.metadata.sync", map[string]interface{}{"created_models": result.CreatedModels, "updated_models": result.UpdatedModels, "created_vendors": result.CreatedVendors})
common.ApiSuccess(c, result)
}