mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 00:07:33 +00:00
模型广场:按计费阶梯单价表展示长上下文档位
- 新建 ModelPlazaService(持计费服务与定价解析器)承接广场聚合, token 模型的单价与档位全部取自 ResolveContextPricingSchedule, 渠道选择与计费同源;图片/按次模型沿用原档位合成 - 官方参考价改走计费目录(LiteLLM → 内置兜底 → 模型策略),带官方阶梯 - DTO 增加 long_context_pricing_enabled / long_context_basis / official_pricing.intervals - 前端实付与官方三列按档分行(标签只在首列,其余列按行对齐), 缓存列按档展示写/读价,边际计价以徽章与 tooltip 标注, 分组关闭阶梯时在头部说明
This commit is contained in:
@@ -304,7 +304,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
|
||||
modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService)
|
||||
modelPlazaService := service.NewModelPlazaService(channelRepository, groupRepository, pricingService, billingService, modelPricingResolver)
|
||||
modelPlazaHandler := handler.NewModelPlazaHandler(modelPlazaService, apiKeyService, settingService)
|
||||
imageTaskStore := repository.NewImageTaskStore(redisClient)
|
||||
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
|
||||
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
|
||||
|
||||
@@ -284,13 +284,13 @@ func toUserSupportedModels(
|
||||
return out
|
||||
}
|
||||
|
||||
// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
|
||||
func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
if p == nil {
|
||||
// toUserPricingIntervals 将定价区间转换为用户 DTO 白名单形态;nil 入参返回 nil(JSON omitempty 可省略)。
|
||||
func toUserPricingIntervals(src []service.PricingInterval) []userPricingIntervalDTO {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(p.Intervals))
|
||||
for _, iv := range p.Intervals {
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(src))
|
||||
for _, iv := range src {
|
||||
intervals = append(intervals, userPricingIntervalDTO{
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
@@ -302,6 +302,19 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
}
|
||||
|
||||
// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
|
||||
func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
intervals := toUserPricingIntervals(p.Intervals)
|
||||
if intervals == nil {
|
||||
// 用户侧定价的 intervals 固定输出数组(空配置为 []),保持既有契约。
|
||||
intervals = []userPricingIntervalDTO{}
|
||||
}
|
||||
billingMode := string(p.BillingMode)
|
||||
if billingMode == "" {
|
||||
billingMode = string(service.BillingModeToken)
|
||||
|
||||
@@ -17,39 +17,43 @@ import (
|
||||
// - 匿名:仅非专属分组(订阅型照常展示);
|
||||
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
|
||||
type ModelPlazaHandler struct {
|
||||
channelService *service.ChannelService
|
||||
plazaService *service.ModelPlazaService
|
||||
apiKeyService *service.APIKeyService
|
||||
settingService *service.SettingService
|
||||
}
|
||||
|
||||
// NewModelPlazaHandler 创建模型广场 handler。
|
||||
func NewModelPlazaHandler(
|
||||
channelService *service.ChannelService,
|
||||
plazaService *service.ModelPlazaService,
|
||||
apiKeyService *service.APIKeyService,
|
||||
settingService *service.SettingService,
|
||||
) *ModelPlazaHandler {
|
||||
return &ModelPlazaHandler{
|
||||
channelService: channelService,
|
||||
plazaService: plazaService,
|
||||
apiKeyService: apiKeyService,
|
||||
settingService: settingService,
|
||||
}
|
||||
}
|
||||
|
||||
// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。
|
||||
// modelPlazaOfficialPricing 官方参考价(USD per token,与计费目录同源)。
|
||||
type modelPlazaOfficialPricing struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
// Intervals 官方长上下文阶梯,仅多档模型给出。
|
||||
Intervals []userPricingIntervalDTO `json:"intervals,omitempty"`
|
||||
}
|
||||
|
||||
// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。
|
||||
// modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。
|
||||
type modelPlazaModel struct {
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Pricing *userSupportedModelPricing `json:"pricing"`
|
||||
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
|
||||
// LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。
|
||||
LongContextBasis string `json:"long_context_basis,omitempty"`
|
||||
}
|
||||
|
||||
// modelPlazaGroup 广场分组条目(白名单字段)。
|
||||
@@ -68,9 +72,11 @@ type modelPlazaGroup struct {
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
// 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier,
|
||||
// 不取分组/用户专属倍率。
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
||||
// 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。
|
||||
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
}
|
||||
|
||||
// modelPlazaResponse 广场页响应。
|
||||
@@ -98,7 +104,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := h.channelService.ListPlazaGroups(c.Request.Context())
|
||||
groups, err := h.plazaService.ListGroups(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -161,27 +167,29 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
|
||||
for i := range g.Models {
|
||||
m := &g.Models[i]
|
||||
models = append(models, modelPlazaModel{
|
||||
Name: m.Name,
|
||||
Platform: m.Platform,
|
||||
Pricing: toUserPricing(m.Pricing),
|
||||
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
|
||||
Name: m.Name,
|
||||
Platform: m.Platform,
|
||||
Pricing: toUserPricing(m.Pricing),
|
||||
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
|
||||
LongContextBasis: string(m.LongContextBasis),
|
||||
})
|
||||
}
|
||||
dto := modelPlazaGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
Models: models,
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
LongContextPricingEnabled: g.LongContextPricingEnabled,
|
||||
Models: models,
|
||||
}
|
||||
if rate, ok := userRates[g.ID]; ok {
|
||||
dto.UserRateMultiplier = &rate
|
||||
@@ -200,5 +208,6 @@ func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOff
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
Intervals: toUserPricingIntervals(p.Intervals),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
"id", "name", "description", "platform", "subscription_type",
|
||||
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
|
||||
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
|
||||
"image_rate_independent", "image_rate_multiplier",
|
||||
"image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled",
|
||||
} {
|
||||
_, exists := decoded[key]
|
||||
require.Truef(t, exists, "plaza group DTO must expose %q", key)
|
||||
@@ -109,6 +109,10 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
require.Contains(t, official, "cache_read_price")
|
||||
_, has1h := official["cache_write_1h_price"]
|
||||
require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty")
|
||||
_, hasOfficialIntervals := official["intervals"]
|
||||
require.False(t, hasOfficialIntervals, "官方无阶梯时 intervals 应 omitempty")
|
||||
_, hasBasis := model["long_context_basis"]
|
||||
require.False(t, hasBasis, "单档模型不输出 long_context_basis")
|
||||
|
||||
// 无专属倍率:user_rate_multiplier 整个字段省略
|
||||
dtoNoRate := toModelPlazaGroupDTO(&g, nil)
|
||||
@@ -124,4 +128,54 @@ func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) {
|
||||
require.Nil(t, toModelPlazaOfficialPricing(nil))
|
||||
}
|
||||
|
||||
func TestToModelPlazaGroupDTO_LongContextTiersAndBasis(t *testing.T) {
|
||||
maxTokens := 272000
|
||||
g := service.PlazaGroup{
|
||||
ID: 3, Name: "ladder", Platform: "openai", SubscriptionType: "standard", RateMultiplier: 1,
|
||||
LongContextPricingEnabled: true,
|
||||
Models: []service.PlazaModel{{
|
||||
Name: "gpt-5.4",
|
||||
Platform: "openai",
|
||||
Pricing: &service.ChannelModelPricing{
|
||||
BillingMode: service.BillingModeToken,
|
||||
InputPrice: testPtr(2.5e-6),
|
||||
Intervals: []service.PricingInterval{
|
||||
{MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)},
|
||||
{MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)},
|
||||
},
|
||||
},
|
||||
OfficialPricing: &service.PlazaOfficialPricing{
|
||||
InputPrice: testPtr(2.5e-6),
|
||||
Intervals: []service.PricingInterval{
|
||||
{MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)},
|
||||
{MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)},
|
||||
},
|
||||
},
|
||||
LongContextBasis: service.ContextPricingBasisWholeRequest,
|
||||
}},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil))
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
require.Equal(t, true, decoded["long_context_pricing_enabled"])
|
||||
|
||||
model := decoded["models"].([]any)[0].(map[string]any)
|
||||
require.Equal(t, "whole_request", model["long_context_basis"])
|
||||
|
||||
pricing := model["pricing"].(map[string]any)
|
||||
paidTiers := pricing["intervals"].([]any)
|
||||
require.Len(t, paidTiers, 2)
|
||||
require.Equal(t, ">272K", paidTiers[1].(map[string]any)["tier_label"])
|
||||
|
||||
official := model["official_pricing"].(map[string]any)
|
||||
officialTiers := official["intervals"].([]any)
|
||||
require.Len(t, officialTiers, 2)
|
||||
first := officialTiers[0].(map[string]any)
|
||||
require.Equal(t, "≤272K", first["tier_label"])
|
||||
require.InDelta(t, 272000, first["max_tokens"].(float64), 0)
|
||||
require.Contains(t, first, "cache_write_price", "区间 DTO 字段齐全(nil 输出 null)")
|
||||
}
|
||||
|
||||
func testPtr(v float64) *float64 { return &v }
|
||||
|
||||
@@ -90,7 +90,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
|
||||
ch.normalizeBillingModelSource()
|
||||
|
||||
supported := ch.SupportedModels()
|
||||
s.fillGlobalPricingFallback(supported)
|
||||
fillGlobalPricingFallback(s.pricingService, supported)
|
||||
|
||||
out = append(out, AvailableChannel{
|
||||
ID: ch.ID,
|
||||
@@ -117,16 +117,16 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
|
||||
// 1. Pricing == nil(渠道完全没声明该模型的定价条目)
|
||||
// 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格)
|
||||
//
|
||||
// 当 s.pricingService 为 nil(测试场景),跳过回落。
|
||||
func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) {
|
||||
if s.pricingService == nil {
|
||||
// 当 pricingService 为 nil(测试场景),跳过回落。可用渠道与模型广场共用。
|
||||
func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) {
|
||||
if pricingService == nil {
|
||||
return
|
||||
}
|
||||
for i := range models {
|
||||
if !pricingNeedsFallback(models[i].Pricing) {
|
||||
continue
|
||||
}
|
||||
lp := s.pricingService.GetModelPricing(models[i].Name)
|
||||
lp := pricingService.GetModelPricing(models[i].Name)
|
||||
if lp == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ func TestFillGlobalPricingFallback_NilPricing(t *testing.T) {
|
||||
models := []SupportedModel{
|
||||
{Name: "claude-opus-4-5", Platform: "anthropic"},
|
||||
}
|
||||
svc.fillGlobalPricingFallback(models)
|
||||
fillGlobalPricingFallback(svc.pricingService, models)
|
||||
require.NotNil(t, models[0].Pricing)
|
||||
require.NotNil(t, models[0].Pricing.InputPrice)
|
||||
require.InDelta(t, 5e-6, *models[0].Pricing.InputPrice, 1e-12)
|
||||
@@ -281,7 +281,7 @@ func TestFillGlobalPricingFallback_EmptyPricingFillsFromLiteLLM(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
svc.fillGlobalPricingFallback(models)
|
||||
fillGlobalPricingFallback(svc.pricingService, models)
|
||||
require.NotNil(t, models[0].Pricing)
|
||||
require.Equal(t, BillingModeImage, models[0].Pricing.BillingMode)
|
||||
require.NotNil(t, models[0].Pricing.ImageOutputPrice)
|
||||
@@ -302,7 +302,7 @@ func TestFillGlobalPricingFallback_KeepsExistingPrice(t *testing.T) {
|
||||
models := []SupportedModel{
|
||||
{Name: "served-model", Platform: "anthropic", Pricing: existing},
|
||||
}
|
||||
svc.fillGlobalPricingFallback(models)
|
||||
fillGlobalPricingFallback(svc.pricingService, models)
|
||||
require.Same(t, existing, models[0].Pricing)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlazaOfficialPricing 模型广场展示用的 LiteLLM 官方参考价(USD per token)。
|
||||
// 字段为 nil 表示官方数据中该项缺失(0 视为未配置)。
|
||||
type PlazaOfficialPricing struct {
|
||||
InputPrice *float64
|
||||
OutputPrice *float64
|
||||
CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation)
|
||||
CacheWrite1hPrice *float64 // 1h 缓存写入(LiteLLM cache_creation_above_1hr)
|
||||
CacheReadPrice *float64
|
||||
}
|
||||
|
||||
// PlazaModel 模型广场中单个模型条目:渠道定价 + 官方参考价。
|
||||
type PlazaModel struct {
|
||||
Name string
|
||||
Platform string
|
||||
Pricing *ChannelModelPricing
|
||||
OfficialPricing *PlazaOfficialPricing
|
||||
}
|
||||
|
||||
// PlazaGroup 模型广场中以分组为顶层的条目。
|
||||
//
|
||||
// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
|
||||
// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的
|
||||
// 具体平台),与「可用渠道」页口径一致。
|
||||
type PlazaGroup struct {
|
||||
ID int64
|
||||
Name string
|
||||
Description string
|
||||
Platform string
|
||||
SubscriptionType string
|
||||
RateMultiplier float64
|
||||
PeakRateEnabled bool
|
||||
PeakStart string
|
||||
PeakEnd string
|
||||
PeakRateMultiplier float64
|
||||
IsExclusive bool
|
||||
// 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付
|
||||
// = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
|
||||
ImageRateIndependent bool
|
||||
ImageRateMultiplier float64
|
||||
Models []PlazaModel
|
||||
}
|
||||
|
||||
// ListPlazaGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。
|
||||
//
|
||||
// 聚合口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、
|
||||
// 平台隔离),仅把顶层从渠道换成分组:
|
||||
// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定;
|
||||
// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换;
|
||||
// - 图片计费模型的档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价,
|
||||
// 见 plazaImageDisplayPricing);
|
||||
// - 每个模型附带 LiteLLM 官方参考价(查不到为 nil);
|
||||
// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称),
|
||||
// 组内模型按名称排序。
|
||||
//
|
||||
// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。
|
||||
func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, error) {
|
||||
channels, err := s.repo.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list channels: %w", err)
|
||||
}
|
||||
groups, err := s.groupRepo.ListActive(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list active groups: %w", err)
|
||||
}
|
||||
|
||||
sort.SliceStable(channels, func(i, j int) bool {
|
||||
return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name)
|
||||
})
|
||||
|
||||
byGroup := make(map[int64]*PlazaGroup, len(groups))
|
||||
groupEnt := make(map[int64]*Group, len(groups))
|
||||
order := make([]int64, 0, len(groups))
|
||||
for i := range groups {
|
||||
g := &groups[i]
|
||||
byGroup[g.ID] = &PlazaGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
}
|
||||
groupEnt[g.ID] = g
|
||||
order = append(order, g.ID)
|
||||
}
|
||||
|
||||
type modelKey struct {
|
||||
platform string
|
||||
name string
|
||||
}
|
||||
// modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models
|
||||
modelIdx := make(map[int64]map[modelKey]int, len(groups))
|
||||
for i := range channels {
|
||||
ch := &channels[i]
|
||||
if ch.Status != StatusActive {
|
||||
continue
|
||||
}
|
||||
ch.normalizeBillingModelSource()
|
||||
supported := ch.SupportedModels()
|
||||
s.fillGlobalPricingFallback(supported)
|
||||
|
||||
for _, gid := range ch.GroupIDs {
|
||||
pg, ok := byGroup[gid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx := modelIdx[gid]
|
||||
if idx == nil {
|
||||
idx = make(map[modelKey]int, len(supported))
|
||||
modelIdx[gid] = idx
|
||||
}
|
||||
for j := range supported {
|
||||
m := supported[j]
|
||||
if pg.Platform == PlatformComposite {
|
||||
if !isConcreteRequestPlatform(m.Platform) {
|
||||
continue
|
||||
}
|
||||
} else if m.Platform != pg.Platform {
|
||||
continue
|
||||
}
|
||||
pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid])
|
||||
key := modelKey{platform: m.Platform, name: m.Name}
|
||||
if at, seen := idx[key]; seen {
|
||||
// 先见者胜;仅当已存条目无定价而新条目有定价时升级。
|
||||
if pg.Models[at].Pricing == nil && pricing != nil {
|
||||
pg.Models[at].Pricing = pricing
|
||||
}
|
||||
continue
|
||||
}
|
||||
idx[key] = len(pg.Models)
|
||||
pg.Models = append(pg.Models, PlazaModel{
|
||||
Name: m.Name,
|
||||
Platform: m.Platform,
|
||||
Pricing: pricing,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
officialMemo := make(map[string]*PlazaOfficialPricing)
|
||||
out := make([]PlazaGroup, 0, len(order))
|
||||
for _, gid := range order {
|
||||
pg := byGroup[gid]
|
||||
if len(pg.Models) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(pg.Models, func(i, j int) bool {
|
||||
if pg.Models[i].Name != pg.Models[j].Name {
|
||||
return pg.Models[i].Name < pg.Models[j].Name
|
||||
}
|
||||
return pg.Models[i].Platform < pg.Models[j].Platform
|
||||
})
|
||||
for j := range pg.Models {
|
||||
pg.Models[j].OfficialPricing = s.lookupOfficialPricing(pg.Models[j].Name, officialMemo)
|
||||
}
|
||||
out = append(out, *pg)
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].RateMultiplier != out[j].RateMultiplier {
|
||||
return out[i].RateMultiplier < out[j].RateMultiplier
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致:
|
||||
// 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。
|
||||
// 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参
|
||||
// (渠道定价指针指向缓存共享数据)。
|
||||
func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing {
|
||||
if p == nil || g == nil || p.BillingMode != BillingModeImage {
|
||||
return p
|
||||
}
|
||||
if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil {
|
||||
return p
|
||||
}
|
||||
channelTierPrice := func(label string) *float64 {
|
||||
for i := range p.Intervals {
|
||||
if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil {
|
||||
return p.Intervals[i].PerRequestPrice
|
||||
}
|
||||
}
|
||||
return p.PerRequestPrice
|
||||
}
|
||||
tiers := []struct {
|
||||
label string
|
||||
groupPrice *float64
|
||||
}{
|
||||
{"1K", g.ImagePrice1K},
|
||||
{"2K", g.ImagePrice2K},
|
||||
{"4K", g.ImagePrice4K},
|
||||
}
|
||||
clone := *p
|
||||
clone.Intervals = make([]PricingInterval, 0, len(tiers))
|
||||
for i, t := range tiers {
|
||||
price := t.groupPrice
|
||||
if price == nil {
|
||||
price = channelTierPrice(t.label)
|
||||
}
|
||||
if price == nil {
|
||||
continue
|
||||
}
|
||||
v := *price
|
||||
clone.Intervals = append(clone.Intervals, PricingInterval{
|
||||
TierLabel: t.label,
|
||||
PerRequestPrice: &v,
|
||||
SortOrder: i,
|
||||
})
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// lookupOfficialPricing 查询模型的 LiteLLM 官方参考价,带 memo 避免同名模型重复转换。
|
||||
// pricingService 为 nil(测试场景)或查不到时返回 nil。
|
||||
func (s *ChannelService) lookupOfficialPricing(modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing {
|
||||
if s.pricingService == nil {
|
||||
return nil
|
||||
}
|
||||
if cached, ok := memo[modelName]; ok {
|
||||
return cached
|
||||
}
|
||||
var result *PlazaOfficialPricing
|
||||
if lp := s.pricingService.GetModelPricing(modelName); lp != nil && !lp.TokenPricingAbsent {
|
||||
result = &PlazaOfficialPricing{
|
||||
InputPrice: nonZeroPtr(lp.InputCostPerToken),
|
||||
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
|
||||
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
|
||||
CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr),
|
||||
CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
|
||||
}
|
||||
if result.InputPrice == nil && result.OutputPrice == nil &&
|
||||
result.CacheWritePrice == nil && result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil {
|
||||
result = nil
|
||||
}
|
||||
}
|
||||
memo[modelName] = result
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlazaOfficialPricing 模型广场展示用的官方参考价(USD per token),与计费同源:
|
||||
// LiteLLM → 内置兜底价卡 → 模型策略。字段为 nil 表示该项缺失(0 视为未配置)。
|
||||
type PlazaOfficialPricing struct {
|
||||
InputPrice *float64
|
||||
OutputPrice *float64
|
||||
CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation)
|
||||
CacheWrite1hPrice *float64 // 1h 缓存写入,仅计费会区分 5m/1h 时给出
|
||||
CacheReadPrice *float64
|
||||
// Intervals 官方长上下文阶梯(多档时给出),不受分组开关影响。
|
||||
Intervals []PricingInterval
|
||||
}
|
||||
|
||||
// PlazaModel 模型广场中单个模型条目:按实收口径合成的展示定价 + 官方参考价。
|
||||
type PlazaModel struct {
|
||||
Name string
|
||||
Platform string
|
||||
Pricing *ChannelModelPricing
|
||||
OfficialPricing *PlazaOfficialPricing
|
||||
// LongContextBasis 多档时的计价基准(整单 / 仅超出部分),单档为空。
|
||||
LongContextBasis ContextPricingBasis
|
||||
}
|
||||
|
||||
// PlazaGroup 模型广场中以分组为顶层的条目。
|
||||
//
|
||||
// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
|
||||
// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的
|
||||
// 具体平台),与「可用渠道」页口径一致。
|
||||
type PlazaGroup struct {
|
||||
ID int64
|
||||
Name string
|
||||
Description string
|
||||
Platform string
|
||||
SubscriptionType string
|
||||
RateMultiplier float64
|
||||
PeakRateEnabled bool
|
||||
PeakStart string
|
||||
PeakEnd string
|
||||
PeakRateMultiplier float64
|
||||
IsExclusive bool
|
||||
// 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付
|
||||
// = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
|
||||
ImageRateIndependent bool
|
||||
ImageRateMultiplier float64
|
||||
// LongContextPricingEnabled 分组是否按上下文长度应用阶梯价;关闭时模型展示的是最低档。
|
||||
LongContextPricingEnabled bool
|
||||
Models []PlazaModel
|
||||
}
|
||||
|
||||
// ModelPlazaService 聚合模型广场数据。
|
||||
//
|
||||
// 模型枚举来自渠道配置;token 模型的展示单价与阶梯由 BillingService 的阶梯表
|
||||
// 查询给出(与扣费走同一条解析链与计费函数),图片/按次模型沿用渠道/分组档位价。
|
||||
type ModelPlazaService struct {
|
||||
channelRepo ChannelRepository
|
||||
groupRepo GroupRepository
|
||||
pricingService *PricingService
|
||||
billingService *BillingService
|
||||
resolver *ModelPricingResolver
|
||||
}
|
||||
|
||||
// NewModelPlazaService 创建模型广场服务。
|
||||
func NewModelPlazaService(
|
||||
channelRepo ChannelRepository,
|
||||
groupRepo GroupRepository,
|
||||
pricingService *PricingService,
|
||||
billingService *BillingService,
|
||||
resolver *ModelPricingResolver,
|
||||
) *ModelPlazaService {
|
||||
return &ModelPlazaService{
|
||||
channelRepo: channelRepo,
|
||||
groupRepo: groupRepo,
|
||||
pricingService: pricingService,
|
||||
billingService: billingService,
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// ListGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。
|
||||
//
|
||||
// 模型枚举口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、
|
||||
// 平台隔离),仅把顶层从渠道换成分组:
|
||||
// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定;
|
||||
// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换;
|
||||
// - token 模型的单价与阶梯按实收口径合成(见 ResolveContextPricingSchedule),
|
||||
// 图片计费模型的档位价按实收口径合成(见 plazaImageDisplayPricing);
|
||||
// - 每个模型附带官方参考价(查不到为 nil);
|
||||
// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称),
|
||||
// 组内模型按名称排序。
|
||||
//
|
||||
// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。
|
||||
func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error) {
|
||||
channels, err := s.channelRepo.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list channels: %w", err)
|
||||
}
|
||||
groups, err := s.groupRepo.ListActive(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list active groups: %w", err)
|
||||
}
|
||||
|
||||
sort.SliceStable(channels, func(i, j int) bool {
|
||||
return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name)
|
||||
})
|
||||
|
||||
byGroup := make(map[int64]*PlazaGroup, len(groups))
|
||||
groupEnt := make(map[int64]*Group, len(groups))
|
||||
order := make([]int64, 0, len(groups))
|
||||
for i := range groups {
|
||||
g := &groups[i]
|
||||
byGroup[g.ID] = &PlazaGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
LongContextPricingEnabled: g.LongContextPricingEnabled,
|
||||
}
|
||||
groupEnt[g.ID] = g
|
||||
order = append(order, g.ID)
|
||||
}
|
||||
|
||||
type modelKey struct {
|
||||
platform string
|
||||
name string
|
||||
}
|
||||
// modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models
|
||||
modelIdx := make(map[int64]map[modelKey]int, len(groups))
|
||||
for i := range channels {
|
||||
ch := &channels[i]
|
||||
if ch.Status != StatusActive {
|
||||
continue
|
||||
}
|
||||
ch.normalizeBillingModelSource()
|
||||
supported := ch.SupportedModels()
|
||||
fillGlobalPricingFallback(s.pricingService, supported)
|
||||
|
||||
for _, gid := range ch.GroupIDs {
|
||||
pg, ok := byGroup[gid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx := modelIdx[gid]
|
||||
if idx == nil {
|
||||
idx = make(map[modelKey]int, len(supported))
|
||||
modelIdx[gid] = idx
|
||||
}
|
||||
for j := range supported {
|
||||
m := supported[j]
|
||||
if pg.Platform == PlatformComposite {
|
||||
if !isConcreteRequestPlatform(m.Platform) {
|
||||
continue
|
||||
}
|
||||
} else if m.Platform != pg.Platform {
|
||||
continue
|
||||
}
|
||||
key := modelKey{platform: m.Platform, name: m.Name}
|
||||
if at, seen := idx[key]; seen {
|
||||
// 先见者胜;仅当已存条目无定价而新条目有定价时升级。
|
||||
if pg.Models[at].Pricing == nil && m.Pricing != nil {
|
||||
pg.Models[at].Pricing = m.Pricing
|
||||
}
|
||||
continue
|
||||
}
|
||||
idx[key] = len(pg.Models)
|
||||
pg.Models = append(pg.Models, PlazaModel{
|
||||
Name: m.Name,
|
||||
Platform: m.Platform,
|
||||
Pricing: m.Pricing,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
officialMemo := make(map[string]*PlazaOfficialPricing)
|
||||
out := make([]PlazaGroup, 0, len(order))
|
||||
for _, gid := range order {
|
||||
pg := byGroup[gid]
|
||||
if len(pg.Models) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(pg.Models, func(i, j int) bool {
|
||||
if pg.Models[i].Name != pg.Models[j].Name {
|
||||
return pg.Models[i].Name < pg.Models[j].Name
|
||||
}
|
||||
return pg.Models[i].Platform < pg.Models[j].Platform
|
||||
})
|
||||
g := groupEnt[gid]
|
||||
for j := range pg.Models {
|
||||
s.fillDisplayPricing(ctx, &pg.Models[j], g)
|
||||
pg.Models[j].OfficialPricing = s.lookupOfficialPricing(ctx, pg.Models[j].Name, officialMemo)
|
||||
}
|
||||
out = append(out, *pg)
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].RateMultiplier != out[j].RateMultiplier {
|
||||
return out[i].RateMultiplier < out[j].RateMultiplier
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fillDisplayPricing 把模型的展示定价换成实收口径:
|
||||
// token 模型取计费阶梯表(单价与档位均由真实计费函数得出),
|
||||
// 图片/按次模型(或阶梯表不可用时)沿用渠道定价与分组图片档位价。
|
||||
func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaModel, g *Group) {
|
||||
if s.billingService != nil && s.resolver != nil {
|
||||
sched, err := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{
|
||||
Model: m.Name,
|
||||
Group: g,
|
||||
Platform: m.Platform,
|
||||
})
|
||||
if err == nil && sched != nil && len(sched.Tiers) > 0 {
|
||||
m.Pricing = plazaPricingFromSchedule(m.Pricing, sched)
|
||||
if len(sched.Tiers) > 1 {
|
||||
m.LongContextBasis = sched.Basis
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
m.Pricing = plazaImageDisplayPricing(m.Pricing, g)
|
||||
}
|
||||
|
||||
// plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing:
|
||||
// 平价取首档单价,多档时 Intervals 逐档给出绝对单价;图片/按次字段沿用原始定价。
|
||||
func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSchedule) *ChannelModelPricing {
|
||||
out := &ChannelModelPricing{BillingMode: BillingModeToken}
|
||||
if raw != nil {
|
||||
out.ImageInputPrice = raw.ImageInputPrice
|
||||
out.ImageOutputPrice = raw.ImageOutputPrice
|
||||
out.PerRequestPrice = raw.PerRequestPrice
|
||||
}
|
||||
first := sched.Tiers[0]
|
||||
out.InputPrice = first.Input
|
||||
out.OutputPrice = first.Output
|
||||
out.CacheWritePrice = first.CacheWrite
|
||||
out.CacheReadPrice = first.CacheRead
|
||||
if len(sched.Tiers) > 1 {
|
||||
out.Intervals = plazaIntervalsFromTiers(sched.Tiers)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func plazaIntervalsFromTiers(tiers []ContextPricingTier) []PricingInterval {
|
||||
intervals := make([]PricingInterval, 0, len(tiers))
|
||||
for i, t := range tiers {
|
||||
intervals = append(intervals, PricingInterval{
|
||||
MinTokens: t.MinTokens,
|
||||
MaxTokens: t.MaxTokens,
|
||||
TierLabel: t.Label,
|
||||
InputPrice: t.Input,
|
||||
OutputPrice: t.Output,
|
||||
CacheWritePrice: t.CacheWrite,
|
||||
CacheReadPrice: t.CacheRead,
|
||||
SortOrder: i,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
}
|
||||
|
||||
// plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致:
|
||||
// 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。
|
||||
// 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参
|
||||
// (渠道定价指针指向缓存共享数据)。
|
||||
func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing {
|
||||
if p == nil || g == nil || p.BillingMode != BillingModeImage {
|
||||
return p
|
||||
}
|
||||
if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil {
|
||||
return p
|
||||
}
|
||||
channelTierPrice := func(label string) *float64 {
|
||||
for i := range p.Intervals {
|
||||
if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil {
|
||||
return p.Intervals[i].PerRequestPrice
|
||||
}
|
||||
}
|
||||
return p.PerRequestPrice
|
||||
}
|
||||
tiers := []struct {
|
||||
label string
|
||||
groupPrice *float64
|
||||
}{
|
||||
{"1K", g.ImagePrice1K},
|
||||
{"2K", g.ImagePrice2K},
|
||||
{"4K", g.ImagePrice4K},
|
||||
}
|
||||
clone := *p
|
||||
clone.Intervals = make([]PricingInterval, 0, len(tiers))
|
||||
for i, t := range tiers {
|
||||
price := t.groupPrice
|
||||
if price == nil {
|
||||
price = channelTierPrice(t.label)
|
||||
}
|
||||
if price == nil {
|
||||
continue
|
||||
}
|
||||
v := *price
|
||||
clone.Intervals = append(clone.Intervals, PricingInterval{
|
||||
TierLabel: t.label,
|
||||
PerRequestPrice: &v,
|
||||
SortOrder: i,
|
||||
})
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// lookupOfficialPricing 查询模型的官方参考价(与计费同源:LiteLLM → 内置兜底 → 模型策略),
|
||||
// 带 memo 避免同名模型重复解析。官方阶梯按无分组、无渠道的口径查阶梯表。
|
||||
// billingService 为 nil(测试场景)或查不到时返回 nil。
|
||||
func (s *ModelPlazaService) lookupOfficialPricing(ctx context.Context, modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing {
|
||||
if s.billingService == nil {
|
||||
return nil
|
||||
}
|
||||
if cached, ok := memo[modelName]; ok {
|
||||
return cached
|
||||
}
|
||||
var result *PlazaOfficialPricing
|
||||
if mp, err := s.billingService.GetModelPricing(modelName); err == nil && mp != nil {
|
||||
result = &PlazaOfficialPricing{
|
||||
InputPrice: nonZeroPtr(mp.InputPricePerToken),
|
||||
OutputPrice: nonZeroPtr(mp.OutputPricePerToken),
|
||||
CacheWritePrice: nonZeroPtr(mp.CacheCreationPricePerToken),
|
||||
CacheReadPrice: nonZeroPtr(mp.CacheReadPricePerToken),
|
||||
}
|
||||
// 计费只在支持 5m/1h 分档时使用 1h 价,其余情况 1h 价对用户无意义。
|
||||
if mp.SupportsCacheBreakdown {
|
||||
result.CacheWrite1hPrice = nonZeroPtr(mp.CacheCreation1hPrice)
|
||||
}
|
||||
if s.resolver != nil {
|
||||
sched, schedErr := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{Model: modelName})
|
||||
if schedErr == nil && sched != nil && len(sched.Tiers) > 1 {
|
||||
result.Intervals = plazaIntervalsFromTiers(sched.Tiers)
|
||||
}
|
||||
}
|
||||
if result.InputPrice == nil && result.OutputPrice == nil && result.CacheWritePrice == nil &&
|
||||
result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil && len(result.Intervals) == 0 {
|
||||
result = nil
|
||||
}
|
||||
}
|
||||
memo[modelName] = result
|
||||
return result
|
||||
}
|
||||
+169
-28
@@ -7,17 +7,16 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newPlazaChannelService 构造 ListPlazaGroups 测试用的 ChannelService。
|
||||
func newPlazaChannelService(channels []Channel, groups []Group, pricing *PricingService) *ChannelService {
|
||||
// newPlazaService 构造 ListGroups 测试用的 ModelPlazaService(不接计费服务:展示定价原样透传)。
|
||||
func newPlazaService(channels []Channel, groups []Group, pricing *PricingService) *ModelPlazaService {
|
||||
repo := &mockChannelRepository{
|
||||
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
|
||||
}
|
||||
svc := NewChannelService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, nil, nil)
|
||||
svc.pricingService = pricing
|
||||
return svc
|
||||
return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, pricing, nil, nil)
|
||||
}
|
||||
|
||||
func plazaPricedChannel(id int64, name string, groupIDs []int64, platform string, models ...string) Channel {
|
||||
@@ -46,8 +45,8 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) {
|
||||
{ID: 10, Name: "g-main", Description: "desc", Platform: "anthropic", RateMultiplier: 1},
|
||||
{ID: 20, Name: "g-empty", Platform: "anthropic", RateMultiplier: 0.5},
|
||||
}
|
||||
svc := newPlazaChannelService(channels, groups, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
svc := newPlazaService(channels, groups, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1, "无模型的分组不应返回")
|
||||
require.Equal(t, int64(10), out[0].ID)
|
||||
@@ -71,8 +70,8 @@ func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) {
|
||||
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
|
||||
|
||||
// alpha(无价)按名称序先于 beta(有价):先见者无价,应被有价条目升级。
|
||||
svc := newPlazaChannelService([]Channel{priced, unpriced}, groups, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
svc := newPlazaService([]Channel{priced, unpriced}, groups, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
require.Len(t, out[0].Models, 1)
|
||||
@@ -93,8 +92,8 @@ func TestListPlazaGroups_PlatformIsolation(t *testing.T) {
|
||||
{ID: 10, Name: "g-claude", Platform: "anthropic", RateMultiplier: 1},
|
||||
{ID: 20, Name: "g-gpt", Platform: "openai", RateMultiplier: 1},
|
||||
}
|
||||
svc := newPlazaChannelService([]Channel{ch}, groups, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
svc := newPlazaService([]Channel{ch}, groups, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
byName := map[string][]PlazaModel{}
|
||||
@@ -122,7 +121,7 @@ func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing
|
||||
}
|
||||
groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}}
|
||||
|
||||
out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background())
|
||||
out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
@@ -146,7 +145,7 @@ func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing
|
||||
{ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1},
|
||||
}
|
||||
|
||||
out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background())
|
||||
out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
@@ -173,8 +172,8 @@ func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) {
|
||||
inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet")
|
||||
inactive.Status = "inactive"
|
||||
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
|
||||
svc := newPlazaChannelService([]Channel{inactive}, groups, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
svc := newPlazaService([]Channel{inactive}, groups, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, out)
|
||||
}
|
||||
@@ -188,8 +187,8 @@ func TestListPlazaGroups_SortedByRateMultiplierAsc(t *testing.T) {
|
||||
{ID: 20, Name: "a-standard", Platform: "anthropic", RateMultiplier: 1},
|
||||
{ID: 30, Name: "cheap", Platform: "anthropic", RateMultiplier: 0.5},
|
||||
}
|
||||
svc := newPlazaChannelService(channels, groups, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
svc := newPlazaService(channels, groups, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 3)
|
||||
require.Equal(t, "cheap", out[0].Name, "倍率低者在前")
|
||||
@@ -213,8 +212,11 @@ func TestListPlazaGroups_OfficialPricingFill(t *testing.T) {
|
||||
plazaPricedChannel(1, "ch", []int64{10}, "anthropic", "claude-sonnet", "unknown-model", "token-absent"),
|
||||
}
|
||||
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
|
||||
svc := newPlazaChannelService(channels, groups, pricingSvc)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
svc := newPlazaService(channels, groups, pricingSvc)
|
||||
// 官方价与计费同源:需要计费服务与解析器(官方参考不查渠道,解析器无需渠道服务)。
|
||||
svc.billingService = NewBillingService(&config.Config{}, pricingSvc)
|
||||
svc.resolver = NewModelPricingResolver(nil, svc.billingService)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
require.Len(t, out[0].Models, 3)
|
||||
@@ -256,8 +258,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
|
||||
ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1},
|
||||
{ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1},
|
||||
}
|
||||
svc := newPlazaChannelService(channels, groups, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
svc := newPlazaService(channels, groups, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
byName := map[string]PlazaGroup{}
|
||||
@@ -298,8 +300,8 @@ func TestListPlazaGroups_GroupImagePriceIgnoredForNonImageModes(t *testing.T) {
|
||||
imgPrice := 0.02
|
||||
channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, "openai", "gpt-5")}
|
||||
groups := []Group{{ID: 10, Name: "g", Platform: "openai", RateMultiplier: 1, ImagePrice1K: &imgPrice}}
|
||||
svc := newPlazaChannelService(channels, groups, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
svc := newPlazaService(channels, groups, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
p := out[0].Models[0].Pricing
|
||||
@@ -314,17 +316,156 @@ func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) {
|
||||
repo := &mockChannelRepository{
|
||||
listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
|
||||
}
|
||||
svc := NewChannelService(repo, &stubGroupRepoForAvailable{}, nil, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
svc := NewModelPlazaService(repo, &stubGroupRepoForAvailable{}, nil, nil, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.Nil(t, out)
|
||||
require.ErrorIs(t, err, sentinel)
|
||||
|
||||
svc2 := NewChannelService(
|
||||
svc2 := NewModelPlazaService(
|
||||
&mockChannelRepository{listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, nil }},
|
||||
&stubGroupRepoForAvailable{listActiveErr: sentinel},
|
||||
nil, nil,
|
||||
nil, nil, nil,
|
||||
)
|
||||
out2, err2 := svc2.ListPlazaGroups(context.Background())
|
||||
out2, err2 := svc2.ListGroups(context.Background())
|
||||
require.Nil(t, out2)
|
||||
require.ErrorIs(t, err2, sentinel)
|
||||
}
|
||||
|
||||
// newPlazaServiceWithBilling 构造接入计费服务与解析器的广场服务:解析器的渠道服务与广场共用同一份渠道数据。
|
||||
func newPlazaServiceWithBilling(channels []Channel, groups []Group, groupPlatforms map[int64]string, catalog *PricingService) *ModelPlazaService {
|
||||
repo := &mockChannelRepository{
|
||||
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
|
||||
getGroupPlatformsFn: func(ctx context.Context, _ []int64) (map[int64]string, error) {
|
||||
return groupPlatforms, nil
|
||||
},
|
||||
}
|
||||
cs := NewChannelService(repo, nil, nil, nil)
|
||||
bs := NewBillingService(&config.Config{}, catalog)
|
||||
return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, catalog, bs, NewModelPricingResolver(cs, bs))
|
||||
}
|
||||
|
||||
func plazaModelsByName(models []PlazaModel) map[string]PlazaModel {
|
||||
out := make(map[string]PlazaModel, len(models))
|
||||
for _, m := range models {
|
||||
out[m.Name] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) {
|
||||
// 同一渠道挂开启/关闭阶梯的两个分组:实付档位随分组开关,官方阶梯不受影响。
|
||||
channels := []Channel{{
|
||||
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10, 20},
|
||||
ModelPricing: []ChannelModelPricing{{Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken}},
|
||||
}}
|
||||
groups := []Group{
|
||||
{ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true},
|
||||
{ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false},
|
||||
}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
|
||||
on, off := out[0], out[1]
|
||||
require.True(t, on.LongContextPricingEnabled)
|
||||
require.False(t, off.LongContextPricingEnabled)
|
||||
|
||||
onModel := on.Models[0]
|
||||
require.Equal(t, ContextPricingBasisWholeRequest, onModel.LongContextBasis)
|
||||
require.Len(t, onModel.Pricing.Intervals, 2)
|
||||
require.Equal(t, "≤272K", onModel.Pricing.Intervals[0].TierLabel)
|
||||
require.Equal(t, ">272K", onModel.Pricing.Intervals[1].TierLabel)
|
||||
require.InDelta(t, 2.5e-6, *onModel.Pricing.InputPrice, 1e-15)
|
||||
require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].InputPrice, 1e-15)
|
||||
require.InDelta(t, 22.5e-6, *onModel.Pricing.Intervals[1].OutputPrice, 1e-15)
|
||||
require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].CacheWritePrice, 1e-15)
|
||||
require.InDelta(t, 0.5e-6, *onModel.Pricing.Intervals[1].CacheReadPrice, 1e-15)
|
||||
|
||||
offModel := off.Models[0]
|
||||
require.Empty(t, offModel.LongContextBasis)
|
||||
require.Empty(t, offModel.Pricing.Intervals)
|
||||
require.InDelta(t, 2.5e-6, *offModel.Pricing.InputPrice, 1e-15)
|
||||
|
||||
for _, m := range []PlazaModel{onModel, offModel} {
|
||||
require.NotNil(t, m.OfficialPricing)
|
||||
require.Len(t, m.OfficialPricing.Intervals, 2, "官方阶梯不受分组开关影响")
|
||||
require.InDelta(t, 5e-6, *m.OfficialPricing.Intervals[1].InputPrice, 1e-15)
|
||||
require.InDelta(t, 2.5e-6, *m.OfficialPricing.InputPrice, 1e-15)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGroups_GeminiLegacyRuleShownAsMarginal(t *testing.T) {
|
||||
channels := []Channel{{
|
||||
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
|
||||
ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}},
|
||||
}}
|
||||
groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, geminiCatalogStub())
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
m := out[0].Models[0]
|
||||
require.Equal(t, ContextPricingBasisMarginal, m.LongContextBasis)
|
||||
require.Len(t, m.Pricing.Intervals, 2)
|
||||
require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel)
|
||||
require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel)
|
||||
require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15)
|
||||
require.InDelta(t, 10e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15)
|
||||
// 官方参考不套用站内旧规则
|
||||
require.NotNil(t, m.OfficialPricing)
|
||||
require.Empty(t, m.OfficialPricing.Intervals)
|
||||
}
|
||||
|
||||
func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) {
|
||||
channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "claude-sonnet-4")}
|
||||
groups := []Group{{
|
||||
ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true,
|
||||
ModelPricing: []ChannelModelPricing{{Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6)}},
|
||||
}}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
m := out[0].Models[0]
|
||||
require.InDelta(t, 1e-6, *m.Pricing.InputPrice, 1e-15, "分组价卡优先于渠道平价")
|
||||
require.InDelta(t, 15e-6, *m.Pricing.OutputPrice, 1e-15, "卡未配置的项回落目录价")
|
||||
require.Empty(t, m.Pricing.Intervals)
|
||||
}
|
||||
|
||||
func TestListGroups_ImageModelKeepsTierSynthesisWithBilling(t *testing.T) {
|
||||
channels := []Channel{{
|
||||
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
|
||||
ModelPricing: []ChannelModelPricing{{
|
||||
Platform: PlatformOpenAI, Models: []string{"gpt-image-2"}, BillingMode: BillingModeImage,
|
||||
PerRequestPrice: testPtrFloat64(0.04),
|
||||
}},
|
||||
}}
|
||||
groups := []Group{{
|
||||
ID: 10, Name: "g", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true,
|
||||
ImagePrice1K: testPtrFloat64(0.02),
|
||||
}}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI}, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
m := out[0].Models[0]
|
||||
require.Equal(t, BillingModeImage, m.Pricing.BillingMode)
|
||||
require.Empty(t, m.LongContextBasis)
|
||||
require.Len(t, m.Pricing.Intervals, 3)
|
||||
require.InDelta(t, 0.02, *m.Pricing.Intervals[0].PerRequestPrice, 1e-12)
|
||||
require.InDelta(t, 0.04, *m.Pricing.Intervals[1].PerRequestPrice, 1e-12)
|
||||
}
|
||||
|
||||
func TestListGroups_CatalogMissingStillShowsChannelFlatPricing(t *testing.T) {
|
||||
// 目录查不到的模型:计费按渠道平价(未配置项 $0),广场单档展示渠道平价,官方价为空。
|
||||
channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "unknown-model-xyz")}
|
||||
groups := []Group{{ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true}}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil)
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
m := out[0].Models[0]
|
||||
require.NotNil(t, m.Pricing)
|
||||
require.InDelta(t, 3e-6, *m.Pricing.InputPrice, 1e-15)
|
||||
require.Empty(t, m.Pricing.Intervals)
|
||||
require.Nil(t, m.Pricing.CacheWritePrice, "目录无价且渠道未配置 → 无价")
|
||||
require.Nil(t, m.OfficialPricing)
|
||||
}
|
||||
@@ -908,6 +908,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewChannelService,
|
||||
wire.Bind(new(ChannelCacheInvalidator), new(*ChannelService)),
|
||||
NewModelPricingResolver,
|
||||
NewModelPlazaService,
|
||||
NewContentModerationService,
|
||||
NewAffiliateService,
|
||||
ProvidePaymentConfigService,
|
||||
|
||||
Reference in New Issue
Block a user