feat(model):租户模型定价和显示优化

This commit is contained in:
qian committed 2026-05-16 13:29:51 +08:00
1 parent f26bb74bcf
commit 37863bad2f
28 files changed
+1148 -314

No files matched your search

+54 -35
View File
@@ -13,21 +13,34 @@ type TenantModelListRes struct {
}
type TenantModelItem struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ModelID int64 `json:"model_id"`
ModelCode string `json:"model_code"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Enabled bool `json:"enabled"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
ChannelScope string `json:"channel_scope"`
CustomInputPrice *float64 `json:"custom_input_price"`
CustomOutputPrice *float64 `json:"custom_output_price"`
Multiplier float64 `json:"multiplier"`
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ModelID int64 `json:"model_id"`
ModelCode string `json:"model_code"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Enabled bool `json:"enabled"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
ChannelScope string `json:"channel_scope"`
CustomInputPrice *float64 `json:"custom_input_price"`
CustomOutputPrice *float64 `json:"custom_output_price"`
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"`
CustomPricingTiers []*PricingTier `json:"custom_pricing_tiers"`
Multiplier float64 `json:"multiplier"`
}
// PricingTier 阶梯定价项(租户自定义阶梯时使用)
type PricingTier struct {
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
}
// TenantModelBatchAssignReq 批量分配模型给租户
@@ -38,15 +51,18 @@ type TenantModelBatchAssignReq struct {
}
type ModelAssignment struct {
ModelID int64 `json:"model_id" v:"required" dc:"模型ID"`
Enabled bool `json:"enabled" d:"true" dc:"是否启用"`
BillingMode *string `json:"billing_mode" dc:"覆盖计费模式"`
PerRequestPrice *float64 `json:"per_request_price" dc:"按次单价"`
DiscountRatio *float64 `json:"discount_ratio" dc:"折扣比例(0-1)"`
MaxConcurrency *int `json:"max_concurrency" dc:"单模型并发上限"`
ChannelScope *string `json:"channel_scope" dc:"渠道范围JSON数组"`
CustomInputPrice *float64 `json:"custom_input_price" dc:"自定义输入价格"`
CustomOutputPrice *float64 `json:"custom_output_price" dc:"自定义输出价格"`
ModelID int64 `json:"model_id" v:"required" dc:"模型ID"`
Enabled bool `json:"enabled" d:"true" dc:"是否启用"`
BillingMode *string `json:"billing_mode" dc:"覆盖计费模式"`
PerRequestPrice *float64 `json:"per_request_price" dc:"按次单价"`
DiscountRatio *float64 `json:"discount_ratio" dc:"折扣比例(0-1)"`
MaxConcurrency *int `json:"max_concurrency" dc:"单模型并发上限"`
ChannelScope *string `json:"channel_scope" dc:"渠道范围JSON数组"`
CustomInputPrice *float64 `json:"custom_input_price" dc:"自定义输入价格"`
CustomOutputPrice *float64 `json:"custom_output_price" dc:"自定义输出价格"`
CustomCacheReadPrice *float64 `json:"custom_cache_read_price" dc:"自定义缓存读取价格"`
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price" dc:"自定义缓存创建价格"`
CustomPricingTiers []*PricingTier `json:"custom_pricing_tiers" dc:"自定义阶梯定价"`
}
type TenantModelBatchAssignRes struct {
@@ -55,17 +71,20 @@ type TenantModelBatchAssignRes struct {
// TenantModelUpdateReq 更新租户模型分配配置
type TenantModelUpdateReq struct {
g.Meta `path:"/tenants/{tenant_id}/models/{model_id}" method:"put" mime:"json" tags:"管理后台-租户模型" summary:"更新租户模型配置"`
TenantID int64 `json:"tenant_id" in:"path" v:"required" dc:"租户ID"`
ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"`
Enabled *bool `json:"enabled" dc:"是否启用"`
BillingMode *string `json:"billing_mode" v:"in:token,per_request" dc:"计费模式"`
PerRequestPrice **float64 `json:"per_request_price" dc:"按次单价"`
DiscountRatio **float64 `json:"discount_ratio" dc:"折扣比例"`
MaxConcurrency **int `json:"max_concurrency" dc:"单模型并发上限"`
ChannelScope **string `json:"channel_scope" dc:"渠道范围JSON数组"`
CustomInputPrice **float64 `json:"custom_input_price" dc:"自定义输入价格"`
CustomOutputPrice **float64 `json:"custom_output_price" dc:"自定义输出价格"`
g.Meta `path:"/tenants/{tenant_id}/models/{model_id}" method:"put" mime:"json" tags:"管理后台-租户模型" summary:"更新租户模型配置"`
TenantID int64 `json:"tenant_id" in:"path" v:"required" dc:"租户ID"`
ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"`
Enabled *bool `json:"enabled" dc:"是否启用"`
BillingMode *string `json:"billing_mode" v:"in:token,per_request,tiered" dc:"计费模式"`
PerRequestPrice **float64 `json:"per_request_price" dc:"按次单价"`
DiscountRatio **float64 `json:"discount_ratio" dc:"折扣比例"`
MaxConcurrency **int `json:"max_concurrency" dc:"单模型并发上限"`
ChannelScope **string `json:"channel_scope" dc:"渠道范围JSON数组"`
CustomInputPrice **float64 `json:"custom_input_price" dc:"自定义输入价格"`
CustomOutputPrice **float64 `json:"custom_output_price" dc:"自定义输出价格"`
CustomCacheReadPrice **float64 `json:"custom_cache_read_price" dc:"自定义缓存读取价格"`
CustomCacheCreationPrice **float64 `json:"custom_cache_creation_price" dc:"自定义缓存创建价格"`
CustomPricingTiers *[]*PricingTier `json:"custom_pricing_tiers" dc:"自定义阶梯定价"`
}
type TenantModelUpdateRes struct{}
+26 -13
View File
@@ -13,19 +13,32 @@ type TenantAvailableModelsRes struct {
List []TenantAvailableModelItem `json:"list"`
}
// PricingTierItem 阶梯定价项
type PricingTierItem struct {
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
}
// TenantAvailableModelItem 租户可用模型信息
type TenantAvailableModelItem struct {
ID int64 `json:"id"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
MaxContext int `json:"max_context_tokens"`
MaxOutput int `json:"max_output_tokens"`
Description string `json:"description"`
Tags string `json:"tags"`
Capabilities string `json:"capabilities"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
ID int64 `json:"id"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
MaxContext int `json:"max_context_tokens"`
MaxOutput int `json:"max_output_tokens"`
Description string `json:"description"`
Tags string `json:"tags"`
Capabilities string `json:"capabilities"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
CacheCreationPrice *float64 `json:"cache_creation_price"`
PricingTiers []PricingTierItem `json:"pricing_tiers"`
}
+2 -2
View File
@@ -29,9 +29,9 @@ type ChnChannelKeysColumns struct {
LastUsedAt string // 最后使用时间
LastError string // 最后一次错误信息
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
KeyType string // Key 类型:apikey(传统静态密钥)/ oauth(OAuth 令牌)
TokenExpiresAt string // OAuth access_token 过期时间(仅 key_type=oauth 时有值)
UpdatedAt string // 更新时间
}
// chnChannelKeysColumns holds the columns for the table chn_channel_keys.
@@ -44,9 +44,9 @@ var chnChannelKeysColumns = ChnChannelKeysColumns{
LastUsedAt: "last_used_at",
LastError: "last_error",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
KeyType: "key_type",
TokenExpiresAt: "token_expires_at",
UpdatedAt: "updated_at",
}
// NewChnChannelKeysDao creates and returns a new DAO object for table data access.
+2 -2
View File
@@ -30,12 +30,12 @@ type MdlModelsColumns struct {
MaxOutputTokens string // 最大输出 token 数
Description string // 模型描述
Tags string // 标签(如 reasoning、vision、function_calling)
Capabilities string // 模型能力特性(如 vision、function_calling、reasoning 等)
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
DeprecatedAt string // 标记弃用的时间(NULL表示未弃用)
SunsetDate string // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
ReplacementModel string // 推荐替代模型名(NULL表示无替代)
Capabilities string // 模型能力特性(如 vision、function_calling、reasoning 等)
}
// mdlModelsColumns holds the columns for the table mdl_models.
@@ -49,12 +49,12 @@ var mdlModelsColumns = MdlModelsColumns{
MaxOutputTokens: "max_output_tokens",
Description: "description",
Tags: "tags",
Capabilities: "capabilities",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
DeprecatedAt: "deprecated_at",
SunsetDate: "sunset_date",
ReplacementModel: "replacement_model",
Capabilities: "capabilities",
}
// NewMdlModelsDao creates and returns a new DAO object for table data access.
+34 -28
View File
@@ -21,38 +21,44 @@ type MdlTenantModelsDao struct {
// MdlTenantModelsColumns defines and stores column names for the table mdl_tenant_models.
type MdlTenantModelsColumns struct {
Id string // 主键ID
TenantId string // 租户ID
ModelId string // 模型ID
Enabled string // 是否启用(禁用后该租户无法调用此模型)
CustomInputPrice string // 租户自定义输入价格(NULL 表示使用默认定价)
CustomOutputPrice string // 租户自定义输出价格(NULL 表示使用默认定价)
Multiplier string // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
BillingMode string // 覆盖模型计费方式(NULL表示跟随模型默认)
PerRequestPrice string // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
DiscountRatio string // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
MaxConcurrency string // 单模型并发上限(NULL表示不限制)
ChannelScope string // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
Id string // 主键ID
TenantId string // 租户ID
ModelId string // 模型ID
Enabled string // 是否启用(禁用后该租户无法调用此模型)
CustomInputPrice string // 租户自定义输入价格(NULL 表示使用默认定价)
CustomOutputPrice string // 租户自定义输出价格(NULL 表示使用默认定价)
Multiplier string // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
BillingMode string // 覆盖模型计费方式(NULL表示跟随模型默认)
PerRequestPrice string // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
DiscountRatio string // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
MaxConcurrency string // 单模型并发上限(NULL表示不限制)
ChannelScope string // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
CustomCacheReadPrice string // 自定义缓存读取价格($/1M token),NULL 表示使用基础定价
CustomCacheCreationPrice string // 自定义缓存创建价格($/1M token),NULL 表示使用基础定价
CustomPricingTiers string // 自定义阶梯定价(JSONB 数组),格式: [{"min_tokens":0,"max_tokens":100000,"input_price":0.5,"output_price":1.5,"cache_read_price":0.1,"cache_creation_price":0.2}]
}
// mdlTenantModelsColumns holds the columns for the table mdl_tenant_models.
var mdlTenantModelsColumns = MdlTenantModelsColumns{
Id: "id",
TenantId: "tenant_id",
ModelId: "model_id",
Enabled: "enabled",
CustomInputPrice: "custom_input_price",
CustomOutputPrice: "custom_output_price",
Multiplier: "multiplier",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
BillingMode: "billing_mode",
PerRequestPrice: "per_request_price",
DiscountRatio: "discount_ratio",
MaxConcurrency: "max_concurrency",
ChannelScope: "channel_scope",
Id: "id",
TenantId: "tenant_id",
ModelId: "model_id",
Enabled: "enabled",
CustomInputPrice: "custom_input_price",
CustomOutputPrice: "custom_output_price",
Multiplier: "multiplier",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
BillingMode: "billing_mode",
PerRequestPrice: "per_request_price",
DiscountRatio: "discount_ratio",
MaxConcurrency: "max_concurrency",
ChannelScope: "channel_scope",
CustomCacheReadPrice: "custom_cache_read_price",
CustomCacheCreationPrice: "custom_cache_creation_price",
CustomPricingTiers: "custom_pricing_tiers",
}
// NewMdlTenantModelsDao creates and returns a new DAO object for table data access.
+2 -2
View File
@@ -31,8 +31,8 @@ type NtfMessagesColumns struct {
IsRead string // 是否已读:0=未读, 1=已读
IsBroadcast string // 是否广播消息:0=个人消息, 1=广播消息
Metadata string // 附加元数据(JSONB,如关联资源ID、跳转链接等)
CreatedAt string // 创建时间
TargetRoles string // 目标角色(NULL=全部角色,逗号分隔如 owner,admin 表示仅限这些角色)
CreatedAt string // 创建时间
}
// ntfMessagesColumns holds the columns for the table ntf_messages.
@@ -47,8 +47,8 @@ var ntfMessagesColumns = NtfMessagesColumns{
IsRead: "is_read",
IsBroadcast: "is_broadcast",
Metadata: "metadata",
CreatedAt: "created_at",
TargetRoles: "target_roles",
CreatedAt: "created_at",
}
// NewNtfMessagesDao creates and returns a new DAO object for table data access.
+97
View File
@@ -0,0 +1,97 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysPluginsDao is the data access object for the table sys_plugins.
type SysPluginsDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysPluginsColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysPluginsColumns defines and stores column names for the table sys_plugins.
type SysPluginsColumns struct {
Id string //
Name string // 插件唯一标识,如 email-report
Label string // 显示名称
Version string // 当前安装版本
Status string // 状态:registered=已注册, installed=已安装, enabled=已启用, disabled=已禁用, error=异常
Category string // 分类:relay=代理扩展, middleware=中间件, billing=计费, notification=通知, extension=通用扩展
Config string // 插件全局配置(JSON)
ErrorMsg string // 异常信息
CreatedAt string //
UpdatedAt string //
}
// sysPluginsColumns holds the columns for the table sys_plugins.
var sysPluginsColumns = SysPluginsColumns{
Id: "id",
Name: "name",
Label: "label",
Version: "version",
Status: "status",
Category: "category",
Config: "config",
ErrorMsg: "error_msg",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewSysPluginsDao creates and returns a new DAO object for table data access.
func NewSysPluginsDao(handlers ...gdb.ModelHandler) *SysPluginsDao {
return &SysPluginsDao{
group: "default",
table: "sys_plugins",
columns: sysPluginsColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysPluginsDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysPluginsDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysPluginsDao) Columns() SysPluginsColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysPluginsDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysPluginsDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysPluginsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,91 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TntTenantPluginsDao is the data access object for the table tnt_tenant_plugins.
type TntTenantPluginsDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TntTenantPluginsColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TntTenantPluginsColumns defines and stores column names for the table tnt_tenant_plugins.
type TntTenantPluginsColumns struct {
Id string //
TenantId string //
PluginName string // 插件标识
Enabled string // 是否启用
Config string // 租户级配置覆盖(JSON),优先级高于全局配置
CreatedAt string //
UpdatedAt string //
}
// tntTenantPluginsColumns holds the columns for the table tnt_tenant_plugins.
var tntTenantPluginsColumns = TntTenantPluginsColumns{
Id: "id",
TenantId: "tenant_id",
PluginName: "plugin_name",
Enabled: "enabled",
Config: "config",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewTntTenantPluginsDao creates and returns a new DAO object for table data access.
func NewTntTenantPluginsDao(handlers ...gdb.ModelHandler) *TntTenantPluginsDao {
return &TntTenantPluginsDao{
group: "default",
table: "tnt_tenant_plugins",
columns: tntTenantPluginsColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TntTenantPluginsDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TntTenantPluginsDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TntTenantPluginsDao) Columns() TntTenantPluginsColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TntTenantPluginsDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TntTenantPluginsDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TntTenantPluginsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"github.com/qianfree/team-api/internal/dao/internal"
)
// sysPluginsDao is the data access object for the table sys_plugins.
// You can define custom methods on it to extend its functionality as needed.
type sysPluginsDao struct {
*internal.SysPluginsDao
}
var (
// SysPlugins is a globally accessible object for table sys_plugins operations.
SysPlugins = sysPluginsDao{internal.NewSysPluginsDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"github.com/qianfree/team-api/internal/dao/internal"
)
// tntTenantPluginsDao is the data access object for the table tnt_tenant_plugins.
// You can define custom methods on it to extend its functionality as needed.
type tntTenantPluginsDao struct {
*internal.TntTenantPluginsDao
}
var (
// TntTenantPlugins is a globally accessible object for table tnt_tenant_plugins operations.
TntTenantPlugins = tntTenantPluginsDao{internal.NewTntTenantPluginsDao()}
)
// Add your custom methods and functionality below.
+81 -50
View File
@@ -2,38 +2,42 @@ package admin
import (
"context"
do "github.com/qianfree/team-api/internal/model/do"
"encoding/json"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/common"
do "github.com/qianfree/team-api/internal/model/do"
)
// ListTenantModels 列出租户已分配的模型
func (s *sAdmin) ListTenantModels(ctx context.Context, req *v1.TenantModelListReq) (*v1.TenantModelListRes, error) {
var results []struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ModelID int64 `json:"model_id"`
Enabled bool `json:"enabled"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
ChannelScope string `json:"channel_scope"`
CustomInputPrice *float64 `json:"custom_input_price"`
CustomOutputPrice *float64 `json:"custom_output_price"`
Multiplier float64 `json:"multiplier"`
ModelCode string `json:"model_code"`
ModelName string `json:"model_name"`
Category string `json:"category"`
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ModelID int64 `json:"model_id"`
Enabled bool `json:"enabled"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
ChannelScope string `json:"channel_scope"`
CustomInputPrice *float64 `json:"custom_input_price"`
CustomOutputPrice *float64 `json:"custom_output_price"`
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"`
CustomPricingTiers string `json:"custom_pricing_tiers"`
Multiplier float64 `json:"multiplier"`
ModelCode string `json:"model_code"`
ModelName string `json:"model_name"`
Category string `json:"category"`
}
err := dao.MdlTenantModels.Ctx(ctx).
LeftJoin("mdl_models ON mdl_tenant_models.model_id = mdl_models.id").
Where("mdl_tenant_models.tenant_id", req.TenantID).
Fields("mdl_tenant_models.id, mdl_tenant_models.tenant_id, mdl_tenant_models.model_id, mdl_tenant_models.enabled, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, mdl_tenant_models.channel_scope, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.multiplier, mdl_models.model_id as model_code, mdl_models.model_name, mdl_models.category").
Fields("mdl_tenant_models.id, mdl_tenant_models.tenant_id, mdl_tenant_models.model_id, mdl_tenant_models.enabled, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, mdl_tenant_models.channel_scope, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers, mdl_tenant_models.multiplier, mdl_models.model_id as model_code, mdl_models.model_name, mdl_models.category").
OrderAsc("mdl_models.category").
OrderAsc("mdl_models.model_id").
Scan(&results)
@@ -43,23 +47,32 @@ func (s *sAdmin) ListTenantModels(ctx context.Context, req *v1.TenantModelListRe
list := make([]v1.TenantModelItem, 0, len(results))
for _, r := range results {
list = append(list, v1.TenantModelItem{
ID: r.ID,
TenantID: r.TenantID,
ModelID: r.ModelID,
ModelCode: r.ModelCode,
ModelName: r.ModelName,
Category: r.Category,
Enabled: r.Enabled,
BillingMode: r.BillingMode,
PerRequestPrice: r.PerRequestPrice,
DiscountRatio: r.DiscountRatio,
MaxConcurrency: r.MaxConcurrency,
ChannelScope: r.ChannelScope,
CustomInputPrice: r.CustomInputPrice,
CustomOutputPrice: r.CustomOutputPrice,
Multiplier: r.Multiplier,
})
item := v1.TenantModelItem{
ID: r.ID,
TenantID: r.TenantID,
ModelID: r.ModelID,
ModelCode: r.ModelCode,
ModelName: r.ModelName,
Category: r.Category,
Enabled: r.Enabled,
BillingMode: r.BillingMode,
PerRequestPrice: r.PerRequestPrice,
DiscountRatio: r.DiscountRatio,
MaxConcurrency: r.MaxConcurrency,
ChannelScope: r.ChannelScope,
CustomInputPrice: r.CustomInputPrice,
CustomOutputPrice: r.CustomOutputPrice,
CustomCacheReadPrice: r.CustomCacheReadPrice,
CustomCacheCreationPrice: r.CustomCacheCreationPrice,
Multiplier: r.Multiplier,
}
if r.CustomPricingTiers != "" && r.CustomPricingTiers != "null" && r.CustomPricingTiers != "[]" {
var tiers []*v1.PricingTier
if err := json.Unmarshal([]byte(r.CustomPricingTiers), &tiers); err == nil {
item.CustomPricingTiers = tiers
}
}
list = append(list, item)
}
return &v1.TenantModelListRes{List: list}, nil
@@ -70,7 +83,6 @@ func (s *sAdmin) BatchAssignModels(ctx context.Context, req *v1.TenantModelBatch
assigned := 0
for _, a := range req.Assignments {
// Check model exists and is active
var model *struct {
ID int64 `json:"id"`
Status string `json:"status"`
@@ -82,7 +94,6 @@ func (s *sAdmin) BatchAssignModels(ctx context.Context, req *v1.TenantModelBatch
continue
}
// Check if already assigned
count, _ := dao.MdlTenantModels.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("model_id", a.ModelID).
@@ -97,25 +108,33 @@ func (s *sAdmin) BatchAssignModels(ctx context.Context, req *v1.TenantModelBatch
maxConc = &concurrency
}
var tiersJSON string
if len(a.CustomPricingTiers) > 0 {
b, _ := json.Marshal(a.CustomPricingTiers)
tiersJSON = string(b)
}
_, err = dao.MdlTenantModels.Ctx(ctx).Insert(do.MdlTenantModels{
TenantId: req.TenantID,
ModelId: a.ModelID,
Enabled: a.Enabled,
BillingMode: a.BillingMode,
PerRequestPrice: a.PerRequestPrice,
DiscountRatio: a.DiscountRatio,
MaxConcurrency: maxConc,
ChannelScope: a.ChannelScope,
CustomInputPrice: a.CustomInputPrice,
CustomOutputPrice: a.CustomOutputPrice,
Multiplier: 1.0,
TenantId: req.TenantID,
ModelId: a.ModelID,
Enabled: a.Enabled,
BillingMode: a.BillingMode,
PerRequestPrice: a.PerRequestPrice,
DiscountRatio: a.DiscountRatio,
MaxConcurrency: maxConc,
ChannelScope: a.ChannelScope,
CustomInputPrice: a.CustomInputPrice,
CustomOutputPrice: a.CustomOutputPrice,
CustomCacheReadPrice: a.CustomCacheReadPrice,
CustomCacheCreationPrice: a.CustomCacheCreationPrice,
CustomPricingTiers: tiersJSON,
Multiplier: 1.0,
})
if err == nil {
assigned++
}
}
// Clear billing cache for this tenant
billing.ClearTenantPriceCache(ctx, req.TenantID)
return &v1.TenantModelBatchAssignRes{Assigned: assigned}, nil
@@ -146,9 +165,23 @@ func (s *sAdmin) UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdat
if req.CustomInputPrice != nil {
data.CustomInputPrice = *req.CustomInputPrice
}
if req.CustomCacheReadPrice != nil {
data.CustomCacheReadPrice = *req.CustomCacheReadPrice
}
if req.CustomCacheCreationPrice != nil {
data.CustomCacheCreationPrice = *req.CustomCacheCreationPrice
}
if req.CustomOutputPrice != nil {
data.CustomOutputPrice = *req.CustomOutputPrice
}
if req.CustomPricingTiers != nil {
if len(*req.CustomPricingTiers) > 0 {
b, _ := json.Marshal(*req.CustomPricingTiers)
data.CustomPricingTiers = string(b)
} else {
data.CustomPricingTiers = "[]"
}
}
_, err := dao.MdlTenantModels.Ctx(ctx).
Where("tenant_id", req.TenantID).
@@ -159,7 +192,6 @@ func (s *sAdmin) UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdat
return nil, err
}
// Clear billing cache
billing.ClearTenantPriceCache(ctx, req.TenantID)
return nil, nil
@@ -180,7 +212,6 @@ func (s *sAdmin) DeleteTenantModel(ctx context.Context, req *v1.TenantModelDelet
return nil, common.NewNotFoundError("模型分配")
}
// Clear billing cache
billing.ClearTenantPriceCache(ctx, req.TenantID)
return nil, nil
+57 -14
View File
@@ -2,6 +2,7 @@ package billing
import (
"context"
"encoding/json"
"fmt"
"math"
"time"
@@ -45,6 +46,9 @@ type PricingResult struct {
// Cache 直接定价
CacheReadPrice float64 // 缓存读取每 1M token 价格
CacheCreationPrice float64 // 缓存创建每 1M token 价格
// 租户自定义阶梯定价(JSONB 解析后的原始数据,供 CalculateCost 使用)
CustomTiers []pricingTierRow
}
// ClearTenantPriceCache 清除租户的所有模型价格缓存
@@ -139,20 +143,23 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
// 3. 查租户独立价格(mdl_tenant_models)
type tenantModelRow struct {
CustomInputPrice *float64 `json:"custom_input_price"`
CustomOutputPrice *float64 `json:"custom_output_price"`
Multiplier *float64 `json:"multiplier"`
DiscountRatio *float64 `json:"discount_ratio"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
Enabled bool `json:"enabled"`
CustomInputPrice *float64 `json:"custom_input_price"`
CustomOutputPrice *float64 `json:"custom_output_price"`
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"`
CustomPricingTiers string `json:"custom_pricing_tiers"`
Multiplier *float64 `json:"multiplier"`
DiscountRatio *float64 `json:"discount_ratio"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
Enabled bool `json:"enabled"`
}
var tm *tenantModelRow
err = dao.MdlTenantModels.Ctx(ctx).
Where("tenant_id", tenantID).
Where("model_id", model.ID).
Fields("custom_input_price, custom_output_price, multiplier, discount_ratio, billing_mode, per_request_price, enabled").
Fields("custom_input_price, custom_output_price, custom_cache_read_price, custom_cache_creation_price, custom_pricing_tiers, multiplier, discount_ratio, billing_mode, per_request_price, enabled").
Scan(&tm)
if err != nil {
return nil, gerror.Wrapf(err, "query tenant model price")
@@ -161,6 +168,7 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
tenantMultiplier := 1.0
discountRatio := 1.0
billingSource := "base"
var customTiers []pricingTierRow
if tm != nil && tm.Enabled {
billingSource = "tenant_custom"
@@ -176,6 +184,19 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
outputPrice = *tm.CustomOutputPrice
}
// 租户覆盖缓存定价
if tm.CustomCacheReadPrice != nil && *tm.CustomCacheReadPrice > 0 {
cacheReadPrice = *tm.CustomCacheReadPrice
}
if tm.CustomCacheCreationPrice != nil && *tm.CustomCacheCreationPrice > 0 {
cacheCreationPrice = *tm.CustomCacheCreationPrice
}
// 租户自定义阶梯定价
if tm.CustomPricingTiers != "" && tm.CustomPricingTiers != "null" && tm.CustomPricingTiers != "[]" {
_ = json.Unmarshal([]byte(tm.CustomPricingTiers), &customTiers)
}
// discount_ratio 优先于 multiplier
if tm.DiscountRatio != nil && *tm.DiscountRatio > 0 {
discountRatio = *tm.DiscountRatio
@@ -211,6 +232,7 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
Currency: "USD",
CacheReadPrice: cacheReadPrice,
CacheCreationPrice: cacheCreationPrice,
CustomTiers: customTiers,
}
modelPriceCache.Set(ctx, cacheKey, result)
@@ -245,8 +267,13 @@ func CalculateCost(ctx context.Context, tenantID int64, modelName string, inputT
// Token / Tiered 计费
var inputCost, outputCost float64
if pricing.BillingMode == "tiered" {
inputCost, _ = calculateTieredCostFromPricing(ctx, modelName, inputTokens, true)
outputCost, _ = calculateTieredCostFromPricing(ctx, modelName, outputTokens, false)
if len(pricing.CustomTiers) > 0 {
inputCost = calculateTieredCostFromTiers(pricing.CustomTiers, inputTokens, true)
outputCost = calculateTieredCostFromTiers(pricing.CustomTiers, outputTokens, false)
} else {
inputCost, _ = calculateTieredCostFromPricing(ctx, modelName, inputTokens, true)
outputCost, _ = calculateTieredCostFromPricing(ctx, modelName, outputTokens, false)
}
} else {
// 统一单价模式
inputCost = float64(inputTokens) / 1_000_000.0 * pricing.InputPrice
@@ -326,7 +353,11 @@ func CalculateCostWithUsage(ctx context.Context, tenantID int64, modelName strin
// 基础输入费用
var baseInputCost float64
if pricing.BillingMode == "tiered" {
baseInputCost, _ = calculateTieredCostFromPricing(ctx, modelName, baseInputTokens, true)
if len(pricing.CustomTiers) > 0 {
baseInputCost = calculateTieredCostFromTiers(pricing.CustomTiers, baseInputTokens, true)
} else {
baseInputCost, _ = calculateTieredCostFromPricing(ctx, modelName, baseInputTokens, true)
}
} else {
baseInputCost = float64(baseInputTokens) / 1_000_000.0 * pricing.InputPrice
}
@@ -334,7 +365,11 @@ func CalculateCostWithUsage(ctx context.Context, tenantID int64, modelName strin
// 输出费用
var outputCost float64
if pricing.BillingMode == "tiered" {
outputCost, _ = calculateTieredCostFromPricing(ctx, modelName, outputTokens, false)
if len(pricing.CustomTiers) > 0 {
outputCost = calculateTieredCostFromTiers(pricing.CustomTiers, outputTokens, false)
} else {
outputCost, _ = calculateTieredCostFromPricing(ctx, modelName, outputTokens, false)
}
} else {
outputCost = float64(outputTokens) / 1_000_000.0 * pricing.OutputPrice
}
@@ -486,6 +521,15 @@ func calculateTieredCostFromPricing(ctx context.Context, modelName string, token
return 0, 1.0
}
return calculateTieredCostFromTiers(tiers, tokens, isInput), 1.0
}
// calculateTieredCostFromTiers 从给定的阶梯数组计算费用(租户自定义阶梯或基础阶梯共用)
func calculateTieredCostFromTiers(tiers []pricingTierRow, tokens int, isInput bool) float64 {
if tokens <= 0 || len(tiers) == 0 {
return 0
}
var totalCost float64
remaining := int64(tokens)
@@ -500,7 +544,6 @@ func calculateTieredCostFromPricing(ctx context.Context, modelName string, token
}
if tier.MaxTokens == nil {
// 最后一段无上限
totalCost += float64(remaining) / 1_000_000.0 * price
remaining = 0
} else {
@@ -517,5 +560,5 @@ func calculateTieredCostFromPricing(ctx context.Context, modelName string, token
}
}
return totalCost, 1.0
return totalCost
}
+167 -29
View File
@@ -2,17 +2,27 @@ package tenant
import (
"context"
"encoding/json"
v1 "github.com/qianfree/team-api/api/tenant/v1"
"github.com/qianfree/team-api/internal/dao"
)
// pricingTierRow 阶梯定价行(与 billing.pricingTierRow 结构一致)
type pricingTierRow struct {
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
}
// ListAvailableModels 获取租户可用的模型列表
func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsReq) (*v1.TenantAvailableModelsRes, error) {
tenantID := ctxTenantID(ctx)
query := dao.MdlTenantModels.Ctx(ctx).
LeftJoin("mdl_models m ON mdl_tenant_models.model_id = m.id").
LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id AND p.min_tokens = 0").
Where("mdl_tenant_models.tenant_id", tenantID).
Where("mdl_tenant_models.enabled", true).
Where("m.status", "active")
@@ -25,23 +35,34 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
}
var results []struct {
ID int64 `json:"id"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Description string `json:"description"`
Tags string `json:"tags"`
Capabilities string `json:"capabilities"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
ID int64 `json:"id"`
ModelDBID int64 `json:"model_db_id"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Description string `json:"description"`
Tags string `json:"tags"`
Capabilities string `json:"capabilities"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
BaseInputPrice float64 `json:"base_input_price"`
BaseOutputPrice float64 `json:"base_output_price"`
BaseCacheReadPrice float64 `json:"base_cache_read_price"`
BaseCacheCreationPrice float64 `json:"base_cache_creation_price"`
BaseBillingMode string `json:"base_billing_mode"`
CustomInputPrice *float64 `json:"custom_input_price"`
CustomOutputPrice *float64 `json:"custom_output_price"`
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"`
CustomPricingTiers string `json:"custom_pricing_tiers"`
}
err := query.
Fields("mdl_tenant_models.id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency").
Fields("mdl_tenant_models.id, mdl_tenant_models.model_id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers").
OrderAsc("m.category").
OrderAsc("m.model_id").
Scan(&results)
@@ -49,24 +70,141 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
return nil, err
}
// 收集需要查询 base 阶梯定价的 model 内部 ID(mdl_models.id)
tieredModelDBIDs := make([]int64, 0)
for _, r := range results {
effectiveBillingMode := resolveBillingMode(r.BillingMode, r.BaseBillingMode)
if effectiveBillingMode == "tiered" && r.CustomPricingTiers == "" {
tieredModelDBIDs = append(tieredModelDBIDs, r.ModelDBID)
}
}
// 批量查询 base 阶梯定价(min_tokens > 0 的阶梯)
baseTiersMap := make(map[int64][]v1.PricingTierItem)
if len(tieredModelDBIDs) > 0 {
var baseTiers []struct {
ModelId int64 `json:"model_id"`
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
}
err = dao.MdlPricing.Ctx(ctx).
WhereIn("model_id", tieredModelDBIDs).
Where("billing_mode", "tiered").
Where("min_tokens > 0").
Fields("model_id, min_tokens, max_tokens, input_price, output_price").
OrderAsc("model_id").
OrderAsc("min_tokens").
Scan(&baseTiers)
if err == nil {
for _, t := range baseTiers {
baseTiersMap[t.ModelId] = append(baseTiersMap[t.ModelId], v1.PricingTierItem{
MinTokens: t.MinTokens,
MaxTokens: t.MaxTokens,
InputPrice: t.InputPrice,
OutputPrice: t.OutputPrice,
})
}
}
}
list := make([]v1.TenantAvailableModelItem, 0, len(results))
for _, r := range results {
list = append(list, v1.TenantAvailableModelItem{
ID: r.ID,
ModelId: r.ModelId,
ModelName: r.ModelName,
Category: r.Category,
MaxContext: r.MaxContextTokens,
MaxOutput: r.MaxOutputTokens,
Description: r.Description,
Tags: r.Tags,
Capabilities: r.Capabilities,
BillingMode: r.BillingMode,
PerRequestPrice: r.PerRequestPrice,
DiscountRatio: r.DiscountRatio,
MaxConcurrency: r.MaxConcurrency,
})
effectiveBillingMode := resolveBillingMode(r.BillingMode, r.BaseBillingMode)
inputPrice := effectivePrice(r.CustomInputPrice, r.BaseInputPrice)
outputPrice := effectivePrice(r.CustomOutputPrice, r.BaseOutputPrice)
cacheReadPrice := effectivePrice(r.CustomCacheReadPrice, r.BaseCacheReadPrice)
cacheCreationPrice := effectivePrice(r.CustomCacheCreationPrice, r.BaseCacheCreationPrice)
item := v1.TenantAvailableModelItem{
ID: r.ID,
ModelId: r.ModelId,
ModelName: r.ModelName,
Category: r.Category,
MaxContext: r.MaxContextTokens,
MaxOutput: r.MaxOutputTokens,
Description: r.Description,
Tags: r.Tags,
Capabilities: r.Capabilities,
BillingMode: &effectiveBillingMode,
PerRequestPrice: r.PerRequestPrice,
DiscountRatio: r.DiscountRatio,
MaxConcurrency: r.MaxConcurrency,
InputPrice: inputPrice,
OutputPrice: outputPrice,
CacheReadPrice: cacheReadPrice,
CacheCreationPrice: cacheCreationPrice,
}
// 阶梯定价处理
if effectiveBillingMode == "tiered" {
var tiers []v1.PricingTierItem
// 优先使用租户自定义阶梯
if r.CustomPricingTiers != "" && r.CustomPricingTiers != "null" && r.CustomPricingTiers != "[]" {
var raw []pricingTierRow
if json.Unmarshal([]byte(r.CustomPricingTiers), &raw) == nil && len(raw) > 0 {
for _, t := range raw {
tiers = append(tiers, v1.PricingTierItem{
MinTokens: t.MinTokens,
MaxTokens: t.MaxTokens,
InputPrice: t.InputPrice,
OutputPrice: t.OutputPrice,
})
}
}
}
// 使用 base 阶梯定价
if len(tiers) == 0 {
// 第一阶梯(min_tokens=0,来自主查询的 base_input/output_price)
if r.BaseInputPrice > 0 || r.BaseOutputPrice > 0 {
tiers = append(tiers, v1.PricingTierItem{
MinTokens: 0,
MaxTokens: nil,
InputPrice: r.BaseInputPrice,
OutputPrice: r.BaseOutputPrice,
})
}
// 后续阶梯
if rest, ok := baseTiersMap[r.ModelDBID]; ok {
// 设置第一阶梯的 max_tokens
if len(tiers) > 0 && len(rest) > 0 {
tiers[0].MaxTokens = &rest[0].MinTokens
}
tiers = append(tiers, rest...)
}
}
item.PricingTiers = tiers
}
list = append(list, item)
}
return &v1.TenantAvailableModelsRes{List: list}, nil
}
// resolveBillingMode 解析有效计费模式
func resolveBillingMode(tenantMode *string, baseMode string) string {
if tenantMode != nil && *tenantMode != "" {
return *tenantMode
}
if baseMode != "" {
return baseMode
}
return "token"
}
// effectivePrice 计算有效价格:自定义价优先,否则用基础价
func effectivePrice(custom *float64, base float64) *float64 {
if custom != nil && *custom > 0 {
return custom
}
if base > 0 {
return &base
}
return nil
}
+1 -1
View File
@@ -20,7 +20,7 @@ type ChnChannelKeys struct {
LastUsedAt *gtime.Time // 最后使用时间
LastError any // 最后一次错误信息
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
KeyType any // Key 类型:apikey(传统静态密钥)/ oauth(OAuth 令牌)
TokenExpiresAt *gtime.Time // OAuth access_token 过期时间(仅 key_type=oauth 时有值)
UpdatedAt *gtime.Time // 更新时间
}
+1 -1
View File
@@ -21,10 +21,10 @@ type MdlModels struct {
MaxOutputTokens any // 最大输出 token 数
Description any // 模型描述
Tags []string // 标签(如 reasoning、vision、function_calling)
Capabilities any // 模型能力特性(如 vision、function_calling、reasoning 等)
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
DeprecatedAt *gtime.Time // 标记弃用的时间(NULL表示未弃用)
SunsetDate *gtime.Time // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
ReplacementModel any // 推荐替代模型名(NULL表示无替代)
Capabilities any // 模型能力特性(如 vision、function_calling、reasoning 等)
}
+18 -15
View File
@@ -11,19 +11,22 @@ import (
// MdlTenantModels is the golang structure of table mdl_tenant_models for DAO operations like Where/Data.
type MdlTenantModels struct {
g.Meta `orm:"table:mdl_tenant_models, do:true"`
Id any // 主键ID
TenantId any // 租户ID
ModelId any // 模型ID
Enabled any // 是否启用(禁用后该租户无法调用此模型)
CustomInputPrice any // 租户自定义输入价格(NULL 表示使用默认定价)
CustomOutputPrice any // 租户自定义输出价格(NULL 表示使用默认定价)
Multiplier any // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
BillingMode any // 覆盖模型计费方式(NULL表示跟随模型默认)
PerRequestPrice any // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
DiscountRatio any // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
MaxConcurrency any // 单模型并发上限(NULL表示不限制)
ChannelScope any // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
g.Meta `orm:"table:mdl_tenant_models, do:true"`
Id any // 主键ID
TenantId any // 租户ID
ModelId any // 模型ID
Enabled any // 是否启用(禁用后该租户无法调用此模型)
CustomInputPrice any // 租户自定义输入价格(NULL 表示使用默认定价)
CustomOutputPrice any // 租户自定义输出价格(NULL 表示使用默认定价)
Multiplier any // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
BillingMode any // 覆盖模型计费方式(NULL表示跟随模型默认)
PerRequestPrice any // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
DiscountRatio any // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
MaxConcurrency any // 单模型并发上限(NULL表示不限制)
ChannelScope any // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
CustomCacheReadPrice any // 自定义缓存读取价格($/1M token),NULL 表示使用基础定价
CustomCacheCreationPrice any // 自定义缓存创建价格($/1M token),NULL 表示使用基础定价
CustomPricingTiers any // 自定义阶梯定价(JSONB 数组),格式: [{"min_tokens":0,"max_tokens":100000,"input_price":0.5,"output_price":1.5,"cache_read_price":0.1,"cache_creation_price":0.2}]
}
+1 -1
View File
@@ -22,6 +22,6 @@ type NtfMessages struct {
IsRead any // 是否已读:0=未读, 1=已读
IsBroadcast any // 是否广播消息:0=个人消息, 1=广播消息
Metadata any // 附加元数据(JSONB,如关联资源ID、跳转链接等)
CreatedAt *gtime.Time // 创建时间
TargetRoles any // 目标角色(NULL=全部角色,逗号分隔如 owner,admin 表示仅限这些角色)
CreatedAt *gtime.Time // 创建时间
}
+25
View File
@@ -0,0 +1,25 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// SysPlugins is the golang structure of table sys_plugins for DAO operations like Where/Data.
type SysPlugins struct {
g.Meta `orm:"table:sys_plugins, do:true"`
Id any //
Name any // 插件唯一标识,如 email-report
Label any // 显示名称
Version any // 当前安装版本
Status any // 状态:registered=已注册, installed=已安装, enabled=已启用, disabled=已禁用, error=异常
Category any // 分类:relay=代理扩展, middleware=中间件, billing=计费, notification=通知, extension=通用扩展
Config any // 插件全局配置(JSON)
ErrorMsg any // 异常信息
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// TntTenantPlugins is the golang structure of table tnt_tenant_plugins for DAO operations like Where/Data.
type TntTenantPlugins struct {
g.Meta `orm:"table:tnt_tenant_plugins, do:true"`
Id any //
TenantId any //
PluginName any // 插件标识
Enabled any // 是否启用
Config any // 租户级配置覆盖(JSON),优先级高于全局配置
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
+1 -1
View File
@@ -18,7 +18,7 @@ type ChnChannelKeys struct {
LastUsedAt *gtime.Time `json:"last_used_at" orm:"last_used_at" description:"最后使用时间"` // 最后使用时间
LastError string `json:"last_error" orm:"last_error" description:"最后一次错误信息"` // 最后一次错误信息
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
KeyType string `json:"key_type" orm:"key_type" description:"Key 类型:apikey(传统静态密钥)/ oauth(OAuth 令牌)"` // Key 类型:apikey(传统静态密钥)/ oauth(OAuth 令牌)
TokenExpiresAt *gtime.Time `json:"token_expires_at" orm:"token_expires_at" description:"OAuth access_token 过期时间(仅 key_type=oauth 时有值)"` // OAuth access_token 过期时间(仅 key_type=oauth 时有值)
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
}
+1 -1
View File
@@ -19,10 +19,10 @@ type MdlModels struct {
MaxOutputTokens int `json:"max_output_tokens" orm:"max_output_tokens" description:"最大输出 token 数"` // 最大输出 token 数
Description string `json:"description" orm:"description" description:"模型描述"` // 模型描述
Tags []string `json:"tags" orm:"tags" description:"标签(如 reasoning、vision、function_calling)"` // 标签(如 reasoning、vision、function_calling)
Capabilities string `json:"capabilities" orm:"capabilities" description:"模型能力特性(如 vision、function_calling、reasoning 等)"` // 模型能力特性(如 vision、function_calling、reasoning 等)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
DeprecatedAt *gtime.Time `json:"deprecated_at" orm:"deprecated_at" description:"标记弃用的时间(NULL表示未弃用)"` // 标记弃用的时间(NULL表示未弃用)
SunsetDate *gtime.Time `json:"sunset_date" orm:"sunset_date" description:"计划下线日期(到达后返回410 Gone,NULL表示未设置)"` // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
ReplacementModel string `json:"replacement_model" orm:"replacement_model" description:"推荐替代模型名(NULL表示无替代)"` // 推荐替代模型名(NULL表示无替代)
Capabilities string `json:"capabilities" orm:"capabilities" description:"模型能力特性(如 vision、function_calling、reasoning 等)"` // 模型能力特性(如 vision、function_calling、reasoning 等)
}
+17 -14
View File
@@ -10,18 +10,21 @@ import (
// MdlTenantModels is the golang structure for table mdl_tenant_models.
type MdlTenantModels struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
ModelId int64 `json:"model_id" orm:"model_id" description:"模型ID"` // 模型ID
Enabled bool `json:"enabled" orm:"enabled" description:"是否启用(禁用后该租户无法调用此模型)"` // 是否启用(禁用后该租户无法调用此模型)
CustomInputPrice float64 `json:"custom_input_price" orm:"custom_input_price" description:"租户自定义输入价格(NULL 表示使用默认定价)"` // 租户自定义输入价格(NULL 表示使用默认定价)
CustomOutputPrice float64 `json:"custom_output_price" orm:"custom_output_price" description:"租户自定义输出价格(NULL 表示使用默认定价)"` // 租户自定义输出价格(NULL 表示使用默认定价)
Multiplier float64 `json:"multiplier" orm:"multiplier" description:"租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)"` // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"覆盖模型计费方式(NULL表示跟随模型默认)"` // 覆盖模型计费方式(NULL表示跟随模型默认)
PerRequestPrice float64 `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)"` // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
DiscountRatio float64 `json:"discount_ratio" orm:"discount_ratio" description:"折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)"` // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"单模型并发上限(NULL表示不限制)"` // 单模型并发上限(NULL表示不限制)
ChannelScope string `json:"channel_scope" orm:"channel_scope" description:"渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)"` // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
ModelId int64 `json:"model_id" orm:"model_id" description:"模型ID"` // 模型ID
Enabled bool `json:"enabled" orm:"enabled" description:"是否启用(禁用后该租户无法调用此模型)"` // 是否启用(禁用后该租户无法调用此模型)
CustomInputPrice float64 `json:"custom_input_price" orm:"custom_input_price" description:"租户自定义输入价格(NULL 表示使用默认定价)"` // 租户自定义输入价格(NULL 表示使用默认定价)
CustomOutputPrice float64 `json:"custom_output_price" orm:"custom_output_price" description:"租户自定义输出价格(NULL 表示使用默认定价)"` // 租户自定义输出价格(NULL 表示使用默认定价)
Multiplier float64 `json:"multiplier" orm:"multiplier" description:"租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)"` // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"覆盖模型计费方式(NULL表示跟随模型默认)"` // 覆盖模型计费方式(NULL表示跟随模型默认)
PerRequestPrice float64 `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)"` // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
DiscountRatio float64 `json:"discount_ratio" orm:"discount_ratio" description:"折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)"` // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"单模型并发上限(NULL表示不限制)"` // 单模型并发上限(NULL表示不限制)
ChannelScope string `json:"channel_scope" orm:"channel_scope" description:"渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)"` // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
CustomCacheReadPrice float64 `json:"custom_cache_read_price" orm:"custom_cache_read_price" description:"自定义缓存读取价格($/1M token),NULL 表示使用基础定价"` // 自定义缓存读取价格($/1M token),NULL 表示使用基础定价
CustomCacheCreationPrice float64 `json:"custom_cache_creation_price" orm:"custom_cache_creation_price" description:"自定义缓存创建价格($/1M token),NULL 表示使用基础定价"` // 自定义缓存创建价格($/1M token),NULL 表示使用基础定价
CustomPricingTiers string `json:"custom_pricing_tiers" orm:"custom_pricing_tiers" description:"自定义阶梯定价(JSONB 数组),格式: [{\"min_tokens\":0,\"max_tokens\":100000,\"input_price\":0.5,\"output_price\":1.5,\"cache_read_price\":0.1,\"cache_creation_price\":0.2}]"` // 自定义阶梯定价(JSONB 数组),格式: [{"min_tokens":0,"max_tokens":100000,"input_price":0.5,"output_price":1.5,"cache_read_price":0.1,"cache_creation_price":0.2}]
}
+1 -1
View File
@@ -20,6 +20,6 @@ type NtfMessages struct {
IsRead int `json:"is_read" orm:"is_read" description:"是否已读:0=未读, 1=已读"` // 是否已读:0=未读, 1=已读
IsBroadcast int `json:"is_broadcast" orm:"is_broadcast" description:"是否广播消息:0=个人消息, 1=广播消息"` // 是否广播消息:0=个人消息, 1=广播消息
Metadata string `json:"metadata" orm:"metadata" description:"附加元数据(JSONB,如关联资源ID、跳转链接等)"` // 附加元数据(JSONB,如关联资源ID、跳转链接等)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
TargetRoles string `json:"target_roles" orm:"target_roles" description:"目标角色(NULL=全部角色,逗号分隔如 owner,admin 表示仅限这些角色)"` // 目标角色(NULL=全部角色,逗号分隔如 owner,admin 表示仅限这些角色)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
}
+23
View File
@@ -0,0 +1,23 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// SysPlugins is the golang structure for table sys_plugins.
type SysPlugins struct {
Id int64 `json:"id" orm:"id" description:""` //
Name string `json:"name" orm:"name" description:"插件唯一标识,如 email-report"` // 插件唯一标识,如 email-report
Label string `json:"label" orm:"label" description:"显示名称"` // 显示名称
Version string `json:"version" orm:"version" description:"当前安装版本"` // 当前安装版本
Status string `json:"status" orm:"status" description:"状态:registered=已注册, installed=已安装, enabled=已启用, disabled=已禁用, error=异常"` // 状态:registered=已注册, installed=已安装, enabled=已启用, disabled=已禁用, error=异常
Category string `json:"category" orm:"category" description:"分类:relay=代理扩展, middleware=中间件, billing=计费, notification=通知, extension=通用扩展"` // 分类:relay=代理扩展, middleware=中间件, billing=计费, notification=通知, extension=通用扩展
Config string `json:"config" orm:"config" description:"插件全局配置(JSON)"` // 插件全局配置(JSON)
ErrorMsg string `json:"error_msg" orm:"error_msg" description:"异常信息"` // 异常信息
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
}
@@ -0,0 +1,20 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// TntTenantPlugins is the golang structure for table tnt_tenant_plugins.
type TntTenantPlugins struct {
Id int64 `json:"id" orm:"id" description:""` //
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
PluginName string `json:"plugin_name" orm:"plugin_name" description:"插件标识"` // 插件标识
Enabled bool `json:"enabled" orm:"enabled" description:"是否启用"` // 是否启用
Config string `json:"config" orm:"config" description:"租户级配置覆盖(JSON),优先级高于全局配置"` // 租户级配置覆盖(JSON),优先级高于全局配置
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
}
@@ -0,0 +1,22 @@
-- +goose Up
-- 租户模型分配增加自定义缓存定价覆盖字段和阶梯定价支持
-- 缓存定价覆盖
ALTER TABLE mdl_tenant_models
ADD COLUMN IF NOT EXISTS custom_cache_read_price NUMERIC(20,10),
ADD COLUMN IF NOT EXISTS custom_cache_creation_price NUMERIC(20,10);
COMMENT ON COLUMN mdl_tenant_models.custom_cache_read_price IS '自定义缓存读取价格($/1M token),NULL 表示使用基础定价';
COMMENT ON COLUMN mdl_tenant_models.custom_cache_creation_price IS '自定义缓存创建价格($/1M token),NULL 表示使用基础定价';
-- 阶梯定价
ALTER TABLE mdl_tenant_models
ADD COLUMN IF NOT EXISTS custom_pricing_tiers JSONB;
COMMENT ON COLUMN mdl_tenant_models.custom_pricing_tiers IS '自定义阶梯定价(JSONB 数组),格式: [{"min_tokens":0,"max_tokens":100000,"input_price":0.5,"output_price":1.5,"cache_read_price":0.1,"cache_creation_price":0.2}]';
-- +goose Down
ALTER TABLE mdl_tenant_models
DROP COLUMN IF EXISTS custom_cache_read_price,
DROP COLUMN IF EXISTS custom_cache_creation_price,
DROP COLUMN IF EXISTS custom_pricing_tiers;
+180 -36
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import { ref, reactive, computed, onMounted, h, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Tag, Button, Space, Popconfirm, Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
@@ -199,9 +199,15 @@ const editModelForm = reactive({
max_concurrency: 5 as number | null,
custom_input_price: null as number | null,
custom_output_price: null as number | null,
custom_cache_read_price: null as number | null,
custom_cache_creation_price: null as number | null,
custom_pricing_tiers: [] as any[],
})
let suppressBillingWatch = false
function openEditModel(record: any) {
suppressBillingWatch = true
editingModel.value = record
editModelForm.enabled = record.enabled
editModelForm.billing_mode = record.billing_mode || null
@@ -210,10 +216,48 @@ function openEditModel(record: any) {
editModelForm.max_concurrency = record.max_concurrency ?? 5
editModelForm.custom_input_price = record.custom_input_price || null
editModelForm.custom_output_price = record.custom_output_price || null
editModelForm.custom_cache_read_price = record.custom_cache_read_price || null
editModelForm.custom_cache_creation_price = record.custom_cache_creation_price || null
editModelForm.custom_pricing_tiers = record.custom_pricing_tiers?.length > 0 ? [...record.custom_pricing_tiers] : []
showEditModelModal.value = true
suppressBillingWatch = false
}
async function handleEditModel(done: () => void) {
function addTenantTier() {
const tiers = editModelForm.custom_pricing_tiers
const last = tiers[tiers.length - 1]
const newMin = last?.max_tokens ?? 0
tiers.push({
min_tokens: newMin,
max_tokens: null,
input_price: 0,
output_price: 0,
cache_read_price: 0,
cache_creation_price: 0,
})
if (last && last.max_tokens === null) {
last.max_tokens = newMin
}
}
// 计费模式切换时清理互斥字段
watch(() => editModelForm.billing_mode, (mode) => {
if (suppressBillingWatch) return
if (!mode) {
// 默认模式:清空自定义价格,保留折扣
editModelForm.custom_input_price = null
editModelForm.custom_output_price = null
editModelForm.custom_cache_read_price = null
editModelForm.custom_cache_creation_price = null
editModelForm.per_request_price = null
editModelForm.custom_pricing_tiers = []
} else {
// 自定义价格模式:清空折扣比例
editModelForm.discount_ratio = null
}
})
async function handleEditModel() {
if (!editingModel.value) return
editModelLoading.value = true
try {
@@ -223,10 +267,10 @@ async function handleEditModel(done: () => void) {
}
await request.put(`/admin/tenants/${tenantId}/models/${editingModel.value.model_id}`, payload)
Message.success('更新成功')
done()
showEditModelModal.value = false
fetchTenantModels()
} catch {
return false
// error handled by interceptor
} finally {
editModelLoading.value = false
}
@@ -548,49 +592,132 @@ onMounted(() => {
/>
</AModal>
<!-- Edit Model Config Modal -->
<AModal
<!-- Edit Model Config Drawer -->
<ADrawer
v-model:visible="showEditModelModal"
:title="`编辑模型 - ${editingModel?.model_name || ''}`"
:width="550"
:on-before-ok="handleEditModel"
:ok-loading="editModelLoading"
:width="640"
:mask-closable="false"
:footer="true"
>
<AForm :model="editModelForm" :auto-label-width="true" layout="vertical">
<AFormItem label="启用">
<ASwitch v-model="editModelForm.enabled" />
</AFormItem>
<AFormItem label="计费模式">
<ASelect
v-model="editModelForm.billing_mode"
:options="[{ label: '默认(跟随模型)', value: '' }, { label: 'Token 计费', value: 'token' }, { label: '按次计费', value: 'per_request' }]"
allow-clear
placeholder="默认"
/>
</AFormItem>
<AFormItem label="折扣比例">
<AInputNumber v-model="editModelForm.discount_ratio" :min="0" :max="1" :step="0.05" :precision="2" placeholder="如 0.8 = 8折" class="w-full" />
</AFormItem>
<AFormItem label="自定义输入价格">
<AInputNumber v-model="editModelForm.custom_input_price" :min="0" :precision="4" placeholder="留空则使用默认价格" class="w-full">
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="自定义输出价格">
<AInputNumber v-model="editModelForm.custom_output_price" :min="0" :precision="4" placeholder="留空则使用默认价格" class="w-full">
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="按次单价">
<AInputNumber v-model="editModelForm.per_request_price" :min="0" :precision="4" placeholder="仅按次计费模式有效" class="w-full">
<template #suffix>$ / 次</template>
</AInputNumber>
</AFormItem>
<AFormItem label="单模型并发上限">
<AInputNumber v-model="editModelForm.max_concurrency" :min="0" placeholder="默认5" class="w-full" />
</AFormItem>
<AFormItem label="计费模式">
<ARadioGroup v-model="editModelForm.billing_mode" type="button">
<ARadio value="">默认</ARadio>
<ARadio value="token">Token</ARadio>
<ARadio value="per_request">按次</ARadio>
<ARadio value="tiered">阶梯</ARadio>
</ARadioGroup>
</AFormItem>
<!-- 默认模式:仅折扣比例 -->
<template v-if="!editModelForm.billing_mode">
<ADivider margin="8px">折扣</ADivider>
<div style="color: var(--ta-text-tertiary); font-size: 12px; margin-bottom: 12px">使用模型基础定价 × 折扣比例</div>
<AFormItem label="折扣比例">
<AInputNumber v-model="editModelForm.discount_ratio" :min="0" :max="1" :step="0.05" :precision="2" placeholder="如 0.8 = 8折" class="w-full" />
</AFormItem>
</template>
<!-- Token 模式:自定义价格 -->
<template v-if="editModelForm.billing_mode === 'token'">
<ADivider margin="8px">自定义价格</ADivider>
<div style="color: var(--ta-text-tertiary); font-size: 12px; margin-bottom: 12px">留空则使用模型基础定价</div>
<div class="grid grid-cols-2 gap-x-3">
<AFormItem label="输入价格">
<AInputNumber v-model="editModelForm.custom_input_price" :min="0" :precision="4" placeholder="默认" class="w-full">
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
<AInputNumber v-model="editModelForm.custom_output_price" :min="0" :precision="4" placeholder="默认" class="w-full">
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存读取价格">
<AInputNumber v-model="editModelForm.custom_cache_read_price" :min="0" :precision="4" placeholder="默认" class="w-full">
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建价格">
<AInputNumber v-model="editModelForm.custom_cache_creation_price" :min="0" :precision="4" placeholder="默认" class="w-full">
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
</div>
</template>
<!-- 按次计费 -->
<template v-if="editModelForm.billing_mode === 'per_request'">
<ADivider margin="8px">按次定价</ADivider>
<AFormItem label="按次单价">
<AInputNumber v-model="editModelForm.per_request_price" :min="0" :precision="4" placeholder="每次调用价格" class="w-full">
<template #suffix>$ / 次</template>
</AInputNumber>
</AFormItem>
</template>
<!-- 阶梯计费 -->
<template v-if="editModelForm.billing_mode === 'tiered'">
<ADivider margin="8px">阶梯定价</ADivider>
<div style="color: var(--ta-text-tertiary); font-size: 12px; margin-bottom: 12px">按 Token 用量分段设置不同价格,留空则使用模型基础阶梯定价</div>
<div v-for="(tier, index) in editModelForm.custom_pricing_tiers" :key="index" class="tier-card">
<div class="tier-header">
<span class="tier-label">第 {{ index + 1 }} 梯</span>
<AButton v-if="editModelForm.custom_pricing_tiers.length > 1" size="mini" status="danger" @click="editModelForm.custom_pricing_tiers.splice(index, 1)">删除</AButton>
</div>
<div class="grid grid-cols-2 gap-x-3">
<AFormItem label="起始 Token">
<AInputNumber v-model="tier.min_tokens" :min="0" :step="1000" placeholder="0" class="w-full" />
</AFormItem>
<AFormItem label="结束 Token">
<AInputNumber v-if="index < editModelForm.custom_pricing_tiers.length - 1" v-model="tier.max_tokens" :min="0" :step="1000" placeholder="上限" class="w-full" />
<AInput v-else model-value="无上限" disabled class="w-full" />
</AFormItem>
</div>
<div class="grid grid-cols-2 gap-x-3 mt-2">
<AFormItem label="输入价格">
<AInputNumber v-model="tier.input_price" :min="0" :precision="4" class="w-full">
<template #suffix>$/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
<AInputNumber v-model="tier.output_price" :min="0" :precision="4" class="w-full">
<template #suffix>$/1M</template>
</AInputNumber>
</AFormItem>
</div>
<div class="grid grid-cols-2 gap-x-3 mt-2">
<AFormItem label="缓存读取价格">
<AInputNumber v-model="tier.cache_read_price" :min="0" :precision="4" class="w-full">
<template #suffix>$/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建价格">
<AInputNumber v-model="tier.cache_creation_price" :min="0" :precision="4" class="w-full">
<template #suffix>$/1M</template>
</AInputNumber>
</AFormItem>
</div>
</div>
<AButton type="dashed" long @click="addTenantTier" class="mt-2">+ 添加梯度</AButton>
</template>
</AForm>
</AModal>
<template #footer>
<ASpace>
<AButton @click="showEditModelModal = false">取消</AButton>
<AButton type="primary" :loading="editModelLoading" @click="handleEditModel">保存</AButton>
</ASpace>
</template>
</ADrawer>
<!-- Recharge / Adjust Balance Modal -->
<AModal v-model:visible="showRechargeModal" title="充值 / 调整余额" :width="420" :on-before-ok="handleRecharge" :ok-loading="rechargeLoading">
@@ -640,4 +767,21 @@ onMounted(() => {
padding-top: 16px;
border-top: 1px solid var(--ta-border-light);
}
.tier-card {
padding: 12px 16px;
background: var(--color-fill-1);
border-radius: 8px;
margin-bottom: 8px;
}
.tier-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.tier-label {
font-size: 13px;
font-weight: 600;
color: var(--ta-text-secondary);
}
</style>
+158 -68
View File
@@ -3,6 +3,13 @@ import { ref, computed, onMounted } from 'vue'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
interface PricingTierItem {
min_tokens: number
max_tokens: number | null
input_price: number
output_price: number
}
interface ModelItem {
id: number
model_id: string
@@ -17,6 +24,11 @@ interface ModelItem {
per_request_price: number | null
discount_ratio: number | null
max_concurrency: number | null
input_price: number | null
output_price: number | null
cache_read_price: number | null
cache_creation_price: number | null
pricing_tiers: PricingTierItem[]
}
const capabilityLabel: Record<string, string> = {
@@ -71,6 +83,11 @@ const models = ref<ModelItem[]>([])
const loading = ref(false)
const searchQuery = ref('')
const activeCategory = ref('')
const expandedPricingId = ref<number | null>(null)
function togglePricingExpand(id: number) {
expandedPricingId.value = expandedPricingId.value === id ? null : id
}
const categories = [
{ value: '', label: '全部' },
@@ -89,12 +106,12 @@ const categoryLabel: Record<string, string> = {
rerank: '重排',
}
const categoryColor: Record<string, string> = {
chat: '#14b8a6',
embedding: '#8b5cf6',
image: '#f59e0b',
audio: '#10b981',
rerank: '#6b7280',
const categoryBadgeStyle: Record<string, string> = {
chat: 'background:rgba(20,184,166,0.1);color:#0d9488',
embedding: 'background:rgba(139,92,246,0.1);color:#7c3aed',
image: 'background:rgba(245,158,11,0.1);color:#d97706',
audio: 'background:rgba(16,185,129,0.1);color:#059669',
rerank: 'background:rgba(107,114,128,0.1);color:#4b5563',
}
const filteredModels = computed(() => {
@@ -129,6 +146,36 @@ function formatTokens(n: number): string {
return String(n)
}
function formatPrice(n: number | null): string {
if (n == null || n === 0) return '-'
if (n >= 1) return '$' + n.toFixed(2)
if (n >= 0.01) return '$' + n.toFixed(3)
return '$' + n.toFixed(4)
}
function formatTokenRange(min: number, max: number | null): string {
const fmtMin = formatTokens(min)
if (max == null) return fmtMin + '+'
return fmtMin + ' ~ ' + formatTokens(max)
}
function hasPricing(m: ModelItem): boolean {
if (m.billing_mode === 'per_request' && m.per_request_price != null) return true
if (m.billing_mode === 'token' && (m.input_price != null || m.output_price != null)) return true
if (m.billing_mode === 'tiered' && m.pricing_tiers?.length > 0) return true
return false
}
function getTieredStartPrice(m: ModelItem): string {
if (!m.pricing_tiers?.length) return '-'
const first = m.pricing_tiers[0]
if (first.input_price > 0 && first.output_price > 0) {
return formatPrice(Math.min(first.input_price, first.output_price))
}
if (first.input_price > 0) return formatPrice(first.input_price)
return formatPrice(first.output_price)
}
async function fetchModels() {
loading.value = true
try {
@@ -225,74 +272,117 @@ onMounted(fetchModels)
</span>
</div>
<!-- Section 1: Category + Name -->
<div class="mb-3" :class="{ 'pr-14': m.discount_ratio && m.discount_ratio < 1 }">
<div class="flex items-center gap-2">
<span
class="shrink-0 px-2.5 py-0.5 text-[10px] font-medium text-white rounded-full"
:style="{ background: categoryColor[m.category] || '#6b7280' }"
>
{{ categoryLabel[m.category] || m.category }}
</span>
<h3 class="text-sm font-semibold text-gray-900 truncate">{{ m.model_name || m.model_id }}</h3>
</div>
<div class="flex items-center gap-1 mt-0.5">
<p class="text-xs text-gray-400 font-mono truncate">{{ m.model_id }}</p>
<button
class="shrink-0 p-0.5 rounded text-gray-300 hover:text-primary-500 transition-colors duration-150"
title="复制模型编码"
@click="copyModelId(m.model_id)"
>
<Icon v-if="copiedId !== m.model_id" name="copy" size="xs" />
<Icon v-else name="check" size="xs" class="text-emerald-500" />
</button>
</div>
<!-- Header: Category badge + Model name -->
<div class="flex items-center gap-2" :class="{ 'pr-14': m.discount_ratio && m.discount_ratio < 1 }">
<span
class="shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded"
:style="categoryBadgeStyle[m.category] || categoryBadgeStyle.rerank"
>
{{ categoryLabel[m.category] || m.category }}
</span>
<h3 class="text-sm font-semibold text-gray-900 truncate">{{ m.model_name || m.model_id }}</h3>
</div>
<!-- Section 2: Description (fixed height, always reserved) -->
<div class="mb-3 h-8">
<p v-if="m.description" class="text-xs text-gray-500 line-clamp-2">{{ m.description }}</p>
<span v-else class="text-xs text-gray-300">暂无描述</span>
<!-- Model ID + Copy -->
<div class="flex items-center gap-1 mt-1">
<span class="text-xs text-gray-400 font-mono truncate">{{ m.model_id }}</span>
<button
class="shrink-0 p-0.5 rounded text-gray-300 hover:text-primary-500 transition-colors duration-150"
title="复制模型编码"
@click="copyModelId(m.model_id)"
>
<Icon v-if="copiedId !== m.model_id" name="copy" size="xs" />
<Icon v-else name="check" size="xs" class="text-emerald-500" />
</button>
</div>
<!-- Section 3: Token info + Tags -->
<div class="flex items-center justify-between gap-2 text-xs text-gray-500 flex-1">
<div class="flex flex-wrap gap-3">
<span class="flex items-center gap-1">
<Icon name="document" size="xs" class="text-gray-300" />
{{ m.max_context_tokens ? formatTokens(m.max_context_tokens) : '-' }}
</span>
<span class="flex items-center gap-1">
<Icon name="arrowUp" size="xs" class="text-gray-300" />
{{ m.max_output_tokens ? formatTokens(m.max_output_tokens) : '-' }}
</span>
<span class="flex items-center gap-1">
<Icon name="chart" size="xs" class="text-gray-300" />
{{ m.max_concurrency ?? '-' }}
</span>
</div>
<div v-if="parseTags(m.tags).length" class="flex flex-wrap gap-1 justify-end">
<span
v-for="tag in parseTags(m.tags)"
:key="tag"
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500"
>
{{ tag }}
</span>
</div>
<!-- Description (only if exists) -->
<p v-if="m.description" class="mt-2.5 text-xs text-gray-500 line-clamp-2 leading-relaxed">
{{ m.description }}
</p>
<!-- Key specs (dot-separated) -->
<div class="mt-2.5 flex items-center gap-1.5 text-[11px] text-gray-400">
<span v-if="m.max_context_tokens">{{ formatTokens(m.max_context_tokens) }} 上下文</span>
<span v-if="m.max_context_tokens && m.max_output_tokens" class="text-gray-200">·</span>
<span v-if="m.max_output_tokens">{{ formatTokens(m.max_output_tokens) }} 输出</span>
<template v-if="m.max_concurrency">
<span class="text-gray-200">·</span>
<span>并发 {{ m.max_concurrency }}</span>
</template>
</div>
<!-- Section 4: Capabilities (bottom, fixed height) -->
<div class="mt-3 pt-3 border-t border-gray-100 min-h-[28px]">
<div v-if="parseCapabilities(m.capabilities).length" class="flex flex-wrap gap-1">
<span
v-for="cap in parseCapabilities(m.capabilities)"
:key="cap"
class="inline-flex items-center rounded-md bg-primary-50 px-1.5 py-0.5 text-[10px] font-medium text-primary-600"
>
{{ capabilityLabel[cap] || cap }}
</span>
</div>
<!-- Capabilities (max 4 visible) -->
<div v-if="parseCapabilities(m.capabilities).length" class="mt-2.5 flex flex-wrap gap-1">
<span
v-for="cap in parseCapabilities(m.capabilities).slice(0, 4)"
:key="cap"
class="inline-flex items-center rounded-md bg-primary-50 px-1.5 py-0.5 text-[10px] font-medium text-primary-600"
>
{{ capabilityLabel[cap] || cap }}
</span>
<span
v-if="parseCapabilities(m.capabilities).length > 4"
class="inline-flex items-center rounded-md bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-400"
>
+{{ parseCapabilities(m.capabilities).length - 4 }}
</span>
</div>
<!-- Pricing (pushed to bottom) -->
<div v-if="hasPricing(m)" class="mt-auto pt-3 border-t border-gray-100">
<template v-if="m.billing_mode === 'token'">
<div class="flex items-baseline gap-3 text-xs">
<span>
<span class="text-gray-400">输入</span>
<span class="ml-1 font-semibold text-gray-800">{{ formatPrice(m.input_price) }}</span>
</span>
<span>
<span class="text-gray-400">输出</span>
<span class="ml-1 font-semibold text-gray-800">{{ formatPrice(m.output_price) }}</span>
</span>
<span class="text-gray-300">/1M tokens</span>
</div>
</template>
<template v-else-if="m.billing_mode === 'per_request'">
<div class="text-xs">
<span class="font-semibold text-gray-800">{{ formatPrice(m.per_request_price) }}</span>
<span class="text-gray-400"> /次</span>
</div>
</template>
<template v-else-if="m.billing_mode === 'tiered'">
<div class="flex items-center justify-between">
<div class="text-xs">
<span class="font-semibold text-gray-800">{{ getTieredStartPrice(m) }}</span>
<span class="text-gray-400"> 起 /1M tokens</span>
</div>
<button
class="flex items-center gap-0.5 text-xs text-primary-500 hover:text-primary-600 transition-colors"
@click.stop="togglePricingExpand(m.id)"
>
{{ expandedPricingId === m.id ? '收起' : '阶梯详情' }}
<Icon :name="expandedPricingId === m.id ? 'chevronUp' : 'chevronDown'" size="xs" />
</button>
</div>
<div v-if="expandedPricingId === m.id && m.pricing_tiers?.length" class="mt-2 bg-gray-50 rounded-lg p-2.5 space-y-1.5">
<div
v-for="(tier, idx) in m.pricing_tiers"
:key="idx"
class="flex items-center justify-between text-xs"
>
<span class="text-gray-500 font-mono">{{ formatTokenRange(tier.min_tokens, tier.max_tokens) }}</span>
<span>
<span class="text-gray-400">输入</span>
<span class="font-medium text-gray-700 ml-0.5">{{ formatPrice(tier.input_price) }}</span>
<span class="text-gray-300 mx-1">|</span>
<span class="text-gray-400">输出</span>
<span class="font-medium text-gray-700 ml-0.5">{{ formatPrice(tier.output_price) }}</span>
</span>
</div>
</div>
</template>
</div>
</div>
</div>