mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 17:36:59 +08:00
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.
456 lines
14 KiB
Go
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)
|
|
}
|