mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-27 16:07:02 +00:00
修复模型广场图片模型价格展示与实收口径不一致
图片计费模型的广场展示价按实收口径计算:档位单价取 分组图片价 > 渠道档位价 > 渠道默认按次价;分组开启生图 独立倍率时实付倍率取独立倍率,不取分组/专属倍率。
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user