diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index a4d8d502bb..e613080a74 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -92,6 +92,8 @@ type modelPlazaGroup struct { // 不取分组/用户专属倍率。 ImageRateIndependent bool `json:"image_rate_independent"` ImageRateMultiplier float64 `json:"image_rate_multiplier"` + VideoRateIndependent bool `json:"video_rate_independent"` + VideoRateMultiplier float64 `json:"video_rate_multiplier"` // 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。 LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` Models []modelPlazaModel `json:"models"` @@ -211,6 +213,8 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo IsExclusive: g.IsExclusive, ImageRateIndependent: g.ImageRateIndependent, ImageRateMultiplier: g.ImageRateMultiplier, + VideoRateIndependent: g.VideoRateIndependent, + VideoRateMultiplier: g.VideoRateMultiplier, LongContextPricingEnabled: g.LongContextPricingEnabled, Models: models, } diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index 2de6cbdbe5..f356d68b5e 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -89,6 +89,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) { g := service.PlazaGroup{ ID: 2, Name: "vip", Description: "d", Platform: "anthropic", SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true, + VideoRateIndependent: true, VideoRateMultiplier: 0.7, Models: []service.PlazaModel{{ Name: "claude-sonnet", Platform: "anthropic", @@ -115,11 +116,14 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) { "rate_multiplier", "user_rate_multiplier", "is_exclusive", "models", "peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier", "image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled", + "video_rate_independent", "video_rate_multiplier", } { _, exists := decoded[key] require.Truef(t, exists, "plaza group DTO must expose %q", key) } require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9) + require.Equal(t, true, decoded["video_rate_independent"]) + require.Equal(t, 0.7, decoded["video_rate_multiplier"]) // 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略 models := decoded["models"].([]any) diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index 17cb9b1b90..0f24a13f1e 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -52,6 +52,9 @@ type PlazaGroup struct { // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。 ImageRateIndependent bool ImageRateMultiplier float64 + // 视频独立倍率与图片独立倍率分别配置,开启时覆盖分组/用户专属倍率。 + VideoRateIndependent bool + VideoRateMultiplier float64 // LongContextPricingEnabled 分组是否按上下文长度应用阶梯价;关闭时模型展示的是最低档。 LongContextPricingEnabled bool Models []PlazaModel @@ -132,6 +135,8 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error IsExclusive: g.IsExclusive, ImageRateIndependent: g.ImageRateIndependent, ImageRateMultiplier: g.ImageRateMultiplier, + VideoRateIndependent: g.VideoRateIndependent, + VideoRateMultiplier: g.VideoRateMultiplier, LongContextPricingEnabled: g.LongContextPricingEnabled, } groupEnt[g.ID] = g diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index fa2e7a8345..b7008f87e9 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -265,7 +265,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) { }} groups := []Group{ {ID: 10, Name: "g-media", Platform: "openai", RateMultiplier: 1, - ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1}, + ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1, + VideoRateIndependent: true, VideoRateMultiplier: 0.7}, {ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1}, } svc := newPlazaService(channels, groups, nil) @@ -279,6 +280,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) { media := byName["g-media"] require.True(t, media.ImageRateIndependent) + require.True(t, media.VideoRateIndependent) + require.InDelta(t, 0.7, media.VideoRateMultiplier, 1e-9) require.InDelta(t, 1.0, media.ImageRateMultiplier, 1e-9) require.Len(t, media.Models, 1) p := media.Models[0].Pricing @@ -295,6 +298,7 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) { plain := byName["g-plain"] require.False(t, plain.ImageRateIndependent) + require.False(t, plain.VideoRateIndependent) require.Len(t, plain.Models, 1) pp := plain.Models[0].Pricing require.NotNil(t, pp) diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index 5096efbe82..ccc478d6a5 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -73,6 +73,9 @@ export interface ModelPlazaGroup { /** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */ image_rate_independent: boolean image_rate_multiplier: number + /** 视频独立倍率开启时,覆盖视频模型的分组/用户专属倍率。 */ + video_rate_independent: boolean + video_rate_multiplier: number /** 分组是否启用长上下文阶梯计费;false 时实付列只展示最低档,官方阶梯仅供参考。 */ long_context_pricing_enabled: boolean models: PlazaModel[] diff --git a/frontend/src/components/modelPlaza/PlazaGroupSection.vue b/frontend/src/components/modelPlaza/PlazaGroupSection.vue index 53a9c77cf9..f748781fcc 100644 --- a/frontend/src/components/modelPlaza/PlazaGroupSection.vue +++ b/frontend/src/components/modelPlaza/PlazaGroupSection.vue @@ -61,6 +61,8 @@ :user-rate-multiplier="group.user_rate_multiplier ?? null" :image-rate-independent="group.image_rate_independent" :image-rate-multiplier="group.image_rate_multiplier" + :video-rate-independent="group.video_rate_independent" + :video-rate-multiplier="group.video_rate_multiplier" :peak-window="peakWindow" :peak-rate-multiplier="group.peak_rate_multiplier" /> diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 71f5ec024d..0a8cbfa9f3 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -289,7 +289,7 @@ >{{ periodRate(period) }}x {{ requestRate(m) }}x @@ -313,6 +313,7 @@ import { platformAccentColor, platformBadgeLightClass, platformLabel } from '@/u import { BILLING_MODE_TOKEN, BILLING_MODE_IMAGE, + BILLING_MODE_VIDEO, REASONING_EFFORT_LEVELS, type BillingMode } from '@/constants/channel' @@ -340,6 +341,9 @@ const props = defineProps<{ /** 生图独立倍率:true 时图片计费模型的实付倍率取 imageRateMultiplier,不取分组/专属倍率。 */ imageRateIndependent?: boolean imageRateMultiplier?: number | null + /** 视频独立倍率开启时,覆盖分组/用户专属倍率,与后端视频计费保持一致。 */ + videoRateIndependent?: boolean + videoRateMultiplier?: number | null /** * 高峰窗口描述(含倍率与服务器时区标注),空串/缺省 = 分组未启用高峰。 * 表格所有价格均为不含高峰因子的口径,该窗口仅用于分时时段行的 tooltip 披露: @@ -425,17 +429,23 @@ function paidPerMillion(value: number | null | undefined, period: PlazaTimePrici return formatScaled(value * rate, PER_MILLION, MIN_DECIMALS) } -/** 图片计费模型且分组开启生图独立倍率:实付倍率取独立倍率,与计费口径一致。 */ -function usesIndependentImageRate(m: PlazaModel): boolean { - return billingMode(m) === BILLING_MODE_IMAGE && props.imageRateIndependent === true +/** 图片和视频分别使用对应的独立倍率开关。 */ +function usesIndependentMediaRate(m: PlazaModel): boolean { + return (billingMode(m) === BILLING_MODE_IMAGE && props.imageRateIndependent === true) || + (billingMode(m) === BILLING_MODE_VIDEO && props.videoRateIndependent === true) } -/** 按次/按图片行的生效倍率。 */ +/** 非 token 行的生效倍率,图片和视频可分别覆盖通用倍率。 */ function requestRate(m: PlazaModel): number { - return usesIndependentImageRate(m) ? (props.imageRateMultiplier ?? 1) : effectiveRate.value + if (billingMode(m) === BILLING_MODE_VIDEO && props.videoRateIndependent === true) { + return Math.max(0, props.videoRateMultiplier ?? 1) + } + return billingMode(m) === BILLING_MODE_IMAGE && props.imageRateIndependent === true + ? (props.imageRateMultiplier ?? 1) + : effectiveRate.value } -/** 按次 / 按图片单价(乘该行生效倍率,不换算 1M)。 */ +/** 非 token 单价乘该行生效倍率,不换算为每百万 token。 */ function paidRequestPrice(m: PlazaModel, value: number | null | undefined): string { if (value == null) return '-' return formatScaled(value * requestRate(m), 1, MIN_DECIMALS) diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts index aad4e25a35..45d21cc791 100644 --- a/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts +++ b/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts @@ -68,6 +68,8 @@ function group(overrides: Partial = {}): ModelPlazaGroup { is_exclusive: false, image_rate_independent: false, image_rate_multiplier: 1, + video_rate_independent: false, + video_rate_multiplier: 1, long_context_pricing_enabled: true, models: [ladderModel(2)], ...overrides @@ -90,6 +92,14 @@ function mountSection(g: ModelPlazaGroup) { const NOTE = 'modelPlaza.detail.longContextDisabledNote' describe('PlazaGroupSection 长上下文说明', () => { + it('passes video-specific rates to the pricing table', () => { + const wrapper = mountSection(group({ video_rate_independent: true, video_rate_multiplier: 0 })) + expect(wrapper.findComponent(PlazaModelPricingTable).props()).toMatchObject({ + videoRateIndependent: true, + videoRateMultiplier: 0 + }) + wrapper.unmount() + }) it('分组关闭阶梯且组内有官方阶梯模型时显示说明', () => { const wrapper = mountSection(group({ long_context_pricing_enabled: false })) expect(wrapper.text()).toContain(NOTE) diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts index c8c6bbe77e..f5c121199b 100644 --- a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts +++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts @@ -46,6 +46,8 @@ function mountTable( extraProps?: { imageRateIndependent?: boolean imageRateMultiplier?: number | null + videoRateIndependent?: boolean + videoRateMultiplier?: number | null peakWindow?: string peakRateMultiplier?: number | null } @@ -56,6 +58,49 @@ function mountTable( } describe('PlazaModelPricingTable', () => { + it.each([ + { enabled: true, multiplier: 1, userRate: 0.05, expected: 1 }, + { enabled: true, multiplier: 0.5, userRate: null, expected: 0.5 }, + { enabled: true, multiplier: 0, userRate: 0.05, expected: 0 }, + { enabled: true, multiplier: -1, userRate: null, expected: 0 }, + { enabled: false, multiplier: 1, userRate: 0.05, expected: 0.05 }, + { enabled: false, multiplier: 1, userRate: null, expected: 0.15 } + ])('uses the video billing multiplier $expected when independent=$enabled', (tc) => { + const model = tokenModel({ name: 'video-test', official_pricing: null }) + model.pricing!.billing_mode = 'video' + model.pricing!.per_request_price = 2 + const wrapper = mountTable([model], 0.15, tc.userRate, { + imageRateIndependent: true, + imageRateMultiplier: 9, + videoRateIndependent: tc.enabled, + videoRateMultiplier: tc.multiplier + }) + try { + expect(wrapper.text()).toContain(`$${(2 * tc.expected).toFixed(2)}`) + const rateCell = wrapper.findAll('tbody tr td').at(-1)! + if (tc.enabled) expect(rateCell.text()).toBe(`${tc.expected}x`) + else expect(rateCell.text()).toContain(`${tc.expected}x`) + } finally { + wrapper.unmount() + } + }) + + it('applies video independent rates to resolution tiers', () => { + const model = tokenModel({ name: 'video-tier-test', official_pricing: null }) + model.pricing!.billing_mode = 'video' + model.pricing!.intervals = [{ + min_tokens: 0, max_tokens: null, tier_label: '720p', + input_price: null, output_price: null, cache_write_price: null, + cache_read_price: null, per_request_price: 2 + }] + const wrapper = mountTable([model], 0.15, 0.05, { + videoRateIndependent: true, videoRateMultiplier: 0.5 + }) + expect(wrapper.text()).toContain('720p') + expect(wrapper.text()).toContain('$1.00') + wrapper.unmount() + }) + it('倍率为 1 时展示渠道单价原值($/1M),价格保底 2 位小数', () => { const wrapper = mountTable([tokenModel()], 1) const text = wrapper.text()