mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-09 01:47:07 +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.
305 lines
8.7 KiB
Go
305 lines
8.7 KiB
Go
package controller
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/model"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// GetAllModelsMeta 获取模型列表(分页)
|
|
func GetAllModelsMeta(c *gin.Context) {
|
|
|
|
pageInfo := common.GetPageQuery(c)
|
|
status := c.Query("status")
|
|
syncOfficial := c.Query("sync_official")
|
|
modelsMeta, total, err := model.SearchModels("", "", status, syncOfficial, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
// 批量填充附加字段,提升列表接口性能
|
|
enrichModels(modelsMeta)
|
|
|
|
// 统计供应商计数(全部数据,不受分页影响)
|
|
vendorCounts, _ := model.GetVendorModelCounts()
|
|
|
|
pageInfo.SetTotal(int(total))
|
|
pageInfo.SetItems(modelsMeta)
|
|
common.ApiSuccess(c, gin.H{
|
|
"items": modelsMeta,
|
|
"total": total,
|
|
"page": pageInfo.GetPage(),
|
|
"page_size": pageInfo.GetPageSize(),
|
|
"vendor_counts": vendorCounts,
|
|
})
|
|
}
|
|
|
|
// SearchModelsMeta 搜索模型列表
|
|
func SearchModelsMeta(c *gin.Context) {
|
|
|
|
keyword := c.Query("keyword")
|
|
vendor := c.Query("vendor")
|
|
status := c.Query("status")
|
|
syncOfficial := c.Query("sync_official")
|
|
pageInfo := common.GetPageQuery(c)
|
|
|
|
modelsMeta, total, err := model.SearchModels(keyword, vendor, status, syncOfficial, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
// 批量填充附加字段,提升列表接口性能
|
|
enrichModels(modelsMeta)
|
|
vendorCounts, _ := model.GetVendorModelCounts()
|
|
pageInfo.SetTotal(int(total))
|
|
pageInfo.SetItems(modelsMeta)
|
|
common.ApiSuccess(c, gin.H{
|
|
"items": modelsMeta,
|
|
"total": total,
|
|
"page": pageInfo.GetPage(),
|
|
"page_size": pageInfo.GetPageSize(),
|
|
"vendor_counts": vendorCounts,
|
|
})
|
|
}
|
|
|
|
// GetModelMeta 根据 ID 获取单条模型信息
|
|
func GetModelMeta(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.Atoi(idStr)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
var m model.Model
|
|
if err := model.DB.First(&m, id).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
enrichModels([]*model.Model{&m})
|
|
common.ApiSuccess(c, &m)
|
|
}
|
|
|
|
// CreateModelMeta 新建模型
|
|
func CreateModelMeta(c *gin.Context) {
|
|
var m model.Model
|
|
if err := c.ShouldBindJSON(&m); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if m.ModelName == "" {
|
|
common.ApiErrorMsg(c, "模型名称不能为空")
|
|
return
|
|
}
|
|
if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
// 名称冲突检查
|
|
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
} else if dup {
|
|
common.ApiErrorMsg(c, "模型名称已存在")
|
|
return
|
|
}
|
|
|
|
if err := m.Insert(); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
model.RefreshPricing()
|
|
common.ApiSuccess(c, &m)
|
|
}
|
|
|
|
// UpdateModelMeta 更新模型
|
|
func UpdateModelMeta(c *gin.Context) {
|
|
statusOnly := c.Query("status_only") == "true"
|
|
|
|
var m model.Model
|
|
if err := c.ShouldBindJSON(&m); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if m.Id == 0 {
|
|
common.ApiErrorMsg(c, "缺少模型 ID")
|
|
return
|
|
}
|
|
|
|
if statusOnly {
|
|
if m.Status != 0 && m.Status != 1 {
|
|
common.ApiErrorMsg(c, "invalid catalog visibility")
|
|
return
|
|
}
|
|
// 只更新状态,防止误清空其他字段
|
|
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
} else {
|
|
if strings.TrimSpace(m.ModelName) == "" {
|
|
common.ApiErrorMsg(c, "模型名称不能为空")
|
|
return
|
|
}
|
|
if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
// 名称冲突检查
|
|
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
} else if dup {
|
|
common.ApiErrorMsg(c, "模型名称已存在")
|
|
return
|
|
}
|
|
|
|
if err := m.Update(); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
}
|
|
model.RefreshPricing()
|
|
common.ApiSuccess(c, &m)
|
|
}
|
|
|
|
// DeleteModelMeta 删除模型
|
|
func DeleteModelMeta(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.Atoi(idStr)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
removeFromChannels, err := strconv.ParseBool(c.DefaultQuery("remove_from_channels", "false"))
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
removePricing, err := strconv.ParseBool(c.DefaultQuery("remove_pricing", "false"))
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if removePricing && c.GetInt("role") != common.RoleRootUser {
|
|
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "Model pricing is managed by a super administrator."})
|
|
return
|
|
}
|
|
result, err := model.DeleteModelMetadata([]int{id}, removeFromChannels, removePricing)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
recordManageAudit(c, "model.delete", map[string]interface{}{"model_ids": []int{id}, "remove_from_channels": removeFromChannels, "remove_pricing": removePricing, "updated_channels": result.UpdatedChannels})
|
|
common.ApiSuccess(c, result)
|
|
}
|
|
|
|
func BatchDeleteModelMeta(c *gin.Context) {
|
|
var request struct {
|
|
ModelIDs []int `json:"model_ids"`
|
|
RemoveFromChannels bool `json:"remove_from_channels"`
|
|
RemovePricing bool `json:"remove_pricing"`
|
|
}
|
|
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if request.RemovePricing && c.GetInt("role") != common.RoleRootUser {
|
|
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "Model pricing is managed by a super administrator."})
|
|
return
|
|
}
|
|
result, err := model.DeleteModelMetadata(request.ModelIDs, request.RemoveFromChannels, request.RemovePricing)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
recordManageAudit(c, "model.delete_batch", map[string]interface{}{"model_ids": request.ModelIDs, "remove_from_channels": request.RemoveFromChannels, "remove_pricing": request.RemovePricing, "updated_channels": result.UpdatedChannels})
|
|
common.ApiSuccess(c, result)
|
|
}
|
|
|
|
// enrichModels keeps configured endpoints intact and derives connections from
|
|
// enabled routes, including hidden or unpriced models absent from the catalog.
|
|
func enrichModels(models []*model.Model) {
|
|
if len(models) == 0 {
|
|
return
|
|
}
|
|
connections, err := model.GetModelConnections()
|
|
if err != nil {
|
|
common.SysError("load model connections: " + err.Error())
|
|
return
|
|
}
|
|
for _, metadata := range models {
|
|
if metadata == nil {
|
|
continue
|
|
}
|
|
channels := make(map[int]model.BoundChannel)
|
|
groups := make(map[string]bool)
|
|
names := make(map[string]bool)
|
|
endpoints := make(map[string]bool)
|
|
quotas := make(map[int]bool)
|
|
for _, connection := range connections {
|
|
name := connection.Model
|
|
matched := name == metadata.ModelName
|
|
switch metadata.NameRule {
|
|
case model.NameRulePrefix:
|
|
matched = strings.HasPrefix(name, metadata.ModelName)
|
|
case model.NameRuleSuffix:
|
|
matched = strings.HasSuffix(name, metadata.ModelName)
|
|
case model.NameRuleContains:
|
|
matched = strings.Contains(name, metadata.ModelName)
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
names[name] = true
|
|
groups[connection.Group] = true
|
|
channels[connection.ChannelId] = model.BoundChannel{Name: connection.ChannelName, Type: connection.ChannelType}
|
|
for _, endpoint := range model.GetModelSupportEndpointTypes(name) {
|
|
endpoints[string(endpoint)] = true
|
|
}
|
|
for _, quota := range model.GetModelQuotaTypes(name) {
|
|
quotas[quota] = true
|
|
}
|
|
}
|
|
metadata.BoundChannels = nil
|
|
metadata.EnableGroups = nil
|
|
metadata.SupportedEndpoints = nil
|
|
metadata.QuotaTypes = nil
|
|
metadata.MatchedModels = nil
|
|
for _, channel := range channels {
|
|
metadata.BoundChannels = append(metadata.BoundChannels, channel)
|
|
}
|
|
sort.Slice(metadata.BoundChannels, func(i, j int) bool {
|
|
a, b := metadata.BoundChannels[i], metadata.BoundChannels[j]
|
|
if a.Name == b.Name {
|
|
return a.Type < b.Type
|
|
}
|
|
return a.Name < b.Name
|
|
})
|
|
for group := range groups {
|
|
metadata.EnableGroups = append(metadata.EnableGroups, group)
|
|
}
|
|
for endpoint := range endpoints {
|
|
metadata.SupportedEndpoints = append(metadata.SupportedEndpoints, endpoint)
|
|
}
|
|
for quota := range quotas {
|
|
metadata.QuotaTypes = append(metadata.QuotaTypes, quota)
|
|
}
|
|
sort.Strings(metadata.EnableGroups)
|
|
sort.Strings(metadata.SupportedEndpoints)
|
|
sort.Ints(metadata.QuotaTypes)
|
|
if metadata.NameRule != model.NameRuleExact {
|
|
for name := range names {
|
|
metadata.MatchedModels = append(metadata.MatchedModels, name)
|
|
}
|
|
sort.Strings(metadata.MatchedModels)
|
|
metadata.MatchedCount = len(names)
|
|
}
|
|
}
|
|
}
|