Merge pull request #6109 from feeeei/main

feat(model-plaza): 模型广场增加长上下文阶梯计价显示 & 分时段计价显示
This commit is contained in:
Wesley Liddick
2026-08-24 14:09:56 +08:00
committed by GitHub
23 changed files with 2787 additions and 405 deletions
@@ -42,6 +42,13 @@
<Icon name="clock" size="xs" class="h-3 w-3" />
{{ peakNote }}
</p>
<p
v-if="longContextNote"
class="mt-1.5 flex items-center gap-1 text-xs text-gray-500 dark:text-dark-400"
>
<Icon name="infoCircle" size="xs" class="h-3 w-3" />
{{ longContextNote }}
</p>
</header>
<!-- 模型价格表:整行(含 hover 底色/分区底色)顶到卡片边缘,左右留白由表格首列/末列的 padding 提供 -->
@@ -54,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"
:peak-window="peakWindow"
:peak-rate-multiplier="group.peak_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') }}
@@ -81,15 +90,32 @@ const props = defineProps<{
const { t } = useI18n()
const appStore = useAppStore()
const peakNote = computed(() => {
/** 高峰窗口描述(含倍率与服务器时区标注);分组未启用高峰为空串。 */
const peakWindow = computed(() => {
if (!hasPeakRate(props.group)) return ''
const window = formatPeakRateWindow(
return formatPeakRateWindow(
props.group,
serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset)
)
})
const peakNote = computed(() => {
if (!peakWindow.value) return ''
return t('modelPlaza.detail.peakNote', {
window,
window: peakWindow.value,
multiplier: props.group.peak_rate_multiplier
})
})
/**
* 分组关闭了长上下文阶梯、但组内有模型官方带阶梯时提示:实付列只展示基础档,
* 官方阶梯仅供参考。字段缺失(旧后端)不提示。
*/
const longContextNote = computed(() => {
if (props.group.long_context_pricing_enabled !== false) return ''
const hasOfficialLadder = props.group.models.some(
(m) => (m.official_pricing?.intervals?.length ?? 0) > 1
)
return hasOfficialLadder ? t('modelPlaza.detail.longContextDisabledNote') : ''
})
</script>
@@ -1,15 +1,15 @@
<template>
<div class="plaza-pricing-table overflow-x-auto" :style="accentStyle">
<table class="w-full min-w-[860px] table-fixed border-collapse text-sm tabular-nums">
<table class="w-full min-w-[1000px] table-fixed border-collapse text-sm tabular-nums">
<colgroup>
<col class="w-[22%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[25%]" />
<col class="w-[11%]" />
<col class="w-[9%]" />
<col class="w-[14%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[11%]" />
<col class="w-[8%]" />
<col class="w-[14%]" />
<col class="w-[10%]" />
<col class="w-[8%]" />
</colgroup>
<thead>
<tr
@@ -58,14 +58,25 @@
</thead>
<tbody>
<tr
v-for="m in sortedModels"
:key="`${m.platform}:${m.name}`"
v-for="{ model: m, period, key } in rows"
:key="key"
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
>
<!-- 模型名 + 非 token 计费模式徽章 -->
<!-- 模型名 + 非 token 计费模式徽章;分时时段行额外标注时段 -->
<td class="border-r border-gray-100 py-2.5 pl-5 pr-4 align-middle dark:border-dark-700/60">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
<!-- 时段徽章紧跟模型名,其余徽章排在后面,空间不足时先换行的是它们 -->
<span
v-if="period"
class="inline-flex items-center whitespace-nowrap rounded-md bg-gray-100 px-1 py-0.5 font-mono text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
:title="timePricingRowHint(m)"
>
<span v-if="m.time_pricing?.weekdays_only" class="mr-1 font-sans">{{
t('modelPlaza.table.timePricingWeekdays')
}}</span>
{{ formatTimeWindow(period) }}
</span>
<span
v-if="platform && m.platform !== platform"
:class="[
@@ -81,10 +92,17 @@
>
{{ billingModeLabel(m) }}
</span>
<span
v-if="m.long_context_basis === 'marginal'"
class="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
:title="t('modelPlaza.table.tierHintMarginal')"
>
{{ t('modelPlaza.table.marginalBadge') }}
</span>
</div>
</td>
<!-- token 计费:输入 / 输出(阶梯内联)/ 缓存(写/读) -->
<!-- token 计费:输入 / 输出 / 缓存(写/读),有阶梯时每档一行;档位标签只放输入列,其余列按行对齐 -->
<template v-if="billingMode(m) === BILLING_MODE_TOKEN">
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
<template v-if="tokenIntervals(m).length">
@@ -93,11 +111,11 @@
:key="idx"
class="whitespace-nowrap text-xs leading-5"
>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
{{ paidPerMillion(iv.input_price) }}
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500" :title="tierHint(m)">{{ tierLabel(iv) }}</span>
{{ paidPerMillion(iv.input_price, period) }}
</div>
</template>
<template v-else>{{ paidPerMillion(m.pricing?.input_price) }}</template>
<template v-else>{{ paidPerMillion(m.pricing?.input_price, period) }}</template>
</td>
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
<template v-if="tokenIntervals(m).length">
@@ -105,25 +123,41 @@
v-for="(iv, idx) in tokenIntervals(m)"
:key="idx"
class="whitespace-nowrap text-xs leading-5"
:title="tierHint(m)"
>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
{{ paidPerMillion(iv.output_price) }}
{{ paidPerMillion(iv.output_price, period) }}
</div>
</template>
<template v-else>{{ paidPerMillion(m.pricing?.output_price) }}</template>
<template v-else>{{ paidPerMillion(m.pricing?.output_price, period) }}</template>
</td>
<td class="pz-cell px-3 py-2.5 align-middle">
<template v-if="hasTierCachePricing(tokenIntervals(m))">
<div
v-for="(iv, idx) in tokenIntervals(m)"
:key="idx"
class="whitespace-nowrap font-mono text-xs leading-5 text-gray-800 dark:text-gray-200"
:title="tierHint(m)"
>
<template v-if="iv.cache_write_price != null || iv.cache_read_price != null">
<span class="font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
{{ paidPerMillion(iv.cache_write_price, period) }}
<span class="ml-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
{{ paidPerMillion(iv.cache_read_price, period) }}
</template>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</div>
</template>
<div
v-if="hasCachePricing(m)"
v-else-if="hasCachePricing(m)"
class="space-y-0.5 font-mono text-xs text-gray-800 dark:text-gray-200"
>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWrite') }}</span>
{{ paidPerMillion(m.pricing?.cache_write_price) }}
{{ paidPerMillion(m.pricing?.cache_write_price, period) }}
</div>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheRead') }}</span>
{{ paidPerMillion(m.pricing?.cache_read_price) }}
{{ paidPerMillion(m.pricing?.cache_read_price, period) }}
</div>
</div>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
@@ -157,18 +191,54 @@
</td>
</template>
<!-- 官方价格(LiteLLM 参考价,不乘倍率) -->
<!-- 官方价格(参考价,不乘倍率;官方有阶梯时每档一行) -->
<td
class="border-l border-gray-100 px-3 py-2.5 align-middle font-mono text-xs text-gray-500 dark:border-dark-700/60 dark:text-dark-400"
>
{{ official(m.official_pricing?.input_price) }}
<template v-if="officialIntervals(m).length">
<div
v-for="(iv, idx) in officialIntervals(m)"
:key="idx"
class="whitespace-nowrap leading-5"
>
<span class="mr-1 font-sans text-gray-400 dark:text-dark-500" :title="t('modelPlaza.table.tierHint')">{{ tierLabel(iv) }}</span>
{{ official(iv.input_price) }}
</div>
</template>
<template v-else>{{ official(m.official_pricing?.input_price) }}</template>
</td>
<td class="px-3 py-2.5 align-middle font-mono text-xs text-gray-500 dark:text-dark-400">
{{ official(m.official_pricing?.output_price) }}
<template v-if="officialIntervals(m).length">
<div
v-for="(iv, idx) in officialIntervals(m)"
:key="idx"
class="whitespace-nowrap leading-5"
:title="t('modelPlaza.table.tierHint')"
>
{{ official(iv.output_price) }}
</div>
</template>
<template v-else>{{ official(m.official_pricing?.output_price) }}</template>
</td>
<td class="px-3 py-2.5 align-middle">
<template v-if="hasTierCachePricing(officialIntervals(m))">
<div
v-for="(iv, idx) in officialIntervals(m)"
:key="idx"
class="whitespace-nowrap font-mono text-xs leading-5 text-gray-500 dark:text-dark-400"
:title="t('modelPlaza.table.tierHint')"
>
<template v-if="iv.cache_write_price != null || iv.cache_read_price != null">
<span class="font-sans text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
{{ official(iv.cache_write_price) }}
<span class="ml-1 font-sans text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
{{ official(iv.cache_read_price) }}
</template>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</div>
</template>
<div
v-if="m.official_pricing && hasOfficialCache(m.official_pricing)"
v-else-if="m.official_pricing && hasOfficialCache(m.official_pricing)"
class="space-y-0.5 font-mono text-xs text-gray-500 dark:text-dark-400"
>
<div>
@@ -187,12 +257,18 @@
<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"
>
<span
v-if="usesIndependentImageRate(m)"
v-if="period"
class="font-bold text-primary-600 dark:text-primary-400"
:title="t('modelPlaza.table.timePricingRateHint', { rate: effectiveRate, multiplier: period.multiplier })"
>{{ periodRate(period) }}x</span
>
<span
v-else-if="usesIndependentImageRate(m)"
class="font-bold text-gray-700 dark:text-gray-300"
>{{ requestRate(m) }}x</span
>
@@ -218,7 +294,7 @@ import {
BILLING_MODE_IMAGE,
type BillingMode
} from '@/constants/channel'
import type { PlazaModel } from '@/api/modelPlaza'
import type { PlazaModel, PlazaTimePricingPeriod } from '@/api/modelPlaza'
import type { UserPricingInterval } from '@/api/channels'
const props = defineProps<{
@@ -232,6 +308,13 @@ const props = defineProps<{
/** 生图独立倍率:true 时图片计费模型的实付倍率取 imageRateMultiplier,不取分组/专属倍率。 */
imageRateIndependent?: boolean
imageRateMultiplier?: number | null
/**
* 高峰窗口描述(含倍率与服务器时区标注),空串/缺省 = 分组未启用高峰。
* 表格所有价格均为不含高峰因子的口径,该窗口仅用于分时时段行的 tooltip 披露:
* 与高峰重叠的部分实付还会再乘高峰倍率。
*/
peakWindow?: string
peakRateMultiplier?: number | null
}>()
const { t } = useI18n()
@@ -279,10 +362,35 @@ function billingModeLabel(m: PlazaModel): string {
/** 价格统一保底 2 位小数,更长的有效小数原样保留。 */
const MIN_DECIMALS = 2
/** 实付价 = 渠道单价 × 生效倍率,按 $/1M token 展示。 */
function paidPerMillion(value: number | null | undefined): string {
/** 表格行:每个模型一行标准价;配置了分时倍率的模型再按时段各加一行。 */
interface PlazaRow {
model: PlazaModel
period: PlazaTimePricingPeriod | null
key: string
}
const rows = computed<PlazaRow[]>(() =>
sortedModels.value.flatMap((m) => {
const base: PlazaRow = { model: m, period: null, key: `${m.platform}:${m.name}` }
const periodRows = timePeriods(m).map<PlazaRow>((p, idx) => ({
model: m,
period: p,
key: `${m.platform}:${m.name}:${idx}`
}))
return [base, ...periodRows]
})
)
/** 时段行的生效倍率 = 生效倍率 × 时段倍率(去掉浮点噪声)。 */
function periodRate(period: PlazaTimePricingPeriod): number {
return Math.round(effectiveRate.value * period.multiplier * 1000) / 1000
}
/** 实付价 = 渠道单价 × 生效倍率(时段行再乘时段倍率),按 $/1M token 展示。 */
function paidPerMillion(value: number | null | undefined, period: PlazaTimePricingPeriod | null = null): string {
if (value == null) return '-'
return formatScaled(value * effectiveRate.value, PER_MILLION, MIN_DECIMALS)
const rate = period ? periodRate(period) : effectiveRate.value
return formatScaled(value * rate, PER_MILLION, MIN_DECIMALS)
}
/** 图片计费模型且分组开启生图独立倍率:实付倍率取独立倍率,与计费口径一致。 */
@@ -322,23 +430,76 @@ function hasOfficialCache(o: NonNullable<PlazaModel['official_pricing']>): boole
return o.cache_write_price != null || o.cache_read_price != null || o.cache_write_1h_price != null
}
/** token 模式的阶梯定价(内联进输入/输出列)。 */
function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
return m.pricing?.intervals ?? []
/** 分时倍率时段(后端只给出倍率 ≠ 1 的时段,已升序)。 */
function timePeriods(m: PlazaModel): PlazaTimePricingPeriod[] {
return m.time_pricing?.periods ?? []
}
/**
* 时段行 tooltip:仅工作日生效的配置换用带周末回落说明的文案;
* 分组启用高峰倍率时追加披露——本行价格不含高峰因子,与高峰窗口重叠的部分实付再乘高峰倍率。
*/
function timePricingRowHint(m: PlazaModel): string {
const key = m.time_pricing?.weekdays_only
? 'modelPlaza.table.timePricingRowHintWeekdays'
: 'modelPlaza.table.timePricingRowHint'
let hint = t(key, { timezone: m.time_pricing?.timezone })
if (props.peakWindow) {
hint += t('modelPlaza.table.timePricingRowHintPeak', {
window: props.peakWindow,
multiplier: props.peakRateMultiplier ?? 1
})
}
return hint
}
/** “00:30–08:30”;整分钟的 HH:mm:ss 省略秒。 */
function formatTimeWindow(p: PlazaTimePricingPeriod): string {
const clock = (v: string) => v.replace(/^(\d{2}:\d{2}):00$/, '$1')
return `${clock(p.start_time)}–${clock(p.end_time)}`
}
/** 上下文档位按下限升序展示(后端已升序,此处兜底)。 */
function sortByContext(intervals: UserPricingInterval[]): UserPricingInterval[] {
return [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
}
/** token 模式的阶梯定价(内联进输入/输出/缓存列)。 */
function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
return sortByContext(m.pricing?.intervals ?? [])
}
/** 官方阶梯(后端按目录规则合成,不受分组开关影响)。 */
function officialIntervals(m: PlazaModel): UserPricingInterval[] {
return sortByContext(m.official_pricing?.intervals ?? [])
}
/** 任一档带缓存价才按档渲染缓存列;否则沿用平价的写入/读取两行。 */
function hasTierCachePricing(intervals: UserPricingInterval[]): boolean {
return intervals.some((iv) => iv.cache_write_price != null || iv.cache_read_price != null)
}
/** 档位说明:整单按档计价,或(平台旧规则)仅超出部分按档计价。 */
function tierHint(m: PlazaModel): string {
return m.long_context_basis === 'marginal'
? t('modelPlaza.table.tierHintMarginal')
: t('modelPlaza.table.tierHint')
}
/** 按次/按图模式的阶梯定价(仅保留配了按次价的档位)。 */
function requestIntervals(m: PlazaModel): UserPricingInterval[] {
return (m.pricing?.intervals ?? []).filter((iv) => iv.per_request_price != null)
}
/** 档位标签:优先管理员配置的 tier_label,否则按 token 区间生成(≤200K / >200K / 200K–1M)。 */
/**
* 档位标签:优先后端/管理员给出的 tier_label,否则按区间生成统一形态——
* 有上限为「≤上限」,末档为「>下限」;档位升序排列,相邻的 ≤100K / ≤200K 即表示 (100K,200K]。
*/
function tierLabel(iv: UserPricingInterval): string {
if (iv.tier_label) return iv.tier_label
const { min_tokens: min, max_tokens: max } = iv
if (max == null) return `>${formatTokenCount(min)}`
if (min === 0) return `≤${formatTokenCount(max)}`
return `${formatTokenCount(min)}–${formatTokenCount(max)}`
return max == null ? `>${formatTokenCount(min)}` : `≤${formatTokenCount(max)}`
}
function formatTokenCount(n: number): string {
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import PlazaGroupSection from '../PlazaGroupSection.vue'
import PlazaModelPricingTable from '../PlazaModelPricingTable.vue'
import type { ModelPlazaGroup, PlazaModel } from '@/api/modelPlaza'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ cachedPublicSettings: null })
}))
function ladderModel(tiers: number): PlazaModel {
const intervals = Array.from({ length: tiers }, (_, i) => ({
min_tokens: i * 272000,
max_tokens: i === tiers - 1 ? null : (i + 1) * 272000,
tier_label: '',
input_price: 5e-6,
output_price: 3e-5,
cache_write_price: null,
cache_read_price: null,
per_request_price: null
}))
return {
name: 'gpt-5.6-sol',
platform: 'openai',
pricing: {
billing_mode: 'token',
input_price: 5e-6,
output_price: 3e-5,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: []
},
official_pricing: {
input_price: 5e-6,
output_price: 3e-5,
cache_write_price: null,
cache_read_price: null,
intervals
}
}
}
function group(overrides: Partial<ModelPlazaGroup> = {}): ModelPlazaGroup {
return {
id: 1,
name: 'g',
description: '',
platform: 'openai',
subscription_type: 'standard',
rate_multiplier: 1,
peak_rate_enabled: false,
peak_start: '',
peak_end: '',
peak_rate_multiplier: 1,
is_exclusive: false,
image_rate_independent: false,
image_rate_multiplier: 1,
long_context_pricing_enabled: true,
models: [ladderModel(2)],
...overrides
}
}
function mountSection(g: ModelPlazaGroup) {
return mount(PlazaGroupSection, {
props: { group: g },
global: {
stubs: {
GroupBadge: true,
Icon: true,
PlazaModelPricingTable: true
}
}
})
}
const NOTE = 'modelPlaza.detail.longContextDisabledNote'
describe('PlazaGroupSection 长上下文说明', () => {
it('分组关闭阶梯且组内有官方阶梯模型时显示说明', () => {
const wrapper = mountSection(group({ long_context_pricing_enabled: false }))
expect(wrapper.text()).toContain(NOTE)
})
it('分组开启阶梯时不显示', () => {
const wrapper = mountSection(group({ long_context_pricing_enabled: true }))
expect(wrapper.text()).not.toContain(NOTE)
})
it('分组关闭但没有官方阶梯模型时不显示', () => {
const wrapper = mountSection(
group({ long_context_pricing_enabled: false, models: [ladderModel(1)] })
)
expect(wrapper.text()).not.toContain(NOTE)
})
it('旧后端缺少开关字段时不显示', () => {
const g = group()
delete (g as Partial<ModelPlazaGroup>).long_context_pricing_enabled
const wrapper = mountSection(g)
expect(wrapper.text()).not.toContain(NOTE)
})
})
describe('PlazaGroupSection 高峰配置传递', () => {
it('分组启用高峰时把窗口描述与倍率传给价格表', () => {
const wrapper = mountSection(
group({
subscription_type: 'subscription',
peak_rate_enabled: true,
peak_start: '14:00',
peak_end: '18:00',
peak_rate_multiplier: 1.5
})
)
const table = wrapper.findComponent(PlazaModelPricingTable)
// appStore mock 无 server_utc_offset,窗口描述不带时区标注
expect(table.props('peakWindow')).toBe('14:00-18:00 ×1.5')
expect(table.props('peakRateMultiplier')).toBe(1.5)
})
it('分组未启用高峰时窗口描述为空串', () => {
const wrapper = mountSection(group())
expect(wrapper.findComponent(PlazaModelPricingTable).props('peakWindow')).toBe('')
})
})
@@ -43,7 +43,12 @@ function mountTable(
models: PlazaModel[],
rateMultiplier: number,
userRateMultiplier?: number | null,
extraProps?: { imageRateIndependent?: boolean; imageRateMultiplier?: number | null }
extraProps?: {
imageRateIndependent?: boolean
imageRateMultiplier?: number | null
peakWindow?: string
peakRateMultiplier?: number | null
}
) {
return mount(PlazaModelPricingTable, {
props: { models, rateMultiplier, userRateMultiplier: userRateMultiplier ?? null, ...extraProps }
@@ -405,3 +410,225 @@ describe('PlazaModelPricingTable', () => {
expect(wrapper.text()).toContain('OpenAI')
})
})
describe('PlazaModelPricingTable 长上下文阶梯', () => {
function ladderIntervals() {
return [
{
min_tokens: 0,
max_tokens: 272000,
tier_label: '≤272K',
input_price: 5e-6,
output_price: 3e-5,
cache_write_price: 6.25e-6,
cache_read_price: 5e-7,
per_request_price: null
},
{
min_tokens: 272000,
max_tokens: null,
tier_label: '>272K',
input_price: 1e-5,
output_price: 4.5e-5,
cache_write_price: 1.25e-5,
cache_read_price: 1e-6,
per_request_price: null
}
]
}
function ladderModel(overrides: Partial<PlazaModel> = {}): PlazaModel {
return tokenModel({
name: 'gpt-5.6-sol',
platform: 'openai',
pricing: {
billing_mode: 'token',
input_price: 5e-6,
output_price: 3e-5,
cache_write_price: 6.25e-6,
cache_read_price: 5e-7,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: ladderIntervals()
},
official_pricing: {
input_price: 5e-6,
output_price: 3e-5,
cache_write_price: 6.25e-6,
cache_read_price: 5e-7,
intervals: ladderIntervals()
},
long_context_basis: 'whole_request',
...overrides
})
}
it('实付缓存列按档分行并乘倍率,每档一行与输入/输出列对齐;档位标签只在输入列', () => {
const wrapper = mountTable([ladderModel()], 0.5)
const cells = wrapper.findAll('tbody td')
const cacheCell = cells[3]
const rows = cacheCell.findAll('.leading-5')
expect(rows).toHaveLength(2)
// 写 6.25 × 0.5 / 读 0.5 × 0.5;高档 12.5 × 0.5 / 1 × 0.5
expect(rows[0].text()).toContain('modelPlaza.table.cacheWriteShort')
expect(rows[0].text()).toContain('$3.125')
expect(rows[0].text()).toContain('$0.25')
expect(rows[1].text()).toContain('$6.25')
expect(rows[1].text()).toContain('$0.50')
// 输入列带标签,输出/缓存列只按行对齐不重复标签
expect(cells[1].text()).toContain('≤272K')
expect(cells[1].text()).toContain('>272K')
expect(cells[2].text()).not.toContain('272K')
expect(cacheCell.text()).not.toContain('272K')
expect(cells[1].findAll('.leading-5')).toHaveLength(2)
expect(cells[2].findAll('.leading-5')).toHaveLength(2)
})
it('官方三列按 official_pricing.intervals 分档且不乘倍率,不内联 1h', () => {
const wrapper = mountTable([ladderModel()], 0.5)
const cells = wrapper.findAll('tbody td')
expect(cells[4].text()).toContain('≤272K')
expect(cells[4].text()).toContain('$5.00')
expect(cells[4].text()).toContain('>272K')
expect(cells[4].text()).toContain('$10.00')
expect(cells[5].text()).toContain('$30.00')
expect(cells[5].text()).toContain('$45.00')
expect(cells[6].text()).toContain('$6.25')
expect(cells[6].text()).toContain('$12.50')
expect(cells[6].text()).toContain('$1.00')
expect(cells[6].text()).not.toContain('(1h')
})
it('整单计价的档位标签带 tooltip;边际计价在模型名旁加徽章并换用边际说明', () => {
const whole = mountTable([ladderModel()], 1)
const wholeLabels = whole.findAll('tbody td span[title="modelPlaza.table.tierHint"]')
expect(wholeLabels.length).toBeGreaterThan(0)
expect(whole.text()).not.toContain('modelPlaza.table.marginalBadge')
const marginal = mountTable([ladderModel({ long_context_basis: 'marginal' })], 1)
const marginalLabels = marginal.findAll('tbody td span[title="modelPlaza.table.tierHintMarginal"]')
expect(marginalLabels.length).toBeGreaterThan(0)
expect(marginal.findAll('tbody td')[0].text()).toContain('modelPlaza.table.marginalBadge')
})
it('无标签的多档按区间生成统一形态(≤上限 / >下限),并按下限升序展示', () => {
const model = ladderModel({
pricing: {
...ladderModel().pricing!,
// 故意乱序:展示必须按上下文从低到高
intervals: [
{ ...ladderIntervals()[1], min_tokens: 1000000, tier_label: '' },
{ ...ladderIntervals()[0], min_tokens: 100000, max_tokens: 200000, tier_label: '' },
{ ...ladderIntervals()[0], max_tokens: 100000, tier_label: '' },
{ ...ladderIntervals()[1], min_tokens: 200000, max_tokens: 1000000, tier_label: '' }
]
}
})
const rows = mountTable([model], 1).findAll('tbody td')[1].findAll('.leading-5')
expect(rows.map((r) => r.text().split(/\s+/)[0])).toEqual(['≤100K', '≤200K', '≤1M', '>1M'])
})
it('官方无 intervals 字段(旧响应)时官方列保持平价,实付无阶梯时缓存列保持两行', () => {
const wrapper = mountTable([tokenModel()], 1)
const cells = wrapper.findAll('tbody td')
expect(cells[3].text()).toContain('modelPlaza.table.cacheWrite')
expect(cells[3].text()).toContain('modelPlaza.table.cacheRead')
expect(cells[3].findAll('.leading-5')).toHaveLength(0)
expect(cells[6].text()).toContain('(1h')
})
})
describe('PlazaModelPricingTable 分时计价', () => {
function timePricedModel() {
return tokenModel({
name: 'deepseek-chat',
platform: 'deepseek',
time_pricing: {
timezone: 'Asia/Shanghai',
periods: [
{ start_time: '00:30', end_time: '08:30:00', multiplier: 0.5 },
{ start_time: '18:00', end_time: '22:00', multiplier: 1.2 }
]
}
})
}
it('有分时倍率的模型展开为标准行 + 每时段一行,时段行价格按倍率折算且倍率列显示生效倍率', () => {
const wrapper = mountTable([timePricedModel()], 0.8)
const trs = wrapper.findAll('tbody tr')
expect(trs).toHaveLength(3)
// 标准行:输入 3 × 0.8
const baseCells = trs[0].findAll('td')
expect(baseCells[0].text()).toBe('deepseek-chat')
expect(baseCells[1].text()).toContain('$2.40')
expect(baseCells[7].text()).toContain('0.8x')
// 夜间时段行:输入 3 × 0.8 × 0.5,倍率 0.4x,标注时段不含时区
const nightCells = trs[1].findAll('td')
expect(nightCells[0].text()).toContain('deepseek-chat')
expect(nightCells[0].text()).toContain('00:30–08:30')
expect(nightCells[0].text()).not.toContain('Asia/Shanghai')
// 时区只放在 tooltip 里(i18n mock 不做插值,这里只断言挂了说明)
expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(true)
expect(nightCells[1].text()).toContain('$1.20')
expect(nightCells[2].text()).toContain('$6.00')
expect(nightCells[3].text()).toContain('$1.50')
expect(nightCells[7].text()).toContain('0.4x')
// 晚高峰行:3 × 0.8 × 1.2 = 2.88,倍率 0.96x
const peakCells = trs[2].findAll('td')
expect(peakCells[0].text()).toContain('18:00–22:00')
expect(peakCells[1].text()).toContain('$2.88')
expect(peakCells[7].text()).toContain('0.96x')
// 官方列不受时段影响
expect(nightCells[4].text()).toContain('$3.00')
})
it('仅工作日生效时时段行带工作日前缀,tooltip 换用周末回落文案', () => {
const model = timePricedModel()
model.time_pricing!.weekdays_only = true
const wrapper = mountTable([model], 1)
const trs = wrapper.findAll('tbody tr')
expect(trs).toHaveLength(3)
const nightCells = trs[1].findAll('td')
expect(nightCells[0].text()).toContain('modelPlaza.table.timePricingWeekdays')
expect(nightCells[0].text()).toContain('00:30–08:30')
expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHintWeekdays"]').exists()).toBe(true)
expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(false)
})
it('每日生效(无 weekdays_only)不渲染工作日前缀', () => {
const wrapper = mountTable([timePricedModel()], 1)
expect(wrapper.find('tbody').text()).not.toContain('modelPlaza.table.timePricingWeekdays')
expect(wrapper.find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(true)
})
it('分组启用高峰倍率时时段行 tooltip 追加高峰披露,价格与倍率列保持不含高峰的口径', () => {
const wrapper = mountTable([timePricedModel()], 0.8, null, {
peakWindow: '14:00-18:00 ×1.5 (UTC+08:00)',
peakRateMultiplier: 1.5
})
const nightCells = wrapper.findAll('tbody tr')[1].findAll('td')
const title = nightCells[0].find('[title*="modelPlaza.table.timePricingRowHint"]').attributes('title')
expect(title).toContain('modelPlaza.table.timePricingRowHintPeak')
// 行内数字仍是 基础倍率 × 时段倍率(0.8 × 0.5),高峰只进披露不进价格
expect(nightCells[1].text()).toContain('$1.20')
expect(nightCells[7].text()).toContain('0.4x')
})
it('分组未启用高峰(peakWindow 缺省)时 tooltip 不含高峰披露', () => {
const wrapper = mountTable([timePricedModel()], 1)
const badge = wrapper.find('[title*="modelPlaza.table.timePricingRowHint"]')
expect(badge.attributes('title')).not.toContain('modelPlaza.table.timePricingRowHintPeak')
})
it('无分时倍率时只有一行,不渲染时段标注', () => {
const wrapper = mountTable([tokenModel()], 1)
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
expect(wrapper.find('[title*="modelPlaza.table.timePricingRowHint"]').exists()).toBe(false)
})
})