1392 lines
49 KiB
Go
1392 lines
49 KiB
Go
package admin
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/database/gdb"
|
||
"github.com/gogf/gf/v2/errors/gcode"
|
||
"github.com/gogf/gf/v2/errors/gerror"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gtime"
|
||
"github.com/shopspring/decimal"
|
||
|
||
"github.com/qianfree/team-api/api/admin/v1"
|
||
"github.com/qianfree/team-api/internal/consts"
|
||
"github.com/qianfree/team-api/internal/dao"
|
||
"github.com/qianfree/team-api/internal/logic/billing"
|
||
"github.com/qianfree/team-api/internal/logic/common"
|
||
"github.com/qianfree/team-api/internal/logic/relay"
|
||
do "github.com/qianfree/team-api/internal/model/do"
|
||
"github.com/qianfree/team-api/internal/utility/export"
|
||
)
|
||
|
||
// modelOptionsCache 模型下拉选项缓存(全量模型,单个条目)。
|
||
// 下拉接口是若干管理页每次打开都要调的,而模型表极少变动;
|
||
// 整表结果缓存为一个条目、按 status/category 在内存里过滤,写侧主动失效。
|
||
var modelOptionsCache = common.NewCache("model_options", 300*time.Second)
|
||
|
||
// modelOptionsCacheKey 模型下拉是全量结果,只有一个缓存条目。
|
||
const modelOptionsCacheKey = "all"
|
||
|
||
// InvalidateModelOptionsCache 失效模型下拉选项缓存(模型增 / 删 / 改后调用)。
|
||
// 注:task 包的模型下线定时任务改 status 时不经此处,下拉最多滞后一个 TTL(300s);
|
||
// 对「按状态筛模型」的下拉而言可接受,故不为此在 task 包反向依赖 admin。
|
||
func InvalidateModelOptionsCache(ctx context.Context) {
|
||
modelOptionsCache.Delete(ctx, modelOptionsCacheKey)
|
||
}
|
||
|
||
// modelOptionRow 下拉缓存行:比出参多一个 status,供内存过滤。
|
||
// 缓存存全量(不过滤),避免 status / category 的每个组合各占一个缓存条目。
|
||
type modelOptionRow struct {
|
||
ID int64 `json:"id"`
|
||
ModelId string `json:"model_id"`
|
||
ModelName string `json:"model_name"`
|
||
Category string `json:"category"`
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
// ListModelOptions 获取模型选项列表(不分页,用于下拉选择)
|
||
func (s *sAdmin) ListModelOptions(ctx context.Context, req *v1.ModelOptionsReq) (*v1.ModelOptionsRes, error) {
|
||
rows := make([]modelOptionRow, 0)
|
||
if !modelOptionsCache.GetJSON(ctx, modelOptionsCacheKey, &rows) {
|
||
err := dao.MdlModels.Ctx(ctx).
|
||
Fields("id, model_id, model_name, category, status").
|
||
OrderAsc("category").OrderAsc("model_id").
|
||
Scan(&rows)
|
||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||
return nil, err
|
||
}
|
||
if rows == nil {
|
||
rows = make([]modelOptionRow, 0)
|
||
}
|
||
modelOptionsCache.Set(ctx, modelOptionsCacheKey, rows)
|
||
}
|
||
|
||
// 沿用原接口语义:不传 status 即只要可用模型
|
||
status := req.Status
|
||
if status == "" {
|
||
status = "active"
|
||
}
|
||
|
||
list := make([]v1.ModelOptionItem, 0, len(rows))
|
||
for _, r := range rows {
|
||
if r.Status != status {
|
||
continue
|
||
}
|
||
if req.Category != "" && r.Category != req.Category {
|
||
continue
|
||
}
|
||
list = append(list, v1.ModelOptionItem{
|
||
ID: r.ID,
|
||
ModelId: r.ModelId,
|
||
ModelName: r.ModelName,
|
||
Category: r.Category,
|
||
})
|
||
}
|
||
|
||
return &v1.ModelOptionsRes{List: list}, nil
|
||
}
|
||
|
||
// ListModels 获取模型列表(含定价摘要)
|
||
func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.ModelListRes, error) {
|
||
query := dao.MdlModels.Ctx(ctx)
|
||
|
||
if req.Category != "" {
|
||
query = query.Where("category", req.Category)
|
||
}
|
||
if req.Vendor != "" {
|
||
query = query.Where("vendor", req.Vendor)
|
||
}
|
||
if req.Status != "" {
|
||
query = query.Where("status", req.Status)
|
||
}
|
||
if req.Search != "" {
|
||
query = query.Where("model_id LIKE ? OR model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
|
||
}
|
||
// 定价状态筛选(pricing JSONB 唯一真相:任一模式有正价即已定价)
|
||
// 已配价谓词:token 价>0 / per_request 价>0 / per_second 矩阵非空 / tiered 任一档价>0
|
||
const pricedPredicate = `EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND (
|
||
(pricing->>'input_price')::numeric > 0
|
||
OR (pricing->>'output_price')::numeric > 0
|
||
OR (pricing->>'price')::numeric > 0
|
||
OR (pricing->'prices' IS NOT NULL AND pricing->'prices' <> '{}'::jsonb)
|
||
OR EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(pricing->'tiers', '[]'::jsonb)) tx WHERE (tx->>'input_price')::numeric > 0 OR (tx->>'output_price')::numeric > 0)
|
||
))`
|
||
if req.PricingStatus == "priced" {
|
||
query = query.Where(pricedPredicate)
|
||
} else if req.PricingStatus == "unpriced" {
|
||
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id) AND NOT " + pricedPredicate)
|
||
}
|
||
|
||
var total int
|
||
var models []struct {
|
||
ID int64 `json:"id"`
|
||
ModelId string `json:"model_id"`
|
||
ModelName string `json:"model_name"`
|
||
Category string `json:"category"`
|
||
Vendor string `json:"vendor"`
|
||
Status string `json:"status"`
|
||
MaxContextTokens int `json:"max_context_tokens"`
|
||
MaxOutputTokens int `json:"max_output_tokens"`
|
||
Capabilities string `json:"capabilities"`
|
||
Description string `json:"description"`
|
||
Tags []string `json:"tags"`
|
||
CreatedAt *gtime.Time `json:"created_at"`
|
||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||
DeprecatedAt *gtime.Time `json:"deprecated_at"`
|
||
SunsetDate *gtime.Time `json:"sunset_date"`
|
||
ReplacementModel string `json:"replacement_model"`
|
||
}
|
||
|
||
err := query.Fields("id, model_id, model_name, category, vendor, status, max_context_tokens, max_output_tokens, capabilities, description, tags, created_at, updated_at, deprecated_at, sunset_date, replacement_model").
|
||
OrderDesc("id").
|
||
Page(req.Page, req.PageSize).
|
||
ScanAndCount(&models, &total, false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 批量查询定价摘要(min_tokens=0 的基准行)
|
||
modelIDs := make([]int64, 0, len(models))
|
||
for _, m := range models {
|
||
modelIDs = append(modelIDs, m.ID)
|
||
}
|
||
type pricingRow struct {
|
||
ModelId int64 `json:"model_id"`
|
||
BillingMode string `json:"billing_mode"`
|
||
Pricing string `json:"pricing"`
|
||
}
|
||
var pricingRows []pricingRow
|
||
if len(modelIDs) > 0 {
|
||
err = dao.MdlPricing.Ctx(ctx).
|
||
Fields("model_id, billing_mode, pricing").
|
||
Where("model_id IN (?)", modelIDs).
|
||
Scan(&pricingRows)
|
||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
pricingMap := make(map[int64]pricingRow, len(pricingRows))
|
||
for _, p := range pricingRows {
|
||
pricingMap[p.ModelId] = p
|
||
}
|
||
|
||
// 批量查询渠道支持情况
|
||
type channelAbilityRow struct {
|
||
ModelName string `json:"model_name"`
|
||
ChannelID int64 `json:"channel_id"`
|
||
ChannelName string `json:"channel_name"`
|
||
Type int `json:"type"`
|
||
}
|
||
modelIdStrs := make([]string, 0, len(models))
|
||
for _, m := range models {
|
||
modelIdStrs = append(modelIdStrs, m.ModelId)
|
||
}
|
||
var channelRows []channelAbilityRow
|
||
if len(modelIdStrs) > 0 {
|
||
err = dao.ChnAbilities.Ctx(ctx).
|
||
LeftJoin("chn_channels", "chn_channels.id = chn_abilities.channel_id").
|
||
Fields("chn_abilities.model_name, chn_abilities.channel_id, chn_channels.name AS channel_name, chn_channels.type").
|
||
Where("chn_abilities.model_name IN (?)", modelIdStrs).
|
||
Where("chn_abilities.enabled", true).
|
||
Where("chn_channels.status", "active").
|
||
Scan(&channelRows)
|
||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
channelMap := make(map[string][]v1.ModelChannelInfo, len(channelRows))
|
||
for _, r := range channelRows {
|
||
channelMap[r.ModelName] = append(channelMap[r.ModelName], v1.ModelChannelInfo{
|
||
ChannelID: r.ChannelID,
|
||
ChannelName: r.ChannelName,
|
||
Type: r.Type,
|
||
})
|
||
}
|
||
|
||
list := make([]v1.ModelItem, 0, len(models))
|
||
for _, m := range models {
|
||
item := v1.ModelItem{
|
||
ID: m.ID,
|
||
ModelId: m.ModelId,
|
||
ModelName: m.ModelName,
|
||
Category: m.Category,
|
||
Vendor: m.Vendor,
|
||
Status: m.Status,
|
||
MaxContext: m.MaxContextTokens,
|
||
MaxOutput: m.MaxOutputTokens,
|
||
Capabilities: parseCapabilities(m.Capabilities),
|
||
Description: m.Description,
|
||
Tags: m.Tags,
|
||
CreatedAt: m.CreatedAt.String(),
|
||
UpdatedAt: m.UpdatedAt.String(),
|
||
}
|
||
if m.DeprecatedAt != nil {
|
||
s := m.DeprecatedAt.String()
|
||
item.DeprecatedAt = &s
|
||
}
|
||
if m.SunsetDate != nil {
|
||
s := m.SunsetDate.Format("Y-m-d")
|
||
item.SunsetDate = &s
|
||
}
|
||
item.ReplacementModel = m.ReplacementModel
|
||
// 填充定价摘要(从 pricing JSONB 解析;tiered 取首档价,与旧列表口径一致)
|
||
if p, ok := pricingMap[m.ID]; ok {
|
||
item.PricingMode = p.BillingMode
|
||
if blob := billing.ParsePricingBlob(p.Pricing); blob != nil {
|
||
switch {
|
||
case len(blob.Tiers) > 0:
|
||
item.InputPrice = blob.Tiers[0].InputPrice
|
||
item.OutputPrice = blob.Tiers[0].OutputPrice
|
||
default:
|
||
if blob.InputPrice != nil {
|
||
item.InputPrice = *blob.InputPrice
|
||
}
|
||
if blob.OutputPrice != nil {
|
||
item.OutputPrice = *blob.OutputPrice
|
||
}
|
||
if blob.Price != nil {
|
||
item.PerRequestPrice = *blob.Price
|
||
}
|
||
if len(blob.Prices) > 0 {
|
||
item.PerSecondPrices = blob.Prices
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 填充可用渠道
|
||
if chs, ok := channelMap[m.ModelId]; ok {
|
||
item.Channels = chs
|
||
}
|
||
list = append(list, item)
|
||
}
|
||
|
||
return &v1.ModelListRes{
|
||
List: list,
|
||
Total: total,
|
||
Page: req.Page,
|
||
PageSize: req.PageSize,
|
||
}, nil
|
||
}
|
||
|
||
// CreateModel 创建模型(自动创建默认 token 定价记录)
|
||
func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.ModelCreateRes, error) {
|
||
count, err := dao.MdlModels.Ctx(ctx).Where(do.MdlModels{ModelId: req.ModelId}).Count()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if count > 0 {
|
||
return nil, gerror.NewCode(gcode.New(consts.CodeModelNameExists, consts.MsgModelNameExists, nil), consts.MsgModelNameExists)
|
||
}
|
||
|
||
insertData := do.MdlModels{
|
||
ModelId: req.ModelId,
|
||
ModelName: req.ModelName,
|
||
Category: req.Category,
|
||
Vendor: req.Vendor, // 空串=未分类
|
||
Status: "active",
|
||
MaxContextTokens: req.MaxContext,
|
||
MaxOutputTokens: req.MaxOutput,
|
||
Description: req.Description,
|
||
}
|
||
if req.Tags != nil {
|
||
insertData.Tags = req.Tags
|
||
} else {
|
||
insertData.Tags = []string{}
|
||
}
|
||
if req.Capabilities != nil {
|
||
capJson, _ := json.Marshal(req.Capabilities)
|
||
insertData.Capabilities = string(capJson)
|
||
}
|
||
|
||
id, err := dao.MdlModels.Ctx(ctx).InsertAndGetId(insertData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 默认 token 模式空定价行(未配价,SetModelPricing 时覆盖 pricing JSONB)
|
||
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||
ModelId: id,
|
||
BillingMode: "token",
|
||
Pricing: "{}",
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
InvalidateModelOptionsCache(ctx)
|
||
|
||
return &v1.ModelCreateRes{ID: id}, nil
|
||
}
|
||
|
||
// validModelVendors 模型研发厂商合法值集合(与 mdl_models.vendor 枚举、API v:"in:" 标签、前端字典对齐)。
|
||
// 注意与 chn_channels.type 的"渠道供应商"是两个维度:研发厂商指模型的开发公司,
|
||
// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。
|
||
var validModelVendors = map[string]struct{}{
|
||
"openai": {}, "anthropic": {}, "google": {}, "xai": {}, "mistral": {}, "cohere": {},
|
||
"meta": {}, "alibaba": {}, "bytedance": {}, "deepseek": {}, "zhipu": {}, "moonshot": {},
|
||
"minimax": {}, "baidu": {}, "tencent": {}, "xunfei": {}, "kuaishou": {}, "midjourney": {},
|
||
"suno": {},
|
||
}
|
||
|
||
// isValidModelVendor 校验研发厂商枚举值
|
||
func isValidModelVendor(v string) bool {
|
||
_, ok := validModelVendors[v]
|
||
return ok
|
||
}
|
||
|
||
// UpdateModel 更新模型(含弃用状态管理)
|
||
func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error) {
|
||
var oldModel *struct {
|
||
ModelId string `json:"model_id"`
|
||
Status string `json:"status"`
|
||
}
|
||
err := dao.MdlModels.Ctx(ctx).Where("id", req.ID).Fields("model_id, status").Scan(&oldModel)
|
||
if err != nil || oldModel == nil {
|
||
return nil, common.NewBusinessError(404, "模型不存在")
|
||
}
|
||
|
||
data := do.MdlModels{}
|
||
hasUpdate := false
|
||
var capUpdate *string // capabilities 需要单独处理
|
||
|
||
if req.ModelName != "" {
|
||
data.ModelName = req.ModelName
|
||
hasUpdate = true
|
||
}
|
||
if req.Category != "" {
|
||
data.Category = req.Category
|
||
hasUpdate = true
|
||
}
|
||
// 厂商指针语义:nil=不更新;空串=清空回未分类(do 字段为 any,空串不会被 ORM 忽略);非空=校验枚举后设置
|
||
if req.Vendor != nil {
|
||
if *req.Vendor != "" && !isValidModelVendor(*req.Vendor) {
|
||
return nil, common.NewBusinessError(422, "厂商值不合法")
|
||
}
|
||
data.Vendor = *req.Vendor
|
||
hasUpdate = true
|
||
}
|
||
data.MaxContextTokens = req.MaxContext
|
||
data.MaxOutputTokens = req.MaxOutput
|
||
hasUpdate = true
|
||
if req.Capabilities != nil {
|
||
capJson, _ := json.Marshal(req.Capabilities)
|
||
s := string(capJson)
|
||
capUpdate = &s
|
||
hasUpdate = true
|
||
}
|
||
if req.Description != "" {
|
||
data.Description = req.Description
|
||
hasUpdate = true
|
||
}
|
||
if req.Tags != nil {
|
||
data.Tags = req.Tags
|
||
hasUpdate = true
|
||
}
|
||
|
||
statusChanged := req.Status != "" && req.Status != oldModel.Status
|
||
|
||
if req.Status == "deprecated" {
|
||
data.Status = "deprecated"
|
||
data.DeprecatedAt = gtime.Now()
|
||
hasUpdate = true
|
||
if req.SunsetDate != nil && *req.SunsetDate != "" {
|
||
data.SunsetDate = gtime.NewFromStr(*req.SunsetDate)
|
||
hasUpdate = true
|
||
}
|
||
if req.ReplacementModel != "" {
|
||
data.ReplacementModel = req.ReplacementModel
|
||
hasUpdate = true
|
||
}
|
||
} else if req.Status == "active" {
|
||
data.Status = "active"
|
||
hasUpdate = true
|
||
} else if req.Status == "offline" {
|
||
data.Status = "offline"
|
||
hasUpdate = true
|
||
} else if req.Status != "" {
|
||
data.Status = req.Status
|
||
hasUpdate = true
|
||
}
|
||
|
||
if !hasUpdate {
|
||
return nil, nil
|
||
}
|
||
|
||
if capUpdate != nil {
|
||
data.Capabilities = *capUpdate
|
||
}
|
||
|
||
_, err = dao.MdlModels.Ctx(ctx).Where("id", req.ID).Data(data).Update()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 下拉缓存按全量存、内存过滤 status/category,故改名 / 改分类同样要失效,
|
||
// 不能只在 statusChanged 时清。
|
||
InvalidateModelOptionsCache(ctx)
|
||
|
||
if statusChanged {
|
||
// 状态变更时清除模型缓存和租户分组缓存
|
||
relay.NewDataProvider().InvalidateModelCache(oldModel.ModelId)
|
||
invalidateTenantsForModel(ctx, req.ID)
|
||
|
||
// 状态从 active → deprecated 时额外发送通知
|
||
if req.Status == "deprecated" && oldModel.Status == "active" {
|
||
go func() {
|
||
bgCtx := context.Background()
|
||
variables := g.Map{
|
||
"model_name": oldModel.ModelId,
|
||
"sunset_date": "",
|
||
"replacement_model": req.ReplacementModel,
|
||
}
|
||
if req.SunsetDate != nil {
|
||
variables["sunset_date"] = *req.SunsetDate
|
||
}
|
||
engine := common.NewNotificationEngine()
|
||
if err := engine.SendToAllTenants(bgCtx, "model_deprecated", variables, ""); err != nil {
|
||
g.Log().Errorf(bgCtx, "[ModelDeprecation] send notification for %s failed: %v", oldModel.ModelId, err)
|
||
}
|
||
}()
|
||
}
|
||
}
|
||
|
||
return nil, nil
|
||
}
|
||
|
||
// DeleteModel 删除模型(同时删除定价记录、租户分配记录和分组关联)
|
||
func (s *sAdmin) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.ModelDeleteRes, error) {
|
||
// 先查出受影响的分组,供事务提交后清理缓存(缓存失效不可回滚,故放在事务外)
|
||
var affectedGroups []struct {
|
||
GroupId int64 `json:"group_id"`
|
||
}
|
||
dao.MdlGroupModels.Ctx(ctx).Where("model_id", req.ID).Fields("group_id").Scan(&affectedGroups)
|
||
|
||
// 查出模型编码,供事务提交后按模型清除所有租户的价格缓存
|
||
var model struct {
|
||
ModelId string `json:"model_id"`
|
||
}
|
||
if err := dao.MdlModels.Ctx(ctx).Where("id", req.ID).Fields("model_id").Scan(&model); err != nil {
|
||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
// 四表删除(定价 / 租户分配 / 分组关联 / 模型本体)放入同一事务,
|
||
// 避免中途失败留下「模型已删但定价/分配记录残留」的孤儿数据。
|
||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||
if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ID).Delete(); err != nil {
|
||
return gerror.Wrapf(err, "delete pricing tiers for model %d", req.ID)
|
||
}
|
||
if _, err := dao.MdlTenantModels.Ctx(ctx).Where("model_id", req.ID).Delete(); err != nil {
|
||
return gerror.Wrapf(err, "delete tenant models for model %d", req.ID)
|
||
}
|
||
if _, err := dao.MdlGroupModels.Ctx(ctx).Where("model_id", req.ID).Delete(); err != nil {
|
||
return gerror.Wrapf(err, "delete group models for model %d", req.ID)
|
||
}
|
||
if _, err := dao.MdlModels.Ctx(ctx).Where("id", req.ID).Delete(); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 事务提交后再清除受影响租户的缓存
|
||
for _, ag := range affectedGroups {
|
||
invalidateTenantsInGroup(ctx, ag.GroupId)
|
||
}
|
||
|
||
// 清除该模型在所有租户下的价格缓存,避免删除后旧价格残留最多 600s
|
||
if model.ModelId != "" {
|
||
billing.ClearModelPriceCache(ctx, model.ModelId)
|
||
}
|
||
InvalidateModelOptionsCache(ctx)
|
||
|
||
return nil, nil
|
||
}
|
||
|
||
func parseCapabilities(raw string) map[string]bool {
|
||
if raw == "" || raw == "{}" {
|
||
return nil
|
||
}
|
||
var caps map[string]bool
|
||
if err := json.Unmarshal([]byte(raw), &caps); err != nil {
|
||
return nil
|
||
}
|
||
return caps
|
||
}
|
||
|
||
// GetModelPricing 获取模型定价(pricing JSONB 单行还原为 PricingItem 列表,响应结构与旧多行结构保持兼容)
|
||
func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error) {
|
||
var row *struct {
|
||
BillingMode string `json:"billing_mode" orm:"billing_mode"`
|
||
Pricing string `json:"pricing" orm:"pricing"`
|
||
OfficialPricing string `json:"official_pricing" orm:"official_pricing"`
|
||
PriceNote *string `json:"price_note" orm:"price_note"`
|
||
DiscountLabel *string `json:"discount_label" orm:"discount_label"`
|
||
PriceChangeNote *string `json:"price_change_note" orm:"price_change_note"`
|
||
}
|
||
|
||
err := dao.MdlPricing.Ctx(ctx).
|
||
Where("model_id", req.ModelID).
|
||
Scan(&row)
|
||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var result []v1.PricingItem
|
||
var timeSegments []v1.TimeSegmentItem
|
||
var paramRules []v1.ParamMultiplierItem
|
||
var officialItems []v1.PricingItem
|
||
var officialTimeSegments []v1.TimeSegmentItem
|
||
var officialParamRules []v1.ParamMultiplierItem
|
||
var priceNote, discountLabel, priceChangeNote string
|
||
var scheme string
|
||
var schemeConfig json.RawMessage
|
||
if row != nil {
|
||
// 主定价:pricing JSONB → items + 时段 + 倍率(与官方参考定价共用展开逻辑)
|
||
blob := billing.ParsePricingBlob(row.Pricing)
|
||
result, timeSegments, paramRules = expandPricingBlob(row.BillingMode, blob)
|
||
if blob != nil {
|
||
scheme = blob.Scheme
|
||
schemeConfig = blob.SchemeConfig
|
||
}
|
||
|
||
// 官方参考定价:official_pricing JSONB 同构展开;未配置(NULL/解析失败)时三个字段保持 nil
|
||
offMode, offBlob := officialBlobFromJSON(row.OfficialPricing)
|
||
if offBlob != nil {
|
||
officialItems, officialTimeSegments, officialParamRules = expandPricingBlob(offMode, offBlob)
|
||
}
|
||
|
||
priceNote = derefStr(row.PriceNote)
|
||
discountLabel = derefStr(row.DiscountLabel)
|
||
priceChangeNote = derefStr(row.PriceChangeNote)
|
||
}
|
||
return &v1.PricingGetRes{
|
||
List: result,
|
||
TimeSegments: timeSegments,
|
||
ParamMultipliers: paramRules,
|
||
OfficialItems: officialItems,
|
||
OfficialTimeSegments: officialTimeSegments,
|
||
OfficialParamMultipliers: officialParamRules,
|
||
PriceNote: priceNote,
|
||
DiscountLabel: discountLabel,
|
||
PriceChangeNote: priceChangeNote,
|
||
Scheme: scheme,
|
||
SchemeConfig: schemeConfig,
|
||
}, nil
|
||
}
|
||
|
||
// expandPricingBlob pricing JSONB 内存形态 → API 响应结构(items 行 + 时段 + 参数倍率)。
|
||
// tiered 的 tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。blob 为 nil 时
|
||
// 仍返回单行空 item(mode 随入参),保持「无定价」的既有响应形态。主定价与官方参考定价共用。
|
||
func expandPricingBlob(mode string, blob *billing.PricingBlob) ([]v1.PricingItem, []v1.TimeSegmentItem, []v1.ParamMultiplierItem) {
|
||
if blob == nil {
|
||
blob = &billing.PricingBlob{}
|
||
}
|
||
items := make([]v1.PricingItem, 0, 1)
|
||
switch {
|
||
case len(blob.Tiers) > 0:
|
||
// tiered:tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。
|
||
// 逐档缓存价随行还原;首档无档内值时回退 blob 顶层锚点缓存价(存量数据兼容)
|
||
for i, tier := range blob.Tiers {
|
||
item := v1.PricingItem{
|
||
BillingMode: mode,
|
||
MinTokens: tier.MinTokens,
|
||
MaxTokens: tier.MaxTokens,
|
||
InputPrice: tier.InputPrice,
|
||
OutputPrice: tier.OutputPrice,
|
||
}
|
||
if tier.CacheReadPrice != nil {
|
||
item.CacheReadPrice = *tier.CacheReadPrice
|
||
} else if i == 0 && blob.CacheReadPrice != nil {
|
||
item.CacheReadPrice = *blob.CacheReadPrice
|
||
}
|
||
if tier.CacheCreationPrice != nil {
|
||
item.CacheCreationPrice = *tier.CacheCreationPrice
|
||
} else if i == 0 && blob.CacheCreationPrice != nil {
|
||
item.CacheCreationPrice = *blob.CacheCreationPrice
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
default:
|
||
item := v1.PricingItem{BillingMode: mode}
|
||
if blob.InputPrice != nil {
|
||
item.InputPrice = *blob.InputPrice
|
||
}
|
||
if blob.OutputPrice != nil {
|
||
item.OutputPrice = *blob.OutputPrice
|
||
}
|
||
if blob.CacheReadPrice != nil {
|
||
item.CacheReadPrice = *blob.CacheReadPrice
|
||
}
|
||
if blob.CacheCreationPrice != nil {
|
||
item.CacheCreationPrice = *blob.CacheCreationPrice
|
||
}
|
||
if blob.Price != nil {
|
||
p := *blob.Price
|
||
item.PerRequestPrice = &p
|
||
}
|
||
if len(blob.Prices) > 0 {
|
||
item.PerSecondPrices = blob.Prices
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
// 时段与倍率横切所有计费模式(含 tiered),随 blob 顶层还原
|
||
return items, timeSegmentsToAPI(blob.TimeSegments), paramMultipliersToAPI(blob.ParamMultipliers)
|
||
}
|
||
|
||
// derefStr 字符串指针安全解引用(NULL → 空串)
|
||
func derefStr(s *string) string {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
return *s
|
||
}
|
||
|
||
// timeSegmentsToAPI billing.TimeSegment → API 时段项
|
||
func timeSegmentsToAPI(segments []billing.TimeSegment) []v1.TimeSegmentItem {
|
||
if len(segments) == 0 {
|
||
return nil
|
||
}
|
||
result := make([]v1.TimeSegmentItem, 0, len(segments))
|
||
for _, seg := range segments {
|
||
result = append(result, v1.TimeSegmentItem{
|
||
Name: seg.Name,
|
||
Days: seg.Days,
|
||
StartTime: seg.StartTime,
|
||
EndTime: seg.EndTime,
|
||
ValidFrom: seg.ValidFrom,
|
||
ValidTo: seg.ValidTo,
|
||
Multiplier: seg.Multiplier,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
// timeSegmentsFromAPI API 时段项 → billing.TimeSegment(含校验)
|
||
func timeSegmentsFromAPI(items []v1.TimeSegmentItem) ([]billing.TimeSegment, error) {
|
||
if len(items) == 0 {
|
||
return nil, nil
|
||
}
|
||
result := make([]billing.TimeSegment, 0, len(items))
|
||
for _, item := range items {
|
||
result = append(result, billing.TimeSegment{
|
||
Name: item.Name,
|
||
Days: item.Days,
|
||
StartTime: item.StartTime,
|
||
EndTime: item.EndTime,
|
||
ValidFrom: item.ValidFrom,
|
||
ValidTo: item.ValidTo,
|
||
Multiplier: item.Multiplier,
|
||
})
|
||
}
|
||
if err := billing.ValidateTimeSegments(result); err != nil {
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// paramMultipliersFromAPI API 参数倍率规则 → billing 规则(含校验)
|
||
func paramMultipliersFromAPI(items []v1.ParamMultiplierItem) ([]billing.ParamRule, error) {
|
||
if len(items) == 0 {
|
||
return nil, nil
|
||
}
|
||
rules := make([]billing.ParamRule, 0, len(items))
|
||
for _, item := range items {
|
||
rule := billing.ParamRule{
|
||
Multiplier: item.Multiplier,
|
||
Note: strings.TrimSpace(item.Note),
|
||
}
|
||
for _, c := range item.Conditions {
|
||
rule.Conditions = append(rule.Conditions, billing.ParamCondition{
|
||
Path: strings.TrimSpace(c.Path),
|
||
Match: c.Match,
|
||
Value: c.Value,
|
||
})
|
||
}
|
||
rules = append(rules, rule)
|
||
}
|
||
if err := billing.ValidateParamMultipliers(rules); err != nil {
|
||
return nil, err
|
||
}
|
||
return rules, nil
|
||
}
|
||
|
||
// paramMultipliersToAPI billing 规则 → API 参数倍率项
|
||
func paramMultipliersToAPI(rules []billing.ParamRule) []v1.ParamMultiplierItem {
|
||
if len(rules) == 0 {
|
||
return nil
|
||
}
|
||
items := make([]v1.ParamMultiplierItem, 0, len(rules))
|
||
for _, rule := range rules {
|
||
item := v1.ParamMultiplierItem{
|
||
Multiplier: rule.Multiplier,
|
||
Note: rule.Note,
|
||
}
|
||
for _, c := range rule.Conditions {
|
||
item.Conditions = append(item.Conditions, v1.ParamConditionItem{
|
||
Path: c.Path,
|
||
Match: c.Match,
|
||
Value: c.Value,
|
||
})
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
return items
|
||
}
|
||
|
||
// pricingItemsToInput API 定价项 → billing 构造输入
|
||
func pricingItemsToInput(items []v1.PricingItem) []billing.PricingItemInput {
|
||
result := make([]billing.PricingItemInput, 0, len(items))
|
||
for _, item := range items {
|
||
result = append(result, billing.PricingItemInput{
|
||
BillingMode: item.BillingMode,
|
||
MinTokens: item.MinTokens,
|
||
MaxTokens: item.MaxTokens,
|
||
InputPrice: item.InputPrice,
|
||
OutputPrice: item.OutputPrice,
|
||
PerRequestPrice: item.PerRequestPrice,
|
||
CacheReadPrice: item.CacheReadPrice,
|
||
CacheCreationPrice: item.CacheCreationPrice,
|
||
PerSecondPrices: item.PerSecondPrices,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
// writePricingForModel 全量替换模型定价:单行写 billing_mode + pricing JSONB(每模型一行,
|
||
// uk_mdl_pricing_model 唯一)。SetModelPricing 与模型导入共用;时段定价随后经
|
||
// writeTimeSegmentsForModel 的 jsonb_set 并入 JSON 顶层;参数倍率规则随 blob 顶层写入。
|
||
// scheme/schemeConfig 为计费方案声明与私有配置:导入路径透传导出文件中的方案字段
|
||
// (旧版导出文件无该字段=空串/nil,导入后为通用引擎定价);两条路径均已在调用前
|
||
// 完成 special ⇔ scheme 配对与 SchemeRegistered 校验。
|
||
// 调用方需保证事务 ctx 传播。
|
||
func writePricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, paramRules []billing.ParamRule, scheme string, schemeConfig json.RawMessage) error {
|
||
blob, err := billing.BuildPricingBlob(pricingItemsToInput(items))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
blob.ParamMultipliers = paramRules
|
||
blob.Scheme = scheme
|
||
blob.SchemeConfig = schemeConfig
|
||
blobJSON, err := json.Marshal(blob)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 全量替换语义改为 upsert:ON CONFLICT (model_id) 命中时只覆盖 billing_mode / pricing,
|
||
// official_pricing 与展示字段列未涉及时保留原值——模型导入共用本函数,不得误清人工
|
||
// 核对落库的官方定价;展示字段随后仍由调用方按「空=清除」全量回写,行为与删行重插等价。
|
||
// 列名直写而非 do 字段,与 gf gen dao 生成物解耦(同 writePricingDisplayFieldsForModel)。
|
||
if _, err := dao.MdlPricing.Ctx(ctx).
|
||
Data(g.Map{
|
||
"model_id": modelDBID,
|
||
"billing_mode": items[0].BillingMode,
|
||
"pricing": string(blobJSON),
|
||
}).
|
||
OnConflict("model_id").
|
||
OnDuplicate(g.Map{
|
||
"billing_mode": gdb.Raw("excluded.billing_mode"),
|
||
"pricing": gdb.Raw("excluded.pricing"),
|
||
}).
|
||
Save(); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// officialPricingBlob official_pricing JSONB 的内存形态:billing_mode 键 + PricingBlob 字段平铺
|
||
// (encoding/json 对嵌入结构体平铺序列化),与计费定价 pricing JSONB 完全同构,
|
||
// 仅多 billing_mode 键——官方定价无独立模式列,模式随 JSONB 存储。
|
||
type officialPricingBlob struct {
|
||
BillingMode string `json:"billing_mode"`
|
||
billing.PricingBlob
|
||
}
|
||
|
||
// buildOfficialBlob 官方定价项 + 时段/倍率 → PricingBlob(与计费定价同构,复用同一构建管线)。
|
||
// 全空(价格维度 + tiers + 时段 + 倍率均无)返回 nil,调用方按清除处理(写 NULL)。
|
||
// 官方价是折扣换算的参照基准而非计费依据,是可选配置:per_second 空/全零矩阵属「未配置」
|
||
// 形态,不走计费定价的矩阵强校验(否则未配置形态会报错,连带阻断主定价保存),
|
||
// 直接落成空 blob 交给下方全空判断;其余模式沿用 BuildPricingBlob 结构校验与时段/倍率 FromAPI 校验。
|
||
func buildOfficialBlob(items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) (*billing.PricingBlob, error) {
|
||
segs, err := timeSegmentsFromAPI(segments)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
paramRules, err := paramMultipliersFromAPI(rules)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
input := pricingItemsToInput(items)
|
||
var blob *billing.PricingBlob
|
||
if len(input) > 0 && (input[0].BillingMode == "per_second" || input[0].BillingMode == billing.BillingModeSpecial) && !perSecondMatrixHasPositivePrice(input[0].PerSecondPrices) {
|
||
blob = &billing.PricingBlob{}
|
||
} else {
|
||
blob, err = billing.BuildPricingBlob(input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
blob.TimeSegments = segs
|
||
blob.ParamMultipliers = paramRules
|
||
empty := blob.InputPrice == nil && blob.OutputPrice == nil && blob.CacheReadPrice == nil &&
|
||
blob.CacheCreationPrice == nil && len(blob.Tiers) == 0 && blob.Price == nil &&
|
||
len(blob.Prices) == 0 && len(blob.TimeSegments) == 0 && len(blob.ParamMultipliers) == 0
|
||
if empty {
|
||
return nil, nil
|
||
}
|
||
return blob, nil
|
||
}
|
||
|
||
// perSecondMatrixHasPositivePrice 按秒矩阵是否存在任一正价(空矩阵/全零价=未配置形态)
|
||
func perSecondMatrixHasPositivePrice(prices map[string]float64) bool {
|
||
for _, p := range prices {
|
||
if p > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// officialBlobFromJSON 解析 official_pricing JSONB → (billing_mode, PricingBlob)。
|
||
// 空串/null/解析失败返回 ("", nil),调用方按未配置处理(参照字段,解析失败不阻断定价编辑)。
|
||
func officialBlobFromJSON(raw string) (string, *billing.PricingBlob) {
|
||
if raw == "" || raw == "null" {
|
||
return "", nil
|
||
}
|
||
wrapped := officialPricingBlob{}
|
||
if err := json.Unmarshal([]byte(raw), &wrapped); err != nil {
|
||
return "", nil
|
||
}
|
||
mode := wrapped.BillingMode
|
||
if mode == "" {
|
||
mode = "token"
|
||
}
|
||
return mode, &wrapped.PricingBlob
|
||
}
|
||
|
||
// writeOfficialPricingForModel 官方参考定价写入模型定价行(official_pricing 列整体替换,
|
||
// 全空=清除写 NULL)。与 pricing 列同构:BuildPricingBlob 构建 + 时段/倍率并入 blob 顶层,
|
||
// 支持全部四种计费模式。列名直写而非 do 字段,与 gf gen dao 生成物解耦
|
||
// (同 writePricingDisplayFieldsForModel)。需在定价行存在之后执行。调用方需保证事务 ctx 传播。
|
||
func writeOfficialPricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) error {
|
||
blob, err := buildOfficialBlob(items, segments, rules)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
data := any(gdb.Raw("NULL"))
|
||
if blob != nil {
|
||
mode := "token"
|
||
if len(items) > 0 {
|
||
mode = items[0].BillingMode
|
||
}
|
||
raw, mErr := json.Marshal(officialPricingBlob{BillingMode: mode, PricingBlob: *blob})
|
||
if mErr != nil {
|
||
return mErr
|
||
}
|
||
data = string(raw)
|
||
}
|
||
_, err = dao.MdlPricing.Ctx(ctx).
|
||
Where("model_id", modelDBID).
|
||
Data(g.Map{"official_pricing": data}).
|
||
Update()
|
||
return err
|
||
}
|
||
|
||
// writeTimeSegmentsForModel 把时段定价并入模型定价行的 pricing JSONB 顶层(time_segments 键),
|
||
// 空列表=清除(全量替换语义,写 "[]" 而非删除键:nil 切片会序列化成 "null",语义不明确)。
|
||
// 定价保存与模型导入共用。jsonb_set 只更新目标键,不覆盖 JSON 内其他计费字段。
|
||
// 单引号需 SQL 转义(json.Marshal 不转义内容中的单引号)。调用方需保证事务 ctx 传播。
|
||
func writeTimeSegmentsForModel(ctx context.Context, modelDBID int64, items []v1.TimeSegmentItem) error {
|
||
segs, err := timeSegmentsFromAPI(items)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
segmentsJSON := "[]"
|
||
if len(segs) > 0 {
|
||
data, mErr := json.Marshal(segs)
|
||
if mErr != nil {
|
||
return mErr
|
||
}
|
||
segmentsJSON = string(data)
|
||
}
|
||
escapedJSON := strings.ReplaceAll(segmentsJSON, "'", "''")
|
||
_, err = dao.MdlPricing.Ctx(ctx).
|
||
Where("model_id", modelDBID).
|
||
Data(g.Map{
|
||
"pricing": gdb.Raw(fmt.Sprintf(
|
||
`jsonb_set(COALESCE(pricing, '{}'::jsonb), '{time_segments}', '%s'::jsonb)`, escapedJSON)),
|
||
}).
|
||
Update()
|
||
return err
|
||
}
|
||
|
||
// writePricingDisplayFieldsForModel 把展示字段(价格说明/折扣标签/价格调整说明)写到模型定价行。
|
||
// 全量替换语义:空串=清除(写 NULL)。与 writeTimeSegmentsForModel 同理用列名直写而非 do 字段,
|
||
// 与 gf gen dao 生成物解耦。调用方需保证事务 ctx 传播。
|
||
func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, priceNote, discountLabel, priceChangeNote string) error {
|
||
trimOrNull := func(s string) any {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return s
|
||
}
|
||
_, err := dao.MdlPricing.Ctx(ctx).
|
||
Where("model_id", modelDBID).
|
||
Data(g.Map{
|
||
"price_note": trimOrNull(priceNote),
|
||
"discount_label": trimOrNull(discountLabel),
|
||
"price_change_note": trimOrNull(priceChangeNote),
|
||
}).
|
||
Update()
|
||
return err
|
||
}
|
||
|
||
// SetModelPricing 设置模型定价(全量替换)
|
||
func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error) {
|
||
// 时段定价校验(乘数范围/时间格式/星期/日期边界),fail-fast 返回用户可读错误
|
||
if _, err := timeSegmentsFromAPI(req.TimeSegments); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 参数倍率规则校验(路径/匹配方式/值形态/倍率区间),fail-fast
|
||
paramRules, err := paramMultipliersFromAPI(req.ParamMultipliers)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 官方参考定价的时段/倍率预校验(与主定价同一套校验;OfficialItems nil=本次不动官方定价)
|
||
if req.OfficialItems != nil {
|
||
if _, err := timeSegmentsFromAPI(req.OfficialTimeSegments); err != nil {
|
||
return nil, gerror.Wrap(err, "官方参考定价")
|
||
}
|
||
if _, err := paramMultipliersFromAPI(req.OfficialParamMultipliers); err != nil {
|
||
return nil, gerror.Wrap(err, "官方参考定价")
|
||
}
|
||
}
|
||
|
||
// 计费方案校验:未注册的方案名不得写入(运行期引擎无从得知正确价格);
|
||
// scheme_config 由方案实现自校验(generic 不接受非空配置,拦截「空方案带配置」的错位数据)
|
||
req.Scheme = strings.TrimSpace(req.Scheme)
|
||
if req.Scheme != "" && !billing.SchemeRegistered(req.Scheme) {
|
||
return nil, gerror.Newf("计费方案 %q 未注册", req.Scheme)
|
||
}
|
||
if err := billing.LookupScheme(req.Scheme).ValidateSchemeConfig(req.SchemeConfig); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// billing_mode 与 scheme 双向配对(special ⇔ 方案声明):裸 special 会落 generic
|
||
// 分发走 default 分支(错价口径不可预期),方案挂非 special 模式会造成展示标签
|
||
// 与计费口径错位(如显示「按秒」实为素材组合计费),两者都在保存时拦截
|
||
if len(req.Items) > 0 {
|
||
isSpecial := req.Items[0].BillingMode == billing.BillingModeSpecial
|
||
if isSpecial && req.Scheme == "" {
|
||
return nil, gerror.New("特殊计费模式(special)必须声明计费方案(scheme)")
|
||
}
|
||
if !isSpecial && req.Scheme != "" {
|
||
return nil, gerror.New("声明计费方案的模型必须使用特殊计费模式(billing_mode=special)")
|
||
}
|
||
}
|
||
|
||
// 先查模型编码,供事务提交后按模型清除所有租户的价格缓存
|
||
var model struct {
|
||
ModelId string `json:"model_id"`
|
||
}
|
||
if err := dao.MdlModels.Ctx(ctx).Where("id", req.ModelID).Fields("model_id").Scan(&model); err != nil {
|
||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
// 全量替换:单行写 pricing JSONB(计费详情 + 参数倍率随 blob 顶层),同一事务。
|
||
// 否则中途失败会残留「无定价」状态,导致计费失败或回退到默认价。
|
||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||
if err := writePricingForModel(ctx, req.ModelID, req.Items, paramRules, req.Scheme, req.SchemeConfig); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 官方参考定价(OfficialItems nil=本次不动库内值;三个官方数组全空=清除)。
|
||
// 与 pricing 列同构,支持全部四种计费模式 + 时段/倍率。官方价非计费依据,
|
||
// 不触发价格缓存失效
|
||
if req.OfficialItems != nil {
|
||
if err := writeOfficialPricingForModel(ctx, req.ModelID, req.OfficialItems, req.OfficialTimeSegments, req.OfficialParamMultipliers); err != nil {
|
||
return gerror.Wrap(err, "官方参考定价")
|
||
}
|
||
}
|
||
|
||
// 时段定价并入 JSON 顶层(空=清除,全量替换语义;需在定价行插入之后执行)
|
||
if err := writeTimeSegmentsForModel(ctx, req.ModelID, req.TimeSegments); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 展示字段写定价行(空=清除,全量替换语义;需在定价行插入之后执行)
|
||
if err := writePricingDisplayFieldsForModel(ctx, req.ModelID, req.PriceNote, req.DiscountLabel, req.PriceChangeNote); err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 事务提交后再清价格缓存:模型基础定价变更影响所有租户,按模型编码全量失效,
|
||
// 否则各租户缓存的旧价格(如新建时的零价)最多残留 600s,出现「已设价仍报未配置定价」。
|
||
if model.ModelId != "" {
|
||
billing.ClearModelPriceCache(ctx, model.ModelId)
|
||
}
|
||
|
||
return nil, nil
|
||
}
|
||
|
||
// FetchOfficialPricing 拉取模型官方定价(来自 LiteLLM + models.dev 双数据源)
|
||
func (s *sAdmin) FetchOfficialPricing(ctx context.Context, req *v1.PricingFetchOfficialReq) (*v1.PricingFetchOfficialRes, error) {
|
||
var model *struct {
|
||
ModelId string `json:"model_id"`
|
||
}
|
||
err := dao.MdlModels.Ctx(ctx).Where("id", req.ModelID).Fields("model_id").Scan(&model)
|
||
if err != nil || model == nil || model.ModelId == "" {
|
||
return nil, common.NewBusinessError(404, "模型不存在")
|
||
}
|
||
|
||
sources := make([]*v1.OfficialPricingSource, 0, 4)
|
||
|
||
// Source 1: LiteLLM
|
||
litellmSource := fetchLiteLLMSource(ctx, model.ModelId)
|
||
sources = append(sources, litellmSource)
|
||
|
||
// Source 2: models.dev
|
||
modelsDevSource := fetchModelsDevSource(ctx, model.ModelId)
|
||
sources = append(sources, modelsDevSource)
|
||
|
||
// Source 3: BaseLLM
|
||
baseLLMSource := fetchBaseLLMSource(ctx, model.ModelId)
|
||
sources = append(sources, baseLLMSource)
|
||
|
||
// Source 4: OpenRouter
|
||
openRouterSource := fetchOpenRouterSource(ctx, model.ModelId)
|
||
sources = append(sources, openRouterSource)
|
||
|
||
return &v1.PricingFetchOfficialRes{
|
||
ModelName: model.ModelId,
|
||
Sources: sources,
|
||
}, nil
|
||
}
|
||
|
||
func fetchLiteLLMSource(ctx context.Context, modelName string) *v1.OfficialPricingSource {
|
||
source := &v1.OfficialPricingSource{Source: "litellm"}
|
||
|
||
data, err := common.FetchLiteLLMPricing(ctx)
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "[FetchOfficialPricing] litellm fetch failed for %s: %v", modelName, err)
|
||
source.Error = err.Error()
|
||
return source
|
||
}
|
||
|
||
matchedName, entry := common.FindLiteLLMModel(data, modelName)
|
||
if entry == nil {
|
||
return source
|
||
}
|
||
|
||
// 修复定价单位转换:用 decimal 避免裸浮点乘法,将每 token 价格转换为每百万 token 价格
|
||
million := decimal.NewFromInt(1_000_000)
|
||
inputPrice := billing.RoundMoney(decimal.NewFromFloat(entry.InputCostPerToken).Mul(million))
|
||
outputPrice := billing.RoundMoney(decimal.NewFromFloat(entry.OutputCostPerToken).Mul(million))
|
||
cacheReadPrice := billing.RoundMoney(decimal.NewFromFloat(entry.CacheReadInputTokenCost).Mul(million))
|
||
cacheCreationPrice := billing.RoundMoney(decimal.NewFromFloat(entry.CacheCreationInputTokenCost).Mul(million))
|
||
|
||
billingMode := "token"
|
||
if entry.Mode == "image_generation" && entry.OutputCostPerImage > 0 && inputPrice.IsZero() && outputPrice.IsZero() {
|
||
billingMode = "per_request"
|
||
}
|
||
|
||
source.Found = true
|
||
source.Provider = entry.LitellmProvider
|
||
source.Mode = entry.Mode
|
||
source.MaxContext = entry.MaxInputTokens
|
||
source.MaxOutput = entry.MaxOutputTokens
|
||
source.Pricing = &v1.OfficialPricingItem{
|
||
InputPrice: billing.InexactFloat64(inputPrice),
|
||
OutputPrice: billing.InexactFloat64(outputPrice),
|
||
CacheReadPrice: billing.InexactFloat64(cacheReadPrice),
|
||
CacheCreationPrice: billing.InexactFloat64(cacheCreationPrice),
|
||
BillingMode: billingMode,
|
||
}
|
||
|
||
g.Log().Debugf(ctx, "[FetchOfficialPricing] litellm matched %s -> %s", modelName, matchedName)
|
||
return source
|
||
}
|
||
|
||
func fetchModelsDevSource(ctx context.Context, modelName string) *v1.OfficialPricingSource {
|
||
source := &v1.OfficialPricingSource{Source: "models.dev"}
|
||
|
||
data, err := common.FetchModelsDevPricing(ctx)
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "[FetchOfficialPricing] models.dev fetch failed for %s: %v", modelName, err)
|
||
source.Error = err.Error()
|
||
return source
|
||
}
|
||
|
||
matchedName, entry := common.FindModelsDevModel(data, modelName)
|
||
if entry == nil {
|
||
return source
|
||
}
|
||
|
||
cacheReadPrice := 0.0
|
||
if entry.CacheRead != nil {
|
||
cacheReadPrice = *entry.CacheRead
|
||
}
|
||
|
||
source.Found = true
|
||
source.Provider = entry.Provider
|
||
source.Pricing = &v1.OfficialPricingItem{
|
||
InputPrice: roundTo2(entry.Input),
|
||
OutputPrice: roundTo2(func() float64 {
|
||
if entry.Output != nil {
|
||
return *entry.Output
|
||
}
|
||
return 0
|
||
}()),
|
||
CacheReadPrice: roundTo2(cacheReadPrice),
|
||
BillingMode: "token",
|
||
}
|
||
|
||
g.Log().Debugf(ctx, "[FetchOfficialPricing] models.dev matched %s -> %s", modelName, matchedName)
|
||
return source
|
||
}
|
||
|
||
func fetchBaseLLMSource(ctx context.Context, modelName string) *v1.OfficialPricingSource {
|
||
source := &v1.OfficialPricingSource{Source: "basellm"}
|
||
|
||
data, err := common.FetchBaseLLMPricing(ctx)
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "[FetchOfficialPricing] basellm fetch failed for %s: %v", modelName, err)
|
||
source.Error = err.Error()
|
||
return source
|
||
}
|
||
|
||
matchedName, entry := common.FindBaseLLMModel(data, modelName)
|
||
if entry == nil {
|
||
return source
|
||
}
|
||
|
||
cacheReadPrice := 0.0
|
||
if entry.PricePerMCacheRead != nil {
|
||
cacheReadPrice = *entry.PricePerMCacheRead
|
||
}
|
||
cacheWritePrice := 0.0
|
||
if entry.PricePerMCacheWrite != nil {
|
||
cacheWritePrice = *entry.PricePerMCacheWrite
|
||
}
|
||
|
||
source.Found = true
|
||
source.Provider = entry.VendorName
|
||
source.MaxContext = common.ParseBaseLLMContext(entry.Tags)
|
||
source.Pricing = &v1.OfficialPricingItem{
|
||
InputPrice: roundTo2(entry.PricePerMInput),
|
||
OutputPrice: roundTo2(entry.PricePerMOutput),
|
||
CacheReadPrice: roundTo2(cacheReadPrice),
|
||
CacheCreationPrice: roundTo2(cacheWritePrice),
|
||
BillingMode: "token",
|
||
}
|
||
|
||
g.Log().Debugf(ctx, "[FetchOfficialPricing] basellm matched %s -> %s", modelName, matchedName)
|
||
return source
|
||
}
|
||
|
||
func fetchOpenRouterSource(ctx context.Context, modelName string) *v1.OfficialPricingSource {
|
||
source := &v1.OfficialPricingSource{Source: "openrouter"}
|
||
|
||
data, err := common.FetchOpenRouterModels(ctx)
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "[FetchOfficialPricing] openrouter fetch failed for %s: %v", modelName, err)
|
||
source.Error = err.Error()
|
||
return source
|
||
}
|
||
|
||
matchedName, entry := common.FindOpenRouterModel(data, modelName)
|
||
if entry == nil || entry.Pricing == nil {
|
||
return source
|
||
}
|
||
|
||
inputPrice := common.OpenRouterPricePerM(entry.Pricing.Prompt)
|
||
outputPrice := common.OpenRouterPricePerM(entry.Pricing.Completion)
|
||
cacheReadPrice := common.OpenRouterPricePerM(entry.Pricing.InputCacheRead)
|
||
cacheWritePrice := common.OpenRouterPricePerM(entry.Pricing.InputCacheWrite)
|
||
|
||
source.Found = true
|
||
if idx := strings.Index(entry.ID, "/"); idx >= 0 {
|
||
source.Provider = entry.ID[:idx]
|
||
}
|
||
source.MaxContext = entry.ContextLength
|
||
if entry.TopProvider != nil && entry.TopProvider.MaxCompletionTokens > 0 {
|
||
source.MaxOutput = entry.TopProvider.MaxCompletionTokens
|
||
}
|
||
source.Pricing = &v1.OfficialPricingItem{
|
||
InputPrice: roundTo2(inputPrice),
|
||
OutputPrice: roundTo2(outputPrice),
|
||
CacheReadPrice: roundTo2(cacheReadPrice),
|
||
CacheCreationPrice: roundTo2(cacheWritePrice),
|
||
BillingMode: "token",
|
||
}
|
||
|
||
g.Log().Debugf(ctx, "[FetchOfficialPricing] openrouter matched %s -> %s", modelName, matchedName)
|
||
return source
|
||
}
|
||
|
||
func roundTo2(v float64) float64 {
|
||
return math.Round(v*100) / 100
|
||
}
|
||
|
||
// FetchOfficialModelInfo 按模型名称拉取官方模型信息(上下文长度+能力特性)
|
||
// 按优先级尝试 LiteLLM → OpenRouter → BaseLLM,返回第一个匹配的结果
|
||
func (s *sAdmin) FetchOfficialModelInfo(ctx context.Context, req *v1.ModelFetchOfficialInfoReq) (*v1.ModelFetchOfficialInfoRes, error) {
|
||
var lastError string
|
||
|
||
// Source 1: LiteLLM
|
||
if data, err := common.FetchLiteLLMPricing(ctx); err != nil {
|
||
g.Log().Warningf(ctx, "[FetchOfficialModelInfo] litellm fetch failed for %s: %v", req.ModelName, err)
|
||
lastError = err.Error()
|
||
} else if _, entry := common.FindLiteLLMModel(data, req.ModelName); entry != nil {
|
||
return &v1.ModelFetchOfficialInfoRes{
|
||
Found: true,
|
||
Provider: entry.LitellmProvider,
|
||
MaxContextTokens: entry.MaxInputTokens,
|
||
MaxOutputTokens: entry.MaxOutputTokens,
|
||
Capabilities: map[string]bool{
|
||
"vision": entry.SupportsVision,
|
||
"function_calling": entry.SupportsFunctionCalling,
|
||
"parallel_function_calling": entry.SupportsParallelFuncCalling,
|
||
"tool_choice": entry.SupportsToolChoice,
|
||
"response_schema": entry.SupportsResponseSchema,
|
||
"system_messages": entry.SupportsSystemMessages,
|
||
"prompt_caching": entry.SupportsPromptCaching,
|
||
"audio_input": entry.SupportsAudioInput,
|
||
"audio_output": entry.SupportsAudioOutput,
|
||
"pdf_input": entry.SupportsPdfInput,
|
||
"embedding_image": entry.SupportsEmbeddingImage,
|
||
"reasoning": entry.SupportsReasoning,
|
||
"web_search": entry.SupportsWebSearch,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
// Source 2: OpenRouter
|
||
if data, err := common.FetchOpenRouterModels(ctx); err != nil {
|
||
g.Log().Warningf(ctx, "[FetchOfficialModelInfo] openrouter fetch failed for %s: %v", req.ModelName, err)
|
||
lastError = err.Error()
|
||
} else if _, entry := common.FindOpenRouterModel(data, req.ModelName); entry != nil {
|
||
maxOutput := 0
|
||
if entry.TopProvider != nil {
|
||
maxOutput = entry.TopProvider.MaxCompletionTokens
|
||
}
|
||
provider := ""
|
||
if idx := strings.Index(entry.ID, "/"); idx >= 0 {
|
||
provider = entry.ID[:idx]
|
||
}
|
||
return &v1.ModelFetchOfficialInfoRes{
|
||
Found: true,
|
||
Provider: provider,
|
||
MaxContextTokens: entry.ContextLength,
|
||
MaxOutputTokens: maxOutput,
|
||
Capabilities: common.ParseOpenRouterCapabilities(entry),
|
||
}, nil
|
||
}
|
||
|
||
// Source 3: BaseLLM
|
||
if data, err := common.FetchBaseLLMPricing(ctx); err != nil {
|
||
g.Log().Warningf(ctx, "[FetchOfficialModelInfo] basellm fetch failed for %s: %v", req.ModelName, err)
|
||
lastError = err.Error()
|
||
} else if _, entry := common.FindBaseLLMModel(data, req.ModelName); entry != nil {
|
||
return &v1.ModelFetchOfficialInfoRes{
|
||
Found: true,
|
||
Provider: entry.VendorName,
|
||
MaxContextTokens: common.ParseBaseLLMContext(entry.Tags),
|
||
Capabilities: common.ParseBaseLLMCapabilities(entry.Tags),
|
||
}, nil
|
||
}
|
||
|
||
res := &v1.ModelFetchOfficialInfoRes{Found: false}
|
||
if lastError != "" {
|
||
res.Error = lastError
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// ExportModels exports model list to CSV or Excel.
|
||
func (s *sAdmin) ExportModels(ctx context.Context, req *v1.ModelExportReq) (*v1.ModelExportRes, error) {
|
||
modelFields := "id, model_id, model_name, category, status, max_context_tokens, max_output_tokens, created_at"
|
||
|
||
config := export.Config{
|
||
Format: req.Format,
|
||
Filename: "模型_" + gtime.Now().Format("Ymd_His"),
|
||
Columns: []export.Column{
|
||
{Field: "id", Header: "ID"},
|
||
{Field: "model_id", Header: "模型标识"},
|
||
{Field: "model_name", Header: "显示名称"},
|
||
{Field: "category", Header: "分类"},
|
||
{Field: "status", Header: "状态"},
|
||
{Field: "max_context_tokens", Header: "最大上下文"},
|
||
{Field: "max_output_tokens", Header: "最大输出"},
|
||
{Field: "created_at", Header: "创建时间"},
|
||
},
|
||
}
|
||
|
||
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
|
||
offset := 0
|
||
for {
|
||
query := dao.MdlModels.Ctx(ctx)
|
||
if req.Category != "" {
|
||
query = query.Where("category", req.Category)
|
||
}
|
||
if req.Status != "" {
|
||
query = query.Where("status", req.Status)
|
||
}
|
||
if req.Search != "" {
|
||
query = query.Where("model_id LIKE ? OR model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
|
||
}
|
||
var batch []struct {
|
||
ID int64 `json:"id"`
|
||
ModelId string `json:"model_id"`
|
||
ModelName string `json:"model_name"`
|
||
Category string `json:"category"`
|
||
Status string `json:"status"`
|
||
MaxContextTokens int `json:"max_context_tokens"`
|
||
MaxOutputTokens int `json:"max_output_tokens"`
|
||
CreatedAt *gtime.Time `json:"created_at"`
|
||
}
|
||
if err := query.Fields(modelFields).OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
|
||
g.Log().Errorf(ctx, "ExportModels: query batch at offset %d failed: %v", offset, err)
|
||
return
|
||
}
|
||
for _, m := range batch {
|
||
if !yield(map[string]any{
|
||
"id": m.ID,
|
||
"model_id": m.ModelId,
|
||
"model_name": m.ModelName,
|
||
"category": m.Category,
|
||
"status": m.Status,
|
||
"max_context_tokens": m.MaxContextTokens,
|
||
"max_output_tokens": m.MaxOutputTokens,
|
||
"created_at": m.CreatedAt.String(),
|
||
}) {
|
||
return
|
||
}
|
||
}
|
||
if len(batch) < 1000 {
|
||
break
|
||
}
|
||
offset += 1000
|
||
}
|
||
})
|
||
}
|