diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 577b1d85e8..0bf294d443 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -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 diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index 1922473e3c..a7fc291ab8 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -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) diff --git a/backend/internal/service/channel_plaza.go b/backend/internal/service/channel_plaza.go index 32704339d1..a71a6e4f1e 100644 --- a/backend/internal/service/channel_plaza.go +++ b/backend/internal/service/channel_plaza.go @@ -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 { diff --git a/backend/internal/service/channel_plaza_test.go b/backend/internal/service/channel_plaza_test.go index fc9c7b7235..82d426ce99 100644 --- a/backend/internal/service/channel_plaza_test.go +++ b/backend/internal/service/channel_plaza_test.go @@ -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{ diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index 741f2bb0e1..c17b3aa1a7 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -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[] } diff --git a/frontend/src/components/modelPlaza/PlazaGroupSection.vue b/frontend/src/components/modelPlaza/PlazaGroupSection.vue index 1ab4dcde26..3a46c93aaf 100644 --- a/frontend/src/components/modelPlaza/PlazaGroupSection.vue +++ b/frontend/src/components/modelPlaza/PlazaGroupSection.vue @@ -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" />

{{ t('modelPlaza.detail.noModels') }} diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 0f076a767f..77f76e8836 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -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" > {{ tierLabel(iv) }} - {{ paidRequestPrice(iv.per_request_price) + {{ paidRequestPrice(m, iv.per_request_price) }}{{ perUnitSuffix(m) }} @@ -178,11 +178,16 @@ - - + -