修复模型广场图片模型价格展示与实收口径不一致

图片计费模型的广场展示价按实收口径计算:档位单价取
分组图片价 > 渠道档位价 > 渠道默认按次价;分组开启生图
独立倍率时实付倍率取独立倍率,不取分组/专属倍率。
This commit is contained in:
feeeei
2026-08-04 13:48:19 +08:00
parent 825ca7b1fc
commit 785b61d424
8 changed files with 280 additions and 50 deletions
+31 -25
View File
@@ -54,19 +54,23 @@ type modelPlazaModel struct {
// modelPlazaGroup 广场分组条目(白名单字段)。
type modelPlazaGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
Models []modelPlazaModel `json:"models"`
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
// 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier,
// 不取分组/用户专属倍率。
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
Models []modelPlazaModel `json:"models"`
}
// modelPlazaResponse 广场页响应。
@@ -164,18 +168,20 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
})
}
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,
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,
Models: models,
}
if rate, ok := userRates[g.ID]; ok {
dto.UserRateMultiplier = &rate
@@ -91,6 +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",
} {
_, exists := decoded[key]
require.Truef(t, exists, "plaza group DTO must expose %q", key)
+74 -16
View File
@@ -41,7 +41,11 @@ type PlazaGroup struct {
PeakEnd string
PeakRateMultiplier float64
IsExclusive bool
Models []PlazaModel
// 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付
// = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
ImageRateIndependent bool
ImageRateMultiplier float64
Models []PlazaModel
}
// ListPlazaGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。
@@ -50,6 +54,8 @@ type PlazaGroup struct {
// 平台隔离),仅把顶层从渠道换成分组:
// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定;
// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换;
// - 图片计费模型的档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价,
// 见 plazaImageDisplayPricing);
// - 每个模型附带 LiteLLM 官方参考价(查不到为 nil);
// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称),
// 组内模型按名称排序。
@@ -70,22 +76,26 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
})
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]
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,
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)
}
@@ -115,10 +125,11 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
if m.Platform != pg.Platform {
continue
}
pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid])
if at, seen := idx[m.Name]; seen {
// 先见者胜;仅当已存条目无定价而新条目有定价时升级。
if pg.Models[at].Pricing == nil && m.Pricing != nil {
pg.Models[at].Pricing = m.Pricing
if pg.Models[at].Pricing == nil && pricing != nil {
pg.Models[at].Pricing = pricing
}
continue
}
@@ -126,7 +137,7 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
pg.Models = append(pg.Models, PlazaModel{
Name: m.Name,
Platform: m.Platform,
Pricing: m.Pricing,
Pricing: pricing,
})
}
}
@@ -155,6 +166,53 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
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 {
@@ -173,6 +173,80 @@ func TestListPlazaGroups_OfficialPricingFill(t *testing.T) {
require.Nil(t, byName["token-absent"].OfficialPricing)
}
func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
// 图片计费模型:档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价),
// 分组独立倍率字段透传;未配图片价的分组保持渠道定价原样。
perReq := 0.2
tier4K := 0.3
imgPrice := 0.02
channels := []Channel{{
ID: 1, Name: "img-ch", Status: StatusActive, GroupIDs: []int64{10, 20},
ModelPricing: []ChannelModelPricing{{
Platform: "openai",
Models: []string{"gpt-image-2"},
BillingMode: BillingModeImage,
PerRequestPrice: &perReq,
Intervals: []PricingInterval{{TierLabel: "4K", PerRequestPrice: &tier4K}},
}},
}}
groups := []Group{
{ID: 10, Name: "g-media", Platform: "openai", RateMultiplier: 1,
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())
require.NoError(t, err)
require.Len(t, out, 2)
byName := map[string]PlazaGroup{}
for _, g := range out {
byName[g.Name] = g
}
media := byName["g-media"]
require.True(t, media.ImageRateIndependent)
require.InDelta(t, 1.0, media.ImageRateMultiplier, 1e-9)
require.Len(t, media.Models, 1)
p := media.Models[0].Pricing
require.NotNil(t, p)
require.Len(t, p.Intervals, 3)
tierPrices := map[string]float64{}
for _, iv := range p.Intervals {
require.NotNil(t, iv.PerRequestPrice)
tierPrices[iv.TierLabel] = *iv.PerRequestPrice
}
require.InDelta(t, 0.02, tierPrices["1K"], 1e-9, "1K 用分组图片价")
require.InDelta(t, 0.2, tierPrices["2K"], 1e-9, "2K 分组未配,回落渠道默认按次价")
require.InDelta(t, 0.3, tierPrices["4K"], 1e-9, "4K 分组未配,回落渠道档位价")
plain := byName["g-plain"]
require.False(t, plain.ImageRateIndependent)
require.Len(t, plain.Models, 1)
pp := plain.Models[0].Pricing
require.NotNil(t, pp)
require.Len(t, pp.Intervals, 1, "未配分组图片价:渠道定价原样")
require.InDelta(t, 0.2, *pp.PerRequestPrice, 1e-9)
// 合成为克隆,渠道原始定价不被修改
require.Len(t, channels[0].ModelPricing[0].Intervals, 1)
}
func TestListPlazaGroups_GroupImagePriceIgnoredForNonImageModes(t *testing.T) {
// token 模式定价不受分组图片价影响。
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())
require.NoError(t, err)
require.Len(t, out, 1)
p := out[0].Models[0].Pricing
require.NotNil(t, p)
require.Empty(t, p.Intervals)
require.NotNil(t, p.InputPrice)
require.Nil(t, p.PerRequestPrice)
}
func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) {
sentinel := errors.New("boom")
repo := &mockChannelRepository{