Merge pull request #5258 from feeeei/fix/model_plaza

fix(model-plaza): Model Plaza image model price display is inconsistent with the actual price
This commit is contained in:
Wesley Liddick
2026-08-04 16:27:53 +08:00
committed by GitHub
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{
+3
View File
@@ -40,6 +40,9 @@ export interface ModelPlazaGroup {
peak_end: string
peak_rate_multiplier: number
is_exclusive: boolean
/** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */
image_rate_independent: boolean
image_rate_multiplier: number
models: PlazaModel[]
}
@@ -52,6 +52,8 @@
:platform="group.platform"
:rate-multiplier="group.rate_multiplier"
:user-rate-multiplier="group.user_rate_multiplier ?? null"
:image-rate-independent="group.image_rate_independent"
:image-rate-multiplier="group.image_rate_multiplier"
/>
<p v-else class="px-5 py-4 text-center text-sm text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.detail.noModels') }}
@@ -134,13 +134,13 @@
class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-800 dark:bg-dark-700/60 dark:text-gray-200"
>
<span class="font-sans text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
{{ paidRequestPrice(iv.per_request_price)
{{ paidRequestPrice(m, iv.per_request_price)
}}<span class="font-sans text-gray-400 dark:text-dark-500">{{ perUnitSuffix(m) }}</span>
</span>
</div>
<template v-else-if="m.pricing?.per_request_price != null">
<span class="font-mono font-semibold text-gray-900 dark:text-gray-50">
{{ paidRequestPrice(m.pricing.per_request_price) }}
{{ paidRequestPrice(m, m.pricing.per_request_price) }}
</span>
<span class="ml-1 text-xs text-gray-400 dark:text-dark-500">{{ perUnitSuffix(m) }}</span>
</template>
@@ -178,11 +178,16 @@
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</td>
<!-- 折扣倍率(专属倍率划线展示原倍率) -->
<!-- 折扣倍率(生图独立倍率行展示独立倍率;专属倍率划线展示原倍率) -->
<td
class="border-l border-gray-100 py-2.5 pl-3 pr-5 text-right align-middle font-mono text-xs dark:border-dark-700/60"
>
<template v-if="hasCustomRate">
<span
v-if="usesIndependentImageRate(m)"
class="font-bold text-gray-700 dark:text-gray-300"
>{{ requestRate(m) }}x</span
>
<template v-else-if="hasCustomRate">
<span class="mr-1 text-gray-400 line-through dark:text-dark-500">{{ rateMultiplier }}x</span>
<span class="font-bold text-primary-600 dark:text-primary-400">{{ effectiveRate }}x</span>
</template>
@@ -215,6 +220,9 @@ const props = defineProps<{
rateMultiplier: number
/** 用户专属倍率;与默认不同,实付价按此计算并划线展示原倍率。 */
userRateMultiplier?: number | null
/** 生图独立倍率:true 时图片计费模型的实付倍率取 imageRateMultiplier,不取分组/专属倍率。 */
imageRateIndependent?: boolean
imageRateMultiplier?: number | null
}>()
const { t } = useI18n()
@@ -268,10 +276,20 @@ function paidPerMillion(value: number | null | undefined): string {
return formatScaled(value * effectiveRate.value, PER_MILLION, MIN_DECIMALS)
}
/** 按次 / 按图片单价(乘生效倍率,不换算 1M)。 */
function paidRequestPrice(value: number | null | undefined): string {
/** 图片计费模型且分组开启生图独立倍率:实付倍率取独立倍率,与计费口径一致。 */
function usesIndependentImageRate(m: PlazaModel): boolean {
return billingMode(m) === BILLING_MODE_IMAGE && props.imageRateIndependent === true
}
/** 按次/按图片行的生效倍率。 */
function requestRate(m: PlazaModel): number {
return usesIndependentImageRate(m) ? (props.imageRateMultiplier ?? 1) : effectiveRate.value
}
/** 按次 / 按图片单价(乘该行生效倍率,不换算 1M)。 */
function paidRequestPrice(m: PlazaModel, value: number | null | undefined): string {
if (value == null) return '-'
return formatScaled(value * effectiveRate.value, 1, MIN_DECIMALS)
return formatScaled(value * requestRate(m), 1, MIN_DECIMALS)
}
/** 官方参考价不乘倍率。 */
@@ -39,9 +39,14 @@ function tokenModel(overrides: Partial<PlazaModel> = {}): PlazaModel {
}
}
function mountTable(models: PlazaModel[], rateMultiplier: number, userRateMultiplier?: number | null) {
function mountTable(
models: PlazaModel[],
rateMultiplier: number,
userRateMultiplier?: number | null,
extraProps?: { imageRateIndependent?: boolean; imageRateMultiplier?: number | null }
) {
return mount(PlazaModelPricingTable, {
props: { models, rateMultiplier, userRateMultiplier: userRateMultiplier ?? null }
props: { models, rateMultiplier, userRateMultiplier: userRateMultiplier ?? null, ...extraProps }
})
}
@@ -265,6 +270,69 @@ describe('PlazaModelPricingTable', () => {
expect(text).toContain('$15.00')
})
it('生图独立倍率开启时,按图价格 × 独立倍率,不乘分组倍率;倍率列展示独立倍率', () => {
const model = tokenModel({
name: 'gpt-image-2',
pricing: {
billing_mode: 'image',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: [
{
min_tokens: 0,
max_tokens: null,
tier_label: '1K',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
per_request_price: 0.02
}
]
},
official_pricing: null
})
const wrapper = mountTable([model], 0.1, null, {
imageRateIndependent: true,
imageRateMultiplier: 1
})
const text = wrapper.text()
// 0.02 × 1(独立倍率),而非 0.02 × 0.1
expect(text).toContain('$0.02')
expect(text).not.toContain('$0.002')
// 倍率列展示独立倍率 1x,而非分组倍率 0.1x
const rateCell = wrapper.findAll('tbody tr td').at(-1)!
expect(rateCell.text()).toBe('1x')
})
it('生图独立倍率关闭时,按图价格仍乘分组/专属生效倍率', () => {
const model = tokenModel({
name: 'gpt-image-2',
pricing: {
billing_mode: 'image',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: 0.2,
intervals: []
},
official_pricing: null
})
const wrapper = mountTable([model], 0.1, null, { imageRateIndependent: false })
const text = wrapper.text()
expect(text).toContain('$0.02')
const rateCell = wrapper.findAll('tbody tr td').at(-1)!
expect(rateCell.text()).toBe('0.1x')
})
it('按图模型主行展示阶梯芯片,不把 image_output_price(每 token)当按次价', () => {
const model = tokenModel({
name: 'gpt-image-2',