diff --git a/api/admin/admin.go b/api/admin/admin.go index a8e560c..0d4028e 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -226,6 +226,7 @@ type IAdminV1 interface { TenantModelBatchAssign(ctx context.Context, req *v1.TenantModelBatchAssignReq) (res *v1.TenantModelBatchAssignRes, err error) TenantModelUpdate(ctx context.Context, req *v1.TenantModelUpdateReq) (res *v1.TenantModelUpdateRes, err error) TenantModelDelete(ctx context.Context, req *v1.TenantModelDeleteReq) (res *v1.TenantModelDeleteRes, err error) + TenantAvailableModelsPreview(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (res *v1.TenantAvailableModelsPreviewRes, err error) TicketList(ctx context.Context, req *v1.TicketListReq) (res *v1.TicketListRes, err error) TicketGet(ctx context.Context, req *v1.TicketGetReq) (res *v1.TicketGetRes, err error) TicketAssign(ctx context.Context, req *v1.TicketAssignReq) (res *v1.TicketAssignRes, err error) diff --git a/api/admin/v1/tenant_model.go b/api/admin/v1/tenant_model.go index 9442ded..3fbf24e 100644 --- a/api/admin/v1/tenant_model.go +++ b/api/admin/v1/tenant_model.go @@ -97,3 +97,24 @@ type TenantModelDeleteReq struct { } type TenantModelDeleteRes struct{} + +// TenantAvailableModelsPreviewReq 预览租户实际可用的所有模型(显式分配 + 分组来源,去重) +type TenantAvailableModelsPreviewReq struct { + g.Meta `path:"/tenants/{tenant_id}/available-models" method:"get" mime:"json" tags:"管理后台-租户模型" summary:"预览租户可用模型"` + TenantID int64 `json:"tenant_id" in:"path" v:"required" dc:"租户ID"` +} + +type TenantAvailableModelsPreviewRes struct { + List []TenantAvailableModelPreview `json:"list"` +} + +// TenantAvailableModelPreview 租户可用模型预览项 +type TenantAvailableModelPreview struct { + 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"` + Source string `json:"source"` // "explicit" 或 "group" +} diff --git a/internal/controller/admin/admin_v1_tenant_available_models_preview.go b/internal/controller/admin/admin_v1_tenant_available_models_preview.go new file mode 100644 index 0000000..b711e71 --- /dev/null +++ b/internal/controller/admin/admin_v1_tenant_available_models_preview.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) TenantAvailableModelsPreview(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (res *v1.TenantAvailableModelsPreviewRes, err error) { + return service.Admin().ListTenantAvailableModels(ctx, req) +} diff --git a/internal/logic/admin/tenant_model.go b/internal/logic/admin/tenant_model.go index d8422c5..df3671f 100644 --- a/internal/logic/admin/tenant_model.go +++ b/internal/logic/admin/tenant_model.go @@ -9,6 +9,7 @@ import ( "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" + tenantLogic "github.com/qianfree/team-api/internal/logic/common" do "github.com/qianfree/team-api/internal/model/do" ) @@ -228,3 +229,26 @@ func (s *sAdmin) DeleteTenantModel(ctx context.Context, req *v1.TenantModelDelet return nil, nil } + +// ListTenantAvailableModels 预览租户实际可用的所有模型(显式分配 + 分组来源,去重) +func (s *sAdmin) ListTenantAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (*v1.TenantAvailableModelsPreviewRes, error) { + models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "") + if err != nil { + return nil, err + } + + list := make([]v1.TenantAvailableModelPreview, 0, len(models)) + for _, m := range models { + list = append(list, v1.TenantAvailableModelPreview{ + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContextTokens: m.MaxContextTokens, + MaxOutputTokens: m.MaxOutputTokens, + Description: m.Description, + Source: m.Source, + }) + } + + return &v1.TenantAvailableModelsPreviewRes{List: list}, nil +} diff --git a/internal/logic/common/tenant_model.go b/internal/logic/common/tenant_model.go new file mode 100644 index 0000000..239453e --- /dev/null +++ b/internal/logic/common/tenant_model.go @@ -0,0 +1,138 @@ +package common + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + "github.com/qianfree/team-api/internal/dao" +) + +// AvailableModelItem 租户可用模型基本信息(公共结构体,不含价格) +type AvailableModelItem struct { + 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"` + Source string `json:"source"` // "explicit" 或 "group" +} + +// GetTenantAvailableModels 计算租户实际可用的所有模型(显式分配 + 分组来源,去重) +// 返回模型基本信息列表,不含价格。可被管理后台和租户控制台共用。 +func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, search string) ([]AvailableModelItem, error) { + // 阶段1:获取显式分配的模型 + var explicitModels []struct { + 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"` + } + + explicitQuery := dao.MdlTenantModels.Ctx(ctx). + LeftJoin("mdl_models m ON mdl_tenant_models.model_id = m.id"). + Where("mdl_tenant_models.tenant_id", tenantID). + Where("mdl_tenant_models.enabled", true). + Where("m.status", "active") + + if category != "" { + explicitQuery = explicitQuery.Where("m.category", category) + } + if search != "" { + explicitQuery = explicitQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%") + } + + err := explicitQuery. + Fields("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"). + OrderAsc("m.category"). + OrderAsc("m.model_id"). + Scan(&explicitModels) + if err != nil { + return nil, err + } + + result := make([]AvailableModelItem, 0, len(explicitModels)) + explicitModelIDs := make(map[int64]bool, len(explicitModels)) + for _, r := range explicitModels { + explicitModelIDs[r.ModelDBID] = true + result = append(result, AvailableModelItem{ + ModelDBID: r.ModelDBID, + ModelId: r.ModelId, + ModelName: r.ModelName, + Category: r.Category, + MaxContextTokens: r.MaxContextTokens, + MaxOutputTokens: r.MaxOutputTokens, + Description: r.Description, + Tags: r.Tags, + Capabilities: r.Capabilities, + Source: "explicit", + }) + } + + // 阶段2:获取分组来源的模型(排除已在显式分配中的) + var groupModels []struct { + 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"` + } + + groupQuery := g.DB().Model("mdl_group_models gm").Ctx(ctx). + InnerJoin("mdl_model_groups g ON gm.group_id = g.id"). + InnerJoin("mdl_models m ON gm.model_id = m.id"). + InnerJoin("mdl_tenant_groups tg ON tg.group_id = g.id"). + Where("tg.tenant_id", tenantID). + Where("g.status", "active"). + Where("m.status", "active") + + if len(explicitModelIDs) > 0 { + ids := make([]int64, 0, len(explicitModelIDs)) + for id := range explicitModelIDs { + ids = append(ids, id) + } + groupQuery = groupQuery.WhereNotIn("m.id", ids) + } + + if category != "" { + groupQuery = groupQuery.Where("m.category", category) + } + if search != "" { + groupQuery = groupQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%") + } + + _ = groupQuery. + Fields("DISTINCT m.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"). + OrderAsc("m.category"). + OrderAsc("m.model_id"). + Scan(&groupModels) + + for _, gm := range groupModels { + result = append(result, AvailableModelItem{ + ModelDBID: gm.ModelDBID, + ModelId: gm.ModelId, + ModelName: gm.ModelName, + Category: gm.Category, + MaxContextTokens: gm.MaxContextTokens, + MaxOutputTokens: gm.MaxOutputTokens, + Description: gm.Description, + Tags: gm.Tags, + Capabilities: gm.Capabilities, + Source: "group", + }) + } + + return result, nil +} diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index 30f17ec..947ac44 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -7,6 +7,7 @@ import ( "github.com/gogf/gf/v2/frame/g" v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/dao" + lcommon "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" ) @@ -22,31 +23,19 @@ type pricingTierRow struct { func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsReq) (*v1.TenantAvailableModelsRes, error) { tenantID := middleware.GetTenantID(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") - - if req.Category != "" { - query = query.Where("m.category", req.Category) - } - if req.Search != "" { - query = query.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") + models, err := lcommon.GetTenantAvailableModels(ctx, tenantID, req.Category, req.Search) + if err != nil { + return nil, err } - var results []struct { - ID int64 `json:"id"` + if len(models) == 0 { + return &v1.TenantAvailableModelsRes{List: nil}, nil + } + + // 查询显式分配模型的完整价格信息 + var priceResults []struct { 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"` + ID int64 `json:"id"` BillingMode *string `json:"billing_mode"` PerRequestPrice *float64 `json:"per_request_price"` DiscountRatio *float64 `json:"discount_ratio"` @@ -63,25 +52,110 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab CustomPricingTiers string `json:"custom_pricing_tiers"` } - err := query. - 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) - if err != nil { - 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) + explicitDBIDs := make([]int64, 0) + for _, m := range models { + if m.Source == "explicit" { + explicitDBIDs = append(explicitDBIDs, m.ModelDBID) + } + } + + if len(explicitDBIDs) > 0 { + _ = dao.MdlTenantModels.Ctx(ctx). + 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). + WhereIn("mdl_tenant_models.model_id", explicitDBIDs). + Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, 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"). + Scan(&priceResults) + } + + // 构建显式模型价格映射 + type priceInfo struct { + ID int64 + BillingMode *string + PerRequestPrice *float64 + DiscountRatio *float64 + MaxConcurrency *int + BaseInputPrice float64 + BaseOutputPrice float64 + BaseCacheReadPrice float64 + BaseCacheCreationPrice float64 + BaseBillingMode string + CustomInputPrice *float64 + CustomOutputPrice *float64 + CustomCacheReadPrice *float64 + CustomCacheCreationPrice *float64 + CustomPricingTiers string + } + priceMap := make(map[int64]*priceInfo, len(priceResults)) + for _, r := range priceResults { + priceMap[r.ModelDBID] = &priceInfo{ + ID: r.ID, + BillingMode: r.BillingMode, + PerRequestPrice: r.PerRequestPrice, + DiscountRatio: r.DiscountRatio, + MaxConcurrency: r.MaxConcurrency, + BaseInputPrice: r.BaseInputPrice, + BaseOutputPrice: r.BaseOutputPrice, + BaseCacheReadPrice: r.BaseCacheReadPrice, + BaseCacheCreationPrice: r.BaseCacheCreationPrice, + BaseBillingMode: r.BaseBillingMode, + CustomInputPrice: r.CustomInputPrice, + CustomOutputPrice: r.CustomOutputPrice, + CustomCacheReadPrice: r.CustomCacheReadPrice, + CustomCacheCreationPrice: r.CustomCacheCreationPrice, + CustomPricingTiers: r.CustomPricingTiers, + } + } + + // 批量查询分组模型的 base 价格 + groupDBIDs := make([]int64, 0) + for _, m := range models { + if m.Source == "group" { + groupDBIDs = append(groupDBIDs, m.ModelDBID) + } + } + + type groupPriceInfo struct { + BaseBillingMode string `json:"base_billing_mode"` + BaseInputPrice float64 `json:"base_input_price"` + BaseOutputPrice float64 `json:"base_output_price"` + } + groupPriceMap := make(map[int64]*groupPriceInfo) + if len(groupDBIDs) > 0 { + var groupPrices []struct { + ModelID int64 `json:"model_id"` + BaseBillingMode string `json:"base_billing_mode"` + BaseInputPrice float64 `json:"base_input_price"` + BaseOutputPrice float64 `json:"base_output_price"` + } + _ = dao.MdlPricing.Ctx(ctx). + WhereIn("model_id", groupDBIDs). + Where("min_tokens", 0). + Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price"). + Scan(&groupPrices) + + for _, gp := range groupPrices { + groupPriceMap[gp.ModelID] = &groupPriceInfo{ + BaseBillingMode: gp.BaseBillingMode, + BaseInputPrice: gp.BaseInputPrice, + BaseOutputPrice: gp.BaseOutputPrice, + } + } + } + + // 收集需要查询阶梯定价的模型 + tieredModelDBIDs := make([]int64, 0) + for _, m := range models { + if m.Source == "explicit" { + if pi, ok := priceMap[m.ModelDBID]; ok { + effectiveBillingMode := resolveBillingMode(pi.BillingMode, pi.BaseBillingMode) + if effectiveBillingMode == "tiered" && pi.CustomPricingTiers == "" { + tieredModelDBIDs = append(tieredModelDBIDs, m.ModelDBID) + } + } } } - // 批量查询 base 阶梯定价(min_tokens > 0 的阶梯) baseTiersMap := make(map[int64][]v1.PricingTierItem) if len(tieredModelDBIDs) > 0 { var baseTiers []struct { @@ -91,7 +165,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab InputPrice float64 `json:"input_price"` OutputPrice float64 `json:"output_price"` } - err = dao.MdlPricing.Ctx(ctx). + _ = dao.MdlPricing.Ctx(ctx). WhereIn("model_id", tieredModelDBIDs). Where("billing_mode", "tiered"). Where("min_tokens > 0"). @@ -99,163 +173,115 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab 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, - }) - } + + 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 { - effectiveBillingMode := resolveBillingMode(r.BillingMode, r.BaseBillingMode) + // 组装最终列表 + list := make([]v1.TenantAvailableModelItem, 0, len(models)) + for _, m := range models { + if m.Source == "explicit" { + pi, ok := priceMap[m.ModelDBID] + if !ok { + continue + } - inputPrice := effectivePrice(r.CustomInputPrice, r.BaseInputPrice) - outputPrice := effectivePrice(r.CustomOutputPrice, r.BaseOutputPrice) - cacheReadPrice := effectivePrice(r.CustomCacheReadPrice, r.BaseCacheReadPrice) - cacheCreationPrice := effectivePrice(r.CustomCacheCreationPrice, r.BaseCacheCreationPrice) + effectiveBillingMode := resolveBillingMode(pi.BillingMode, pi.BaseBillingMode) + inputPrice := effectivePrice(pi.CustomInputPrice, pi.BaseInputPrice) + outputPrice := effectivePrice(pi.CustomOutputPrice, pi.BaseOutputPrice) + cacheReadPrice := effectivePrice(pi.CustomCacheReadPrice, pi.BaseCacheReadPrice) + cacheCreationPrice := effectivePrice(pi.CustomCacheCreationPrice, pi.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, - } + item := v1.TenantAvailableModelItem{ + ID: pi.ID, + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContext: m.MaxContextTokens, + MaxOutput: m.MaxOutputTokens, + Description: m.Description, + Tags: m.Tags, + Capabilities: m.Capabilities, + BillingMode: &effectiveBillingMode, + PerRequestPrice: pi.PerRequestPrice, + DiscountRatio: pi.DiscountRatio, + MaxConcurrency: pi.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 { + if effectiveBillingMode == "tiered" { + var tiers []v1.PricingTierItem + if pi.CustomPricingTiers != "" && pi.CustomPricingTiers != "null" && pi.CustomPricingTiers != "[]" { + var raw []pricingTierRow + if json.Unmarshal([]byte(pi.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, + }) + } + } + } + if len(tiers) == 0 { + if pi.BaseInputPrice > 0 || pi.BaseOutputPrice > 0 { tiers = append(tiers, v1.PricingTierItem{ - MinTokens: t.MinTokens, - MaxTokens: t.MaxTokens, - InputPrice: t.InputPrice, - OutputPrice: t.OutputPrice, + MinTokens: 0, + MaxTokens: nil, + InputPrice: pi.BaseInputPrice, + OutputPrice: pi.BaseOutputPrice, }) } - } - } - - // 使用 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 + if rest, ok := baseTiersMap[m.ModelDBID]; ok { + if len(tiers) > 0 && len(rest) > 0 { + tiers[0].MaxTokens = &rest[0].MinTokens + } + tiers = append(tiers, rest...) } - tiers = append(tiers, rest...) } + item.PricingTiers = tiers } - item.PricingTiers = tiers + list = append(list, item) + } else { + // group 来源的模型:使用 base 价格 + billingMode := "token" + var inputPrice, outputPrice *float64 + if gp, ok := groupPriceMap[m.ModelDBID]; ok { + billingMode = gp.BaseBillingMode + if billingMode == "" { + billingMode = "token" + } + inputPrice = effectivePrice(nil, gp.BaseInputPrice) + outputPrice = effectivePrice(nil, gp.BaseOutputPrice) + } + + item := v1.TenantAvailableModelItem{ + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContext: m.MaxContextTokens, + MaxOutput: m.MaxOutputTokens, + Description: m.Description, + Tags: m.Tags, + Capabilities: m.Capabilities, + BillingMode: &billingMode, + InputPrice: inputPrice, + OutputPrice: outputPrice, + } + list = append(list, item) } - - list = append(list, item) - } - // 追加分组来源的模型(不在显式分配中的模型) - explicitModelIDs := make(map[int64]bool, len(results)) - for _, r := range results { - explicitModelIDs[r.ModelDBID] = true - } - - var groupModels []struct { - 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"` - BaseBillingMode string `json:"base_billing_mode"` - BaseInputPrice float64 `json:"base_input_price"` - BaseOutputPrice float64 `json:"base_output_price"` - } - - groupQuery := g.DB().Model("mdl_group_models gm").Ctx(ctx). - InnerJoin("mdl_model_groups g ON gm.group_id = g.id"). - InnerJoin("mdl_models m ON gm.model_id = m.id"). - InnerJoin("mdl_tenant_groups tg ON tg.group_id = g.id"). - LeftJoin("mdl_pricing p ON p.model_id = m.id AND p.min_tokens = 0"). - Where("tg.tenant_id", tenantID). - Where("g.status", "active"). - Where("m.status", "active") - - if len(explicitModelIDs) > 0 { - ids := make([]int64, 0, len(explicitModelIDs)) - for id := range explicitModelIDs { - ids = append(ids, id) - } - groupQuery = groupQuery.WhereNotIn("m.id", ids) - } - - if req.Category != "" { - groupQuery = groupQuery.Where("m.category", req.Category) - } - if req.Search != "" { - groupQuery = groupQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") - } - - _ = groupQuery. - Fields("DISTINCT m.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, p.billing_mode AS base_billing_mode, p.input_price AS base_input_price, p.output_price AS base_output_price"). - OrderAsc("m.category"). - OrderAsc("m.model_id"). - Scan(&groupModels) - - for _, gm := range groupModels { - billingMode := gm.BaseBillingMode - if billingMode == "" { - billingMode = "token" - } - - item := v1.TenantAvailableModelItem{ - ModelId: gm.ModelId, - ModelName: gm.ModelName, - Category: gm.Category, - MaxContext: gm.MaxContextTokens, - MaxOutput: gm.MaxOutputTokens, - Description: gm.Description, - Tags: gm.Tags, - Capabilities: gm.Capabilities, - BillingMode: &billingMode, - InputPrice: &gm.BaseInputPrice, - OutputPrice: &gm.BaseOutputPrice, - } - list = append(list, item) } // 按成员模型范围过滤 @@ -276,7 +302,6 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab allowed := make(map[string]bool, len(memberScopes)) for _, s := range memberScopes { if s.ModelID == -1 { - // 哨兵:禁止所有模型 return &v1.TenantAvailableModelsRes{List: nil}, nil } if s.ModelName != "" { diff --git a/internal/service/admin.go b/internal/service/admin.go index 1adc420..6317b5c 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -395,6 +395,8 @@ type ( UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdateReq) (*v1.TenantModelUpdateRes, error) // DeleteTenantModel 移除租户模型分配 DeleteTenantModel(ctx context.Context, req *v1.TenantModelDeleteReq) (*v1.TenantModelDeleteRes, error) + // ListTenantAvailableModels 预览租户实际可用的所有模型(显式分配 + 分组来源,去重) + ListTenantAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (*v1.TenantAvailableModelsPreviewRes, error) // ListAllTickets 获取全部工单列表(管理后台) ListAllTickets(ctx context.Context, req *v1.TicketListReq) (*v1.TicketListRes, error) // GetTicketAdmin 获取工单详情(管理后台,含回复) diff --git a/web/admin/src/views/tenant/TenantDetailPage.vue b/web/admin/src/views/tenant/TenantDetailPage.vue index e20e1b6..b961085 100644 --- a/web/admin/src/views/tenant/TenantDetailPage.vue +++ b/web/admin/src/views/tenant/TenantDetailPage.vue @@ -356,6 +356,46 @@ async function handleSaveGroups(done: () => void) { } } +// === Available Models Preview === +const showPreviewModal = ref(false) +const previewLoading = ref(false) +const previewData = ref([]) + +const previewColumns: TableColumnData[] = [ + { title: '模型标识', dataIndex: 'model_id', width: 180, ellipsis: true }, + { title: '显示名', dataIndex: 'model_name', width: 150, ellipsis: true }, + { title: '分类', dataIndex: 'category', width: 80 }, + { title: '上下文', dataIndex: 'max_context_tokens', width: 100, + render({ record }) { return record.max_context_tokens ? record.max_context_tokens.toLocaleString() : '-' }, + }, + { title: '最大输出', dataIndex: 'max_output_tokens', width: 100, + render({ record }) { return record.max_output_tokens ? record.max_output_tokens.toLocaleString() : '-' }, + }, + { title: '来源', dataIndex: 'source', width: 80, + render({ record }) { + const color = record.source === 'explicit' ? 'arcoblue' : 'green' + const label = record.source === 'explicit' ? '独立分配' : '分组' + return h(Tag, { color, size: 'small' }, () => label) + }, + }, +] + +async function fetchAvailableModels() { + previewLoading.value = true + try { + const res: any = await request.get(`/admin/tenants/${tenantId}/available-models`) + previewData.value = res.data?.data?.list || res.data?.list || [] + } catch { + } finally { + previewLoading.value = false + } +} + +function openPreviewModal() { + showPreviewModal.value = true + fetchAvailableModels() +} + // Load data when switching to models tab function onTabChange(key: string) { if (key === 'models') { @@ -533,6 +573,9 @@ onMounted(() => {