diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index ffa2408bcd..ae01687bdb 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -81,15 +81,19 @@ type userSupportedModelPricing struct { // userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。 type userPricingIntervalDTO struct { - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label,omitempty"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label,omitempty"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` } // userSupportedModel 用户可见的支持模型条目。 @@ -295,15 +299,19 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval intervals := make([]userPricingIntervalDTO, 0, len(src)) for _, iv := range src { intervals = append(intervals, userPricingIntervalDTO{ - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheWrite1hPrice: iv.CacheWrite1hPrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheWrite1hPrice: iv.CacheWrite1hPrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: iv.InputMultiplier, + OutputMultiplier: iv.OutputMultiplier, + CacheWriteMultiplier: iv.CacheWriteMultiplier, + CacheReadMultiplier: iv.CacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, }) } return intervals diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index 24d7d1cb45..325f10133b 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { } // pricing interval 白名单:不应暴露 id / sort_order。 + inputMultiplier := 2.0 + outputMultiplier := 1.5 + cacheWriteMultiplier := 2.0 + cacheReadMultiplier := 2.0 pricing := toUserPricing(&service.ChannelModelPricing{ BillingMode: service.BillingModeToken, Intervals: []service.PricingInterval{ - {ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3}, + { + ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3, + InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier, + CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier, + }, }, }) require.NotNil(t, pricing) @@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { _, exists := ivDecoded[key] require.Falsef(t, exists, "user pricing interval must not expose %q", key) } + for key, want := range map[string]float64{ + "input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier, + "cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier, + } { + got, exists := ivDecoded[key] + require.Truef(t, exists, "user pricing interval must expose %q", key) + require.InDelta(t, want, got.(float64), 1e-12) + } } func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) { diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index c998fc3efb..5c3f83542e 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -31,6 +31,10 @@ export interface UserPricingInterval { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null + input_multiplier?: number | null + output_multiplier?: number | null + cache_write_multiplier?: number | null + cache_read_multiplier?: number | null per_request_price: number | null } diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index e96a621934..5096efbe82 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -46,7 +46,7 @@ export interface PlazaTimePricing { export interface PlazaModel { name: string platform: string - /** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */ + /** 实收口径的展示定价:档位可提供绝对单价或相对基础价倍率;均为标准时段价。 */ pricing: UserSupportedModelPricing | null official_pricing: PlazaOfficialPricing | null /** 仅多档模型返回。 */ diff --git a/frontend/src/components/channels/SupportedModelChip.vue b/frontend/src/components/channels/SupportedModelChip.vue index b94fc9f072..04a2ad9de5 100644 --- a/frontend/src/components/channels/SupportedModelChip.vue +++ b/frontend/src/components/channels/SupportedModelChip.vue @@ -153,7 +153,7 @@ - {{ formatInterval(iv, model.pricing.billing_mode) }} + {{ formatInterval(iv, model.pricing) }} @@ -172,12 +172,11 @@ import { formatScaled } from '@/utils/pricing' import { BILLING_MODE_TOKEN, BILLING_MODE_PER_REQUEST, - BILLING_MODE_IMAGE, - type BillingMode + BILLING_MODE_IMAGE } from '@/constants/channel' // 复用 api/channels.ts 的用户侧最小形态 DTO。 // admin 侧 ChannelModelPricing 字段更多,但结构上是用户 DTO 的超集,admin 视图传入可直接通过结构化子类型检查。 -import type { UserPricingInterval, UserSupportedModel } from '@/api/channels' +import type { UserPricingInterval, UserSupportedModel, UserSupportedModelPricing } from '@/api/channels' import PlatformIcon from '@/components/common/PlatformIcon.vue' import type { GroupPlatform } from '@/types' import { platformBadgeClass, platformBorderClass, platformBadgeLightClass } from '@/utils/platformColors' @@ -246,12 +245,22 @@ function formatRange(min: number, max: number | null): string { return `(${min}, ${maxLabel}]` } -function formatInterval(iv: UserPricingInterval, mode: BillingMode): string { - if (mode === BILLING_MODE_PER_REQUEST || mode === BILLING_MODE_IMAGE) { +function effectiveIntervalPrice( + price: number | null | undefined, + multiplier: number | null | undefined, + basePrice: number | null +): number | null { + if (price != null) return price + if (multiplier != null && basePrice != null) return basePrice * multiplier + return basePrice +} + +function formatInterval(iv: UserPricingInterval, pricing: UserSupportedModelPricing): string { + if (pricing.billing_mode === BILLING_MODE_PER_REQUEST || pricing.billing_mode === BILLING_MODE_IMAGE) { return formatScaled(iv.per_request_price, 1) } - const input = formatScaled(iv.input_price, perMillionScale) - const output = formatScaled(iv.output_price, perMillionScale) + const input = formatScaled(effectiveIntervalPrice(iv.input_price, iv.input_multiplier, pricing.input_price), perMillionScale) + const output = formatScaled(effectiveIntervalPrice(iv.output_price, iv.output_multiplier, pricing.output_price), perMillionScale) return `${input} / ${output}` } diff --git a/frontend/src/components/channels/__tests__/SupportedModelChip.spec.ts b/frontend/src/components/channels/__tests__/SupportedModelChip.spec.ts new file mode 100644 index 0000000000..b3799b697c --- /dev/null +++ b/frontend/src/components/channels/__tests__/SupportedModelChip.spec.ts @@ -0,0 +1,54 @@ +import { nextTick } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import SupportedModelChip from '../SupportedModelChip.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }) + } +}) + +describe('SupportedModelChip', () => { + it('仅配置区间倍率时按基础价展示 token 档位', async () => { + const wrapper = mount(SupportedModelChip, { + attachTo: document.body, + props: { + model: { + name: 'gpt-test', + platform: '', + pricing: { + billing_mode: 'token', + input_price: 10e-6, + output_price: 50e-6, + cache_write_price: null, + cache_read_price: null, + image_input_price: null, + image_output_price: null, + per_request_price: null, + intervals: [{ + min_tokens: 272000, + max_tokens: null, + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + input_multiplier: 2, + output_multiplier: 1.5, + per_request_price: null + }] + } + }, + showPlatform: false + } + }) + + await wrapper.find('[tabindex="0"]').trigger('mouseenter') + await nextTick() + + expect(document.body.textContent).toContain('$20 / $75') + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 43aee2c140..59910f2a36 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -119,7 +119,7 @@ class="whitespace-nowrap text-xs leading-5" > {{ tierLabel(iv) }} - {{ paidPerMillion(iv.input_price, period) }} + {{ paidPerMillion(effectiveIntervalPrice(iv.input_price, iv.input_multiplier, m.pricing?.input_price), period) }} @@ -132,7 +132,7 @@ class="whitespace-nowrap text-xs leading-5" :title="tierHint(m)" > - {{ paidPerMillion(iv.output_price, period) }} + {{ paidPerMillion(effectiveIntervalPrice(iv.output_price, iv.output_multiplier, m.pricing?.output_price), period) }} @@ -145,15 +145,15 @@ class="whitespace-nowrap font-mono text-xs leading-5 text-gray-800 dark:text-gray-200" :title="tierHint(m)" > - @@ -243,15 +243,15 @@ class="whitespace-nowrap font-mono text-xs leading-5 text-gray-500 dark:text-dark-400" :title="t('modelPlaza.table.tierHint')" > -