feat(pricing): support processing tier multipliers

This commit is contained in:
elky
2026-07-16 23:30:42 +08:00
parent 312583f055
commit 0be380243b
40 changed files with 5012 additions and 565 deletions
@@ -398,6 +398,7 @@
:show-token-pricing="billingMode === 'token'"
:show-image-pricing="isImageGenerationEnabled"
:show-image-editor="billingMode === 'image'"
:show-processing-tier-multiplier-controls="true"
/>
<TabsContent
@@ -137,7 +137,6 @@
</p>
</div>
</div>
</div>
<!-- 默认定价 -->
@@ -34,6 +34,15 @@
</div>
</div>
<div
v-if="activePriceMultiplier !== null"
class="flex items-center justify-between rounded-md border bg-muted/20 px-3 py-2 text-xs"
data-testid="processing-tier-price-multiplier"
>
<span class="text-muted-foreground">相对 Standard</span>
<span class="font-mono font-medium text-foreground">{{ formatMultiplier(activePriceMultiplier) }}×</span>
</div>
<div
v-if="activeTokenTiers.length > 0"
class="overflow-x-auto rounded-md border"
@@ -226,7 +235,8 @@ const props = defineProps<{
}>()
const KNOWN_PROCESSING_TIERS = [
{ key: 'priority', label: 'Priority' },
{ key: 'priority', label: 'Fast(OpenAI)' },
{ key: 'fast', label: 'Fast(Claude)' },
{ key: 'flex', label: 'Flex' },
{ key: 'batch', label: 'Batch' },
] as const
@@ -264,6 +274,14 @@ const activeTokenTiers = computed<PricingTier[]>(() =>
? activeEntry.value.config.tiers.filter(isRecord) as PricingTier[]
: [],
)
const activePriceMultiplier = computed(() => {
const config = activeEntry.value?.config
if (!config || processingPricingHasExplicitFacts(config)) return null
const multiplier = config.price_multiplier
return typeof multiplier === 'number' && Number.isFinite(multiplier) && multiplier >= 0
? multiplier
: null
})
const activeImageDefaultPrice = computed(() =>
toFiniteNumber(activeEntry.value?.config.image_output_price_default),
)
@@ -311,6 +329,15 @@ const imageTableMinWidthClass = computed(() =>
)
function processingPricingHasFacts(config: ProcessingTierPricingConfig): boolean {
if (
typeof config.price_multiplier === 'number'
&& Number.isFinite(config.price_multiplier)
&& config.price_multiplier >= 0
) return true
return processingPricingHasExplicitFacts(config)
}
function processingPricingHasExplicitFacts(config: ProcessingTierPricingConfig): boolean {
if (Array.isArray(config.tiers) && config.tiers.length > 0) return true
if (toFiniteNumber(config.image_output_price_default) !== null) return true
if (isRecord(config.image_output_prices)) {
@@ -318,12 +345,26 @@ function processingPricingHasFacts(config: ProcessingTierPricingConfig): boolean
if (isRecord(prices) && Object.keys(finitePriceRecord(prices)).length > 0) return true
}
}
return Array.isArray(config.image_output_price_ranges)
if (Array.isArray(config.image_output_price_ranges)
&& config.image_output_price_ranges.some(range => (
isRecord(range)
&& isRecord(range.prices)
&& Object.keys(finitePriceRecord(range.prices)).length > 0
))
))) return true
return [
'image_output_price_per_image',
'image_output_price_matrix',
'image_prices',
].some(key => valueHasEntries(config[key]))
}
function valueHasEntries(value: unknown): boolean {
return (Array.isArray(value) && value.length > 0)
|| (isRecord(value) && Object.keys(value).length > 0)
}
function formatMultiplier(value: number): string {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6)))
}
function formatTokenRange(tiers: PricingTier[], index: number): string {
@@ -1,6 +1,9 @@
<template>
<div class="space-y-3">
<div class="space-y-2 border-b border-border/60 pb-3">
<div
v-if="showProcessingTierControls"
class="space-y-2 border-b border-border/60 pb-3"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-foreground">
@@ -55,210 +58,355 @@
</div>
<div
v-if="!isActivePricingScopeConfigured"
v-if="showProcessingTierControls && !isActivePricingScopeConfigured"
class="flex flex-wrap items-center justify-between gap-3 py-4"
data-testid="processing-tier-empty"
>
<p class="text-sm text-muted-foreground">
未配置 {{ activeProcessingTierLabel }} 费率
</p>
<Button
type="button"
variant="outline"
size="sm"
data-testid="processing-tier-add"
@click="addActiveProcessingTier"
>
<Plus class="mr-2 h-4 w-4" />
添加费率
</Button>
<div class="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
data-testid="processing-tier-add-multiplier"
@click="startActiveProcessingTierMultiplier"
>
<Plus class="mr-2 h-4 w-4" />
使用倍率
</Button>
<Button
type="button"
variant="outline"
size="sm"
data-testid="processing-tier-add"
@click="addActiveProcessingTier"
>
<Plus class="mr-2 h-4 w-4" />
添加自定义费率
</Button>
</div>
</div>
<template v-else>
<template v-if="showTokenPricing !== false">
<!-- 阶梯列表 -->
<div
v-for="(tier, index) in localTiers"
:key="index"
class="space-y-3 border-b border-border/60 pb-3 last:border-b-0"
>
<!-- 阶梯头部 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
<span class="text-muted-foreground">-</span>
<template v-if="isTierUpperBoundEditable(index)">
<template v-if="customInputMode[index]">
<Input
v-model="customInputValue[index]"
type="number"
min="1"
class="h-7 w-20 text-sm"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 自定义上限(千 Token)`"
placeholder="K"
@keyup.enter="confirmCustomInput(index)"
@blur="confirmCustomInput(index)"
/>
<span class="text-xs text-muted-foreground">K</span>
</template>
<select
v-else
:value="getSelectValue(index)"
class="h-7 px-2 text-sm border rounded bg-background"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 上限`"
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
>
<option
v-for="opt in getAvailableThresholds(index)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</template>
<span
v-else
class="font-medium"
>无上限</span>
</div>
<div class="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-muted-foreground"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 切换缓存价格输入方式`"
@click="toggleCachePriceMode(index)"
>
<Repeat2 class="mr-1 h-3.5 w-3.5" />
{{ getCachePriceMode(index) === 'multiplier' ? '价格' : '倍率' }}
</Button>
<Button
v-if="localTiers.length > 1"
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
:aria-label="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
:title="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
@click="removeTier(index)"
>
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<div
v-if="showProcessingTierControls && activeProcessingTierUsesMultiplier"
class="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3"
data-testid="processing-tier-multiplier-editor"
>
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="space-y-1">
<Label class="text-xs font-medium">层级倍率(相对 Standard)</Label>
<p class="text-xs text-muted-foreground">
该层级按 Standard 的完整价格目录统一缩放。
</p>
</div>
<!-- 价格输入 -->
<div
class="grid gap-3"
:class="[showCache1h ? 'grid-cols-2 lg:grid-cols-5' : 'grid-cols-2 lg:grid-cols-4']"
<Button
type="button"
variant="outline"
size="sm"
data-testid="processing-tier-use-custom"
@click="useCustomPricingForActiveProcessingTier"
>
<div class="space-y-1">
<Label class="text-xs">输入 ($/M)</Label>
<Input
:model-value="tier.input_price_per_1m"
data-testid="tier-input-price"
:data-tier-index="index"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输入价格(美元/百万 Token)`"
placeholder="0"
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs">输出 ($/M)</Label>
<Input
:model-value="tier.output_price_per_1m"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输出价格(美元/百万 Token)`"
placeholder="0"
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">
{{ getCachePriceMode(index) === 'multiplier' ? '创建(倍率)' : '创建 ($/M)' }}
</Label>
<div class="relative">
<Input
:model-value="getCacheCreationEditorValue(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存创建${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
placeholder="0"
@update:model-value="(v) => updateCacheCreation(index, v)"
/>
改用自定义价格
</Button>
</div>
<div class="relative max-w-40">
<Input
:model-value="activeProcessingTierMultiplierDraft?.value ?? ''"
type="number"
min="0"
step="0.01"
class="h-8 pr-7"
data-testid="processing-tier-multiplier-input"
:aria-label="`${activeProcessingTierLabel} 层级倍率`"
placeholder="请输入倍率"
@update:model-value="updateActiveProcessingTierMultiplier"
/>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">×</span>
</div>
</div>
<template v-else-if="isActivePricingScopeConfigured">
<template v-if="showTokenPricing !== false">
<!-- 阶梯列表 -->
<div
v-for="(tier, index) in localTiers"
:key="index"
class="space-y-3 border-b border-border/60 pb-3 last:border-b-0"
>
<!-- 阶梯头部 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
<span class="text-muted-foreground">-</span>
<template v-if="isTierUpperBoundEditable(index)">
<template v-if="customInputMode[index]">
<Input
v-model="customInputValue[index]"
type="number"
min="1"
class="h-7 w-20 text-sm"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 自定义上限(千 Token)`"
placeholder="K"
@keyup.enter="confirmCustomInput(index)"
@blur="confirmCustomInput(index)"
/>
<span class="text-xs text-muted-foreground">K</span>
</template>
<select
v-else
:value="getSelectValue(index)"
class="h-7 px-2 text-sm border rounded bg-background"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 上限`"
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
>
<option
v-for="opt in getAvailableThresholds(index)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</template>
<span
v-if="getCachePriceMode(index) === 'multiplier'"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
>×</span>
v-else
class="font-medium"
>无上限</span>
</div>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">
{{ getCachePriceMode(index) === 'multiplier' ? '读取(倍率)' : '读取 ($/M)' }}
</Label>
<div class="relative">
<Input
:model-value="getCacheReadEditorValue(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存读取${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
placeholder="0"
@update:model-value="(v) => updateCacheRead(index, v)"
/>
<span
v-if="getCachePriceMode(index) === 'multiplier'"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
>×</span>
<div class="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-muted-foreground"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 切换缓存价格输入方式`"
@click="toggleCachePriceMode(index)"
>
<Repeat2 class="mr-1 h-3.5 w-3.5" />
{{ getCachePriceMode(index) === 'multiplier' ? '价格' : '倍率' }}
</Button>
<Button
v-if="localTiers.length > 1"
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
:aria-label="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
:title="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
@click="removeTier(index)"
>
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
</div>
<!-- 价格输入 -->
<div
v-if="showCache1h"
class="space-y-1"
class="grid gap-3"
:class="[showCache1h ? 'grid-cols-2 lg:grid-cols-5' : 'grid-cols-2 lg:grid-cols-4']"
>
<Label class="text-xs text-muted-foreground">1h 缓存</Label>
<Input
:model-value="getCache1hDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 一小时缓存价格`"
:placeholder="getCache1hPlaceholder(index)"
@update:model-value="(v) => updateCache1h(index, v)"
/>
<div class="space-y-1">
<Label class="text-xs">输入 ($/M)</Label>
<Input
:model-value="tier.input_price_per_1m"
data-testid="tier-input-price"
:data-tier-index="index"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输入价格(美元/百万 Token)`"
placeholder="0"
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs">输出 ($/M)</Label>
<Input
:model-value="tier.output_price_per_1m"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输出价格(美元/百万 Token)`"
placeholder="0"
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">
{{ getCachePriceMode(index) === 'multiplier' ? '创建(倍率)' : '创建 ($/M)' }}
</Label>
<div class="relative">
<Input
:model-value="getCacheCreationEditorValue(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存创建${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
placeholder="0"
@update:model-value="(v) => updateCacheCreation(index, v)"
/>
<span
v-if="getCachePriceMode(index) === 'multiplier'"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
>×</span>
</div>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">
{{ getCachePriceMode(index) === 'multiplier' ? '读取(倍率)' : '读取 ($/M)' }}
</Label>
<div class="relative">
<Input
:model-value="getCacheReadEditorValue(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存读取${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
placeholder="0"
@update:model-value="(v) => updateCacheRead(index, v)"
/>
<span
v-if="getCachePriceMode(index) === 'multiplier'"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
>×</span>
</div>
</div>
<div
v-if="showCache1h"
class="space-y-1"
>
<Label class="text-xs text-muted-foreground">1h 缓存</Label>
<Input
:model-value="getCache1hDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 一小时缓存价格`"
:placeholder="getCache1hPlaceholder(index)"
@update:model-value="(v) => updateCache1h(index, v)"
/>
</div>
</div>
</div>
</div>
<!-- 添加阶梯按钮 -->
<Button
variant="outline"
size="sm"
class="w-full"
@click="addTier"
>
<Plus class="w-4 h-4 mr-2" />
添加价格阶梯
</Button>
<!-- 添加阶梯按钮 -->
<Button
variant="outline"
size="sm"
class="w-full"
@click="addTier"
>
<Plus class="w-4 h-4 mr-2" />
添加价格阶梯
</Button>
</template>
</template>
<div
v-if="showImagePricing && showImageEditor !== false && isActivePricingScopeConfigured"
v-if="showProcessingTierMultiplierControls && showTokenPricing !== false"
class="space-y-3 border-t border-border/60 pt-3"
data-testid="processing-tier-multiplier-list"
>
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">
层级倍率(相对标准价格)
</p>
<p class="text-xs text-muted-foreground">
配置模型级默认倍率;层级是否可用由 Provider 端点/API 格式决定。
</p>
</div>
<div class="space-y-3">
<div
v-for="group in compactProcessingTierGroups"
:key="group.key"
class="space-y-2"
:data-processing-tier-group="group.key"
>
<p
v-if="group.label"
class="px-1 text-sm font-medium text-foreground"
:data-testid="`processing-tier-group-${group.key}`"
>
{{ group.label }}
</p>
<div
class="space-y-2"
:class="group.label ? 'border-l-2 border-border/60 pl-3' : ''"
>
<div
v-for="option in group.options"
:key="option.key"
class="flex min-h-10 flex-wrap items-center gap-3 rounded-md border border-border/60 px-3 py-2"
:data-processing-tier-multiplier="option.key"
>
<Checkbox
:checked="option.enabled"
:aria-label="`启用 ${option.accessibleLabel} 层级倍率`"
@update:checked="enabled => setCompactProcessingTierEnabled(option.key, enabled)"
/>
<div class="min-w-32 flex-1">
<p class="text-sm font-medium">
{{ option.label }}
</p>
<p
v-if="option.detail"
class="text-xs text-muted-foreground"
>
{{ option.detail }}
</p>
<p
v-if="option.mode === 'custom'"
class="text-xs text-muted-foreground"
>
已配置自定义价格目录
</p>
</div>
<template v-if="option.mode === 'custom'">
<span class="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">自定义价格</span>
<Button
type="button"
variant="outline"
size="sm"
:data-testid="`processing-tier-convert-${option.key}`"
@click="startProcessingTierMultiplier(option.key)"
>
改用倍率
</Button>
</template>
<div
v-else
class="relative w-36"
>
<Input
:model-value="option.value"
type="number"
min="0"
step="0.01"
class="h-8 pr-7"
:disabled="!option.enabled"
:data-testid="`processing-tier-multiplier-${option.key}`"
:aria-label="`${option.accessibleLabel} 层级倍率`"
placeholder="未设置"
@update:model-value="value => updateProcessingTierMultiplier(option.key, value)"
/>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">×</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-if="showImagePricing && showImageEditor !== false && isActivePricingScopeConfigured && !activeProcessingTierUsesMultiplier"
class="space-y-3 border-t border-border/60 pt-3"
>
<div class="flex flex-wrap items-end justify-between gap-3">
@@ -424,7 +572,7 @@
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue'
import { Plus, Repeat2, Trash2, X } from 'lucide-vue-next'
import { Button, Input, Label } from '@/components/ui'
import { Button, Checkbox, Input, Label } from '@/components/ui'
import { formatTokens } from '@/utils/format'
import type {
ImageOutputQualityPricing,
@@ -470,6 +618,23 @@ type ProcessingTierOption = {
label: string
configured: boolean
}
type ProcessingTierMultiplierDraft = {
enabled: boolean
mode: 'multiplier' | 'custom'
value: string
}
type CompactProcessingTierOption = ProcessingTierMultiplierDraft & {
key: string
label: string
detail?: string
group?: string
accessibleLabel: string
}
type CompactProcessingTierGroup = {
key: string
label: string | null
options: CompactProcessingTierOption[]
}
type PricingScopePolicy = {
allowEmptyTiers: boolean
terminalUpperBound: 'require-unbounded' | 'finite-or-unbounded'
@@ -481,10 +646,15 @@ const props = withDefaults(defineProps<{
showCache1h?: boolean
showImagePricing?: boolean
showImageEditor?: boolean
showProcessingTierControls?: boolean
showProcessingTierMultiplierControls?: boolean
autoFillMissingCachePrices?: boolean
}>(), {
modelValue: null,
showTokenPricing: true,
showImageEditor: true,
showProcessingTierControls: true,
showProcessingTierMultiplierControls: false,
autoFillMissingCachePrices: true,
})
const emit = defineEmits<{
@@ -497,7 +667,14 @@ const STANDARD_PRICING_SCOPE = 'standard'
const PROCESSING_PRICING_SCOPE_PREFIX = 'processing:'
const UNBOUNDED_THRESHOLD_VALUE = -2
const KNOWN_PROCESSING_TIERS = [
{ key: 'priority', label: 'Priority' },
{ key: 'priority', label: 'Fast(OpenAI)' },
{ key: 'fast', label: 'Fast(Claude)' },
{ key: 'flex', label: 'Flex' },
{ key: 'batch', label: 'Batch' },
] as const
const COMPACT_PROCESSING_TIERS = [
{ key: 'priority', label: 'OpenAI', detail: 'Chat / Responses', group: 'Fast' },
{ key: 'fast', label: 'Claude', detail: 'Messages', group: 'Fast' },
{ key: 'flex', label: 'Flex' },
{ key: 'batch', label: 'Batch' },
] as const
@@ -519,11 +696,17 @@ const cacheManualStateByScope = reactive<Record<string, Record<number, CacheManu
const cachePriceModesByScope = reactive<Record<string, Record<number, CachePriceMode>>>({})
const cacheMultiplierDraftsByScope = reactive<Record<string, Record<number, CacheMultiplierDraft>>>({})
const imagePricingStateByScope = reactive<Record<string, ImagePricingState>>({})
const processingTierMultiplierDrafts = reactive<Record<string, ProcessingTierMultiplierDraft>>(
Object.create(null) as Record<string, ProcessingTierMultiplierDraft>,
)
const activeProcessingTierKey = computed(() => processingTierKeyFromScope(activePricingScope.value))
const isActiveProcessingTierConfigured = computed(() => {
const key = activeProcessingTierKey.value
return key !== null && hasOwn(processingTierConfigs.value, key)
return key !== null && (
hasOwn(processingTierConfigs.value, key)
|| processingTierMultiplierDrafts[key]?.enabled === true
)
})
const isActivePricingScopeConfigured = computed(() => (
activePricingScope.value === STANDARD_PRICING_SCOPE || isActiveProcessingTierConfigured.value
@@ -533,6 +716,41 @@ const activeProcessingTierLabel = computed(() => {
if (key === null) return 'Standard'
return KNOWN_PROCESSING_TIERS.find(tier => tier.key === key)?.label ?? key
})
const activeProcessingTierMultiplierDraft = computed(() => {
const key = activeProcessingTierKey.value
return key === null ? null : processingTierMultiplierDrafts[key] ?? null
})
const activeProcessingTierUsesMultiplier = computed(() => (
activeProcessingTierKey.value !== null
&& activeProcessingTierMultiplierDraft.value?.enabled === true
&& activeProcessingTierMultiplierDraft.value.mode === 'multiplier'
))
const compactProcessingTierOptions = computed<CompactProcessingTierOption[]>(() => (
COMPACT_PROCESSING_TIERS.map(option => ({
...option,
accessibleLabel: [option.group, option.label, 'detail' in option ? option.detail : null]
.filter((part): part is string => Boolean(part))
.join(' · '),
...(processingTierMultiplierDrafts[option.key] ?? {
enabled: false,
mode: 'multiplier' as const,
value: '',
}),
}))
))
const compactProcessingTierGroups = computed<CompactProcessingTierGroup[]>(() => {
const groups: CompactProcessingTierGroup[] = []
for (const option of compactProcessingTierOptions.value) {
const key = option.group ? option.group.toLowerCase() : option.key
const existing = groups.find(group => group.key === key)
if (existing) {
existing.options.push(option)
} else {
groups.push({ key, label: option.group ?? null, options: [option] })
}
}
return groups
})
const processingTierOptions = computed<ProcessingTierOption[]>(() => {
const knownKeys = new Set<string>(KNOWN_PROCESSING_TIERS.map(tier => tier.key))
const options: ProcessingTierOption[] = [{
@@ -636,6 +854,7 @@ watch(
: 'absent'
processingTierKeysEdited.value = false
resetScopeState()
initializeProcessingTierMultiplierDrafts()
initializeScopeCacheState(STANDARD_PRICING_SCOPE, standardTiers.value)
initializeScopeImagePricingState(STANDARD_PRICING_SCOPE, clonedValue)
for (const [key, config] of Object.entries(processingTierConfigs.value)) {
@@ -660,6 +879,7 @@ watch(
processingTierKeysEdited.value = false
originalEmptyProcessingTiers.value = 'absent'
resetScopeState()
initializeProcessingTierMultiplierDrafts()
initializeScopeCacheState(STANDARD_PRICING_SCOPE, standardTiers.value)
initializeScopeImagePricingState(STANDARD_PRICING_SCOPE, {})
activePricingScope.value = STANDARD_PRICING_SCOPE
@@ -668,6 +888,16 @@ watch(
{ immediate: true }
)
watch(
() => props.showProcessingTierControls,
(showProcessingTierControls) => {
if (showProcessingTierControls) return
activePricingScope.value = STANDARD_PRICING_SCOPE
resetCustomThresholdState()
},
{ immediate: true },
)
function processingTierScope(key: string): string {
return `${PROCESSING_PRICING_SCOPE_PREFIX}${key}`
}
@@ -701,11 +931,53 @@ function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
function processingTierHasExplicitPricingData(config: ProcessingTierPricingConfig): boolean {
return (Array.isArray(config.tiers) && config.tiers.length > 0)
|| (typeof config.image_output_price_default === 'number'
&& Number.isFinite(config.image_output_price_default))
|| [
'image_output_prices',
'image_output_price_ranges',
'image_output_price_per_image',
'image_output_price_matrix',
'image_prices',
].some(key => valueHasEntries(config[key]))
}
function valueHasEntries(value: unknown): boolean {
return (Array.isArray(value) && value.length > 0)
|| (isRecord(value) && Object.keys(value).length > 0)
}
function initializeProcessingTierMultiplierDrafts() {
const keys = new Set<string>([
...KNOWN_PROCESSING_TIERS.map(tier => tier.key),
...Object.keys(processingTierConfigs.value),
])
for (const key of keys) {
const config = processingTierConfigs.value[key]
const hasMultiplier = isRecord(config)
&& !processingTierHasExplicitPricingData(config)
&& hasOwn(config, 'price_multiplier')
processingTierMultiplierDrafts[key] = {
enabled: config !== undefined,
mode: hasMultiplier ? 'multiplier' : 'custom',
value: hasMultiplier && config.price_multiplier != null
? String(config.price_multiplier)
: '',
}
if (config === undefined) {
processingTierMultiplierDrafts[key].mode = 'multiplier'
}
}
}
function resetScopeState() {
for (const scope of Object.keys(cacheManualStateByScope)) delete cacheManualStateByScope[scope]
for (const scope of Object.keys(cachePriceModesByScope)) delete cachePriceModesByScope[scope]
for (const scope of Object.keys(cacheMultiplierDraftsByScope)) delete cacheMultiplierDraftsByScope[scope]
for (const scope of Object.keys(imagePricingStateByScope)) delete imagePricingStateByScope[scope]
for (const key of Object.keys(processingTierMultiplierDrafts)) delete processingTierMultiplierDrafts[key]
resetCustomThresholdState()
}
@@ -868,6 +1140,104 @@ function selectPricingScope(scope: string) {
resetCustomThresholdState()
}
function setProcessingTierConfig(key: string, config: ProcessingTierPricingConfig | null) {
processingTierConfigs.value = Object.fromEntries(
config === null
? Object.entries(processingTierConfigs.value).filter(([existingKey]) => existingKey !== key)
: [
...Object.entries(processingTierConfigs.value)
.filter(([existingKey]) => existingKey !== key),
[key, config],
],
)
processingTierKeysEdited.value = true
}
function requireProcessingTierMultiplierDraft(key: string): ProcessingTierMultiplierDraft {
if (!processingTierMultiplierDrafts[key]) {
processingTierMultiplierDrafts[key] = {
enabled: false,
mode: 'multiplier',
value: '',
}
}
return processingTierMultiplierDrafts[key]
}
function parseProcessingTierMultiplier(value: string | number): number | null {
const raw = String(value ?? '').trim()
if (!raw) return null
const multiplier = Number(raw)
return Number.isFinite(multiplier) && multiplier >= 0 ? multiplier : null
}
function startProcessingTierMultiplier(key: string) {
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = true
draft.mode = 'multiplier'
draft.value = ''
// Keep an existing explicit catalog intact until a valid multiplier is entered.
// This lets validation stop an incomplete conversion without silently deleting
// the catalog when the parent form is submitted.
syncToParent()
}
function startActiveProcessingTierMultiplier() {
const key = activeProcessingTierKey.value
if (key !== null) startProcessingTierMultiplier(key)
}
function updateProcessingTierMultiplier(key: string, value: string | number) {
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = true
draft.mode = 'multiplier'
draft.value = String(value ?? '')
const multiplier = parseProcessingTierMultiplier(value)
if (multiplier !== null) {
setProcessingTierConfig(key, { price_multiplier: multiplier })
const scope = processingTierScope(key)
initializeScopeCacheState(scope, [])
initializeScopeImagePricingState(scope, {})
}
syncToParent()
}
function updateActiveProcessingTierMultiplier(value: string | number) {
const key = activeProcessingTierKey.value
if (key !== null) updateProcessingTierMultiplier(key, value)
}
function setCompactProcessingTierEnabled(key: string, enabled: boolean) {
const draft = requireProcessingTierMultiplierDraft(key)
if (!enabled) {
draft.enabled = false
draft.mode = 'multiplier'
draft.value = ''
setProcessingTierConfig(key, null)
syncToParent()
return
}
if (draft.enabled) return
startProcessingTierMultiplier(key)
}
function useCustomPricingForActiveProcessingTier() {
const key = activeProcessingTierKey.value
if (key === null) return
const existingConfig = processingTierConfigs.value[key]
const restoredConfig = existingConfig && processingTierHasExplicitPricingData(existingConfig)
? cloneJson(existingConfig)
: { tiers: cloneJson(standardTiers.value) }
setProcessingTierConfig(key, restoredConfig)
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = true
draft.mode = 'custom'
draft.value = ''
initializeScopeCacheState(activePricingScope.value, restoredConfig.tiers ?? [])
initializeScopeImagePricingState(activePricingScope.value, restoredConfig)
syncToParent()
}
function addActiveProcessingTier() {
const key = activeProcessingTierKey.value
if (key === null || hasOwn(processingTierConfigs.value, key)) return
@@ -878,6 +1248,10 @@ function addActiveProcessingTier() {
[key, { tiers }],
])
processingTierKeysEdited.value = true
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = true
draft.mode = 'custom'
draft.value = ''
initializeScopeCacheState(activePricingScope.value, tiers)
initializeScopeImagePricingState(activePricingScope.value, {})
syncToParent()
@@ -895,6 +1269,10 @@ function removeActiveProcessingTier() {
delete cacheMultiplierDraftsByScope[activePricingScope.value]
delete imagePricingStateByScope[activePricingScope.value]
processingTierKeysEdited.value = true
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = false
draft.mode = 'multiplier'
draft.value = ''
if (!KNOWN_PROCESSING_TIERS.some(tier => tier.key === key)) {
activePricingScope.value = STANDARD_PRICING_SCOPE
}
@@ -934,9 +1312,14 @@ function replaceCacheTtlPrice(
}
const validationError = computed(() => {
const multiplierError = validateProcessingTierMultipliers()
if (multiplierError) return multiplierError
const scopes = [
STANDARD_PRICING_SCOPE,
...Object.keys(processingTierConfigs.value).map(processingTierScope),
...(props.showProcessingTierControls
? Object.keys(processingTierConfigs.value).map(processingTierScope)
: []),
]
for (const scope of new Set(scopes)) {
const error = validatePricingScope(scope)
@@ -945,6 +1328,39 @@ const validationError = computed(() => {
return null
})
function processingTierDisplayLabel(key: string): string {
const compactTier = COMPACT_PROCESSING_TIERS.find(tier => tier.key === key)
if (compactTier) {
return [
'group' in compactTier ? compactTier.group : null,
compactTier.label,
'detail' in compactTier ? compactTier.detail : null,
].filter((part): part is string => Boolean(part)).join(' · ')
}
return KNOWN_PROCESSING_TIERS.find(tier => tier.key === key)?.label ?? key
}
function validateProcessingTierMultipliers(): string | null {
const keys = new Set<string>([
...Object.keys(processingTierConfigs.value),
...Object.keys(processingTierMultiplierDrafts),
...(props.showProcessingTierMultiplierControls
? COMPACT_PROCESSING_TIERS.map(tier => tier.key)
: []),
])
for (const key of keys) {
const draft = processingTierMultiplierDrafts[key]
if (!draft?.enabled || draft.mode !== 'multiplier') continue
if (!draft.value.trim()) {
return `${processingTierDisplayLabel(key)}: 请输入层级倍率`
}
if (parseProcessingTierMultiplier(draft.value) === null) {
return `${processingTierDisplayLabel(key)}: 层级倍率必须是非负有限数值`
}
}
return null
}
function validatePricingScope(scope: string): string | null {
const processingTierKey = processingTierKeyFromScope(scope)
const tierError = validatePricingTiers(tiersForScope(scope), pricingScopePolicy(scope))
@@ -1152,25 +1568,37 @@ function buildPricingConfig(includeAutomaticCache: boolean): TieredPricingConfig
const config = cloneJson(basePricingConfig.value) as TieredPricingConfig
config.tiers = buildTiersForScope(STANDARD_PRICING_SCOPE, includeAutomaticCache)
const processingTierEntries: Array<[string, ProcessingTierPricingConfig]> = []
for (const [key, overlay] of Object.entries(processingTierConfigs.value)) {
const serializedOverlay = cloneJson(overlay)
if (Array.isArray(overlay.tiers)) {
serializedOverlay.tiers = buildTiersForScope(processingTierScope(key), includeAutomaticCache)
if (props.showProcessingTierControls) {
const processingTierEntries: Array<[string, ProcessingTierPricingConfig]> = []
for (const [key, overlay] of Object.entries(processingTierConfigs.value)) {
const serializedOverlay = cloneJson(overlay)
if (Array.isArray(overlay.tiers)) {
serializedOverlay.tiers = buildTiersForScope(processingTierScope(key), includeAutomaticCache)
}
if (props.showImagePricing) {
applyImagePricing(serializedOverlay, processingTierScope(key))
}
processingTierEntries.push([key, serializedOverlay])
}
if (props.showImagePricing) {
applyImagePricing(serializedOverlay, processingTierScope(key))
const processingTiers = Object.fromEntries(processingTierEntries)
delete config.processing_tiers
if (Object.keys(processingTiers).length > 0) {
config.processing_tiers = processingTiers
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'null') {
config.processing_tiers = null
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'object') {
config.processing_tiers = {}
}
} else {
const processingTiers = cloneJson(processingTierConfigs.value)
delete config.processing_tiers
if (Object.keys(processingTiers).length > 0) {
config.processing_tiers = processingTiers
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'null') {
config.processing_tiers = null
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'object') {
config.processing_tiers = {}
}
processingTierEntries.push([key, serializedOverlay])
}
const processingTiers = Object.fromEntries(processingTierEntries)
delete config.processing_tiers
if (Object.keys(processingTiers).length > 0) {
config.processing_tiers = processingTiers
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'null') {
config.processing_tiers = null
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'object') {
config.processing_tiers = {}
}
if (props.showImagePricing) {
@@ -167,6 +167,10 @@ describe('GlobalModelFormDialog preset replacement', () => {
findButton('Stale Model').click()
await settle()
expect(document.body.querySelector('[data-processing-tier="standard"]')).not.toBeNull()
expect(document.body.querySelector('[data-processing-tier="priority"]')).not.toBeNull()
expect(document.body.textContent).toContain('自定义价格')
await setInput(
document.body.querySelector<HTMLInputElement>('input[placeholder="如 0.01"]'),
'0.25',
@@ -246,4 +250,32 @@ describe('GlobalModelFormDialog preset replacement', () => {
},
])
})
it('submits a compact processing-tier multiplier without a Standard overlay', async () => {
mountDialog()
await settle()
findButton('Fresh Model').click()
await settle()
const priorityToggle = document.body.querySelector(
'input[aria-label="启用 Fast · OpenAI · Chat / Responses 层级倍率"]',
) as HTMLInputElement
priorityToggle.click()
await nextTick()
await setInput(
document.body.querySelector<HTMLInputElement>(
'[data-testid="processing-tier-multiplier-priority"]',
),
'2.5',
)
findExactButton('添加').click()
await settle()
const payload = globalModelMocks.createGlobalModel.mock.calls[0][0]
expect(payload.default_tiered_pricing.processing_tiers).toEqual({
priority: { price_multiplier: 2.5 },
})
expect(payload.default_tiered_pricing.processing_tiers).not.toHaveProperty('standard')
})
})
@@ -31,6 +31,37 @@ afterEach(() => {
})
describe('ProcessingTierPricingSummary', () => {
it('shows multiplier-only processing tiers', async () => {
const root = mountSummary({
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: { price_multiplier: 2.5 },
fast: { price_multiplier: 2 },
flex: {
price_multiplier: 99,
tiers: [{ up_to: null, input_price_per_1m: 2.5, output_price_per_1m: 15 }],
},
},
})
expect(root.querySelector('[data-processing-tier="priority"]')?.textContent)
.toContain('Fast(OpenAI)')
expect(root.querySelector('[data-processing-tier="fast"]')?.textContent)
.toContain('Fast(Claude)')
expect(root.textContent).not.toContain('Priority')
expect(root.querySelector('[data-testid="processing-tier-price-multiplier"]')?.textContent)
.toContain('2.5×')
clickTier(root, 'fast')
await nextTick()
expect(root.querySelector('[data-testid="processing-tier-price-multiplier"]')?.textContent)
.toContain('2×')
clickTier(root, 'flex')
await nextTick()
expect(root.querySelector('[data-testid="processing-tier-price-multiplier"]')).toBeNull()
expect(root.querySelectorAll('[data-testid="processing-token-tier-row"]')).toHaveLength(1)
})
it('shows finite and unbounded token tiers in stable processing-tier order', () => {
const root = mountSummary({
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
@@ -27,12 +27,15 @@ function mountEditor(
showImagePricing?: boolean
showTokenPricing?: boolean
showImageEditor?: boolean
showProcessingTierControls?: boolean
showProcessingTierMultiplierControls?: boolean
} = {},
) {
const root = document.createElement('div')
document.body.appendChild(root)
const onUpdate = vi.fn()
const currentModelValue = shallowRef(modelValue)
const showProcessingTierControls = shallowRef(options.showProcessingTierControls)
let editor: TieredPricingEditorExposed | null = null
const app = createApp(defineComponent({
@@ -47,6 +50,8 @@ function mountEditor(
showImagePricing: options.showImagePricing,
showTokenPricing: options.showTokenPricing,
showImageEditor: options.showImageEditor,
showProcessingTierControls: showProcessingTierControls.value,
showProcessingTierMultiplierControls: options.showProcessingTierMultiplierControls,
'onUpdate:modelValue': onUpdate,
})
},
@@ -61,6 +66,9 @@ function mountEditor(
setModelValue: (value: TieredPricingConfig) => {
currentModelValue.value = value
},
setShowProcessingTierControls: (value: boolean) => {
showProcessingTierControls.value = value
},
getFinalPricing: () => {
if (!editor) throw new Error('TieredPricingEditor ref was not mounted')
return editor.getFinalPricing()
@@ -87,6 +95,288 @@ afterEach(() => {
})
describe('TieredPricingEditor processing tiers', () => {
it('hides processing-tier controls while editing Standard and preserving overlays', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
price_multiplier: 999,
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
future_overlay_option: 'keep-hidden-overlay',
},
},
} as TieredPricingConfig
const {
root,
onUpdate,
getFinalPricing,
setShowProcessingTierControls,
} = mountEditor(pricing)
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
expect(root.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value)
.toBe('10')
setShowProcessingTierControls(false)
await nextTick()
expect(root.querySelector('[data-processing-tier]')).toBeNull()
expect(root.textContent).not.toContain('处理层级')
const input = root.querySelector('[data-testid="tier-input-price"]') as HTMLInputElement | null
if (!input) throw new Error('Expected the Standard input-price control')
input.value = '7.5'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const emitted = onUpdate.mock.lastCall?.[0] as TieredPricingConfig
expect(emitted.tiers[0].input_price_per_1m).toBe(7.5)
expect(emitted.processing_tiers).toEqual(pricing.processing_tiers)
expect(getFinalPricing().processing_tiers).toEqual(pricing.processing_tiers)
})
it('edits compact processing-tier multipliers without writing a Standard overlay', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, getFinalPricing, getValidationError } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
expect(root.querySelector('[data-testid="processing-tier-group-fast"]')?.textContent)
.toBe('Fast')
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
.toContain('OpenAI')
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
.toContain('Chat / Responses')
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
.toContain('Claude')
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
.toContain('Messages')
const priorityToggle = root.querySelector(
'input[aria-label="启用 Fast · OpenAI · Chat / Responses 层级倍率"]',
) as HTMLInputElement
priorityToggle.click()
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-priority"]',
) as HTMLInputElement
multiplier.value = '2.5'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toBeNull()
expect(getFinalPricing().processing_tiers).toEqual({
priority: { price_multiplier: 2.5 },
})
expect(getFinalPricing().processing_tiers).not.toHaveProperty('standard')
priorityToggle.click()
await nextTick()
expect(getFinalPricing()).not.toHaveProperty('processing_tiers')
})
it('keeps the grouped Claude Fast option mapped to the internal fast key', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
const fastToggle = root.querySelector(
'input[aria-label="启用 Fast · Claude · Messages 层级倍率"]',
) as HTMLInputElement
fastToggle.click()
await nextTick()
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-fast"]',
) as HTMLInputElement
multiplier.value = '2'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getFinalPricing().processing_tiers).toEqual({
fast: { price_multiplier: 2 },
})
})
it('requires an enabled processing-tier multiplier instead of clearing the saved value', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: { priority: { price_multiplier: 2.5 } },
} as TieredPricingConfig
const { root, onUpdate, getFinalPricing, getValidationError } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-priority"]',
) as HTMLInputElement
multiplier.value = ''
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
expect(onUpdate).not.toHaveBeenCalled()
})
it('preserves a custom catalog until the user explicitly replaces it with a multiplier', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
future_overlay_option: 'replace-with-catalog',
},
hyperlane: {
tiers: [{ up_to: null, input_price_per_1m: 7, output_price_per_1m: 42 }],
future_overlay_option: 'keep-unknown',
},
},
} as TieredPricingConfig
const { root, onUpdate, getFinalPricing, getValidationError } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
expect(root.textContent).toContain('自定义价格')
expect(getFinalPricing().processing_tiers).toEqual(pricing.processing_tiers)
click(root.querySelector('[data-testid="processing-tier-convert-priority"]'))
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
expect(onUpdate).not.toHaveBeenCalled()
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-priority"]',
) as HTMLInputElement
multiplier.value = '2'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getFinalPricing().processing_tiers).toEqual({
priority: { price_multiplier: 2 },
hyperlane: pricing.processing_tiers?.hyperlane,
})
})
it('validates compact multipliers as finite non-negative numbers', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: { flex: { price_multiplier: 0.5 } },
} as TieredPricingConfig
const { root, getValidationError } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-flex"]',
) as HTMLInputElement
expect(multiplier.value).toBe('0.5')
multiplier.value = '-1'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toContain('必须是非负有限数值')
})
it('requires a full-editor multiplier before persisting the new tier', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, getFinalPricing, getValidationError } = mountEditor(pricing, {
autoFillMissingCachePrices: false,
showProcessingTierMultiplierControls: true,
})
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
click(root.querySelector('[data-testid="processing-tier-add-multiplier"]'))
await nextTick()
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-input"]',
) as HTMLInputElement
expect(getValidationError()).toContain('请输入层级倍率')
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
multiplier.value = '-1'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toContain('必须是非负有限数值')
multiplier.value = ''
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
})
it('restores an explicit catalog when an incomplete multiplier conversion is cancelled', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
tiers: [{ up_to: null, input_price_per_1m: 11, output_price_per_1m: 66 }],
future_overlay_option: 'keep-on-cancel',
},
},
} as TieredPricingConfig
const { root, getFinalPricing, getValidationError } = mountEditor(pricing, {
autoFillMissingCachePrices: false,
showProcessingTierMultiplierControls: true,
})
click(root.querySelector('[data-testid="processing-tier-convert-priority"]'))
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
click(root.querySelector('[data-testid="processing-tier-use-custom"]'))
await nextTick()
expect(getValidationError()).toBeNull()
expect(getFinalPricing().processing_tiers?.priority).toEqual(
pricing.processing_tiers?.priority,
)
})
it('lets the full Provider editor edit a multiplier or replace it with explicit prices', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: { priority: { price_multiplier: 2.5 } },
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing)
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-input"]',
) as HTMLInputElement
expect(multiplier.value).toBe('2.5')
multiplier.value = '3'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getFinalPricing().processing_tiers?.priority).toEqual({ price_multiplier: 3 })
click(root.querySelector('[data-testid="processing-tier-use-custom"]'))
await nextTick()
expect(getFinalPricing().processing_tiers?.priority).toMatchObject({
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
})
expect(getFinalPricing().processing_tiers?.priority).not.toHaveProperty('price_multiplier')
})
it('round-trips root, overlay and pricing-tier extension fields', () => {
const pricing = {
tiers: [{
@@ -140,9 +430,11 @@ describe('TieredPricingEditor processing tiers', () => {
} as TieredPricingConfig
const { root, onUpdate } = mountEditor(pricing)
expect(root.querySelectorAll('[data-processing-tier]')).toHaveLength(5)
expect(root.querySelectorAll('[data-processing-tier]')).toHaveLength(6)
expect(root.textContent).toContain('Standard')
expect(root.textContent).toContain('Priority')
expect(root.textContent).toContain('Fast(OpenAI)')
expect(root.textContent).toContain('Fast(Claude)')
expect(root.textContent).not.toContain('Priority')
expect(root.textContent).toContain('Flex')
expect(root.textContent).toContain('Batch')
expect(root.textContent).toContain('hyperlane')
@@ -283,7 +575,7 @@ describe('TieredPricingEditor processing tiers', () => {
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
const multiplier = root.querySelector(
'input[aria-label="Priority 阶梯 1 缓存创建倍率"]',
'input[aria-label="Fast(OpenAI) 阶梯 1 缓存创建倍率"]',
) as HTMLInputElement
multiplier.value = '2'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
@@ -453,7 +745,8 @@ describe('TieredPricingEditor processing tiers', () => {
await nextTick()
expect(root.querySelector('[data-testid="tier-input-price"]')).toBeNull()
expect(root.querySelector('input[aria-label="Priority 图像输出默认价格"]')).not.toBeNull()
expect(root.querySelector('input[aria-label="Fast(OpenAI) 图像输出默认价格"]'))
.not.toBeNull()
})
it('accepts a finite terminal tier for any processing overlay', () => {
@@ -500,7 +793,7 @@ describe('TieredPricingEditor processing tiers', () => {
await nextTick()
const terminal = root.querySelector(
'select[aria-label="Priority 阶梯 1 上限"]',
'select[aria-label="Fast(OpenAI) 阶梯 1 上限"]',
) as HTMLSelectElement
expect(terminal.value).toBe('272000')
@@ -535,7 +828,7 @@ describe('TieredPricingEditor processing tiers', () => {
expect(getFinalPricing().processing_tiers?.priority.tiers?.map(tier => tier.up_to))
.toEqual([272000, null])
click(root.querySelector('button[aria-label="删除 Priority 阶梯 2"]'))
click(root.querySelector('button[aria-label="删除 Fast(OpenAI) 阶梯 2"]'))
await nextTick()
expect(getFinalPricing().processing_tiers?.priority.tiers?.map(tier => tier.up_to))
.toEqual([272000])
@@ -650,7 +943,7 @@ describe('TieredPricingEditor processing tiers', () => {
await nextTick()
const priorityDefault = root.querySelector(
'input[aria-label="Priority 图像输出默认价格"]',
'input[aria-label="Fast(OpenAI) 图像输出默认价格"]',
) as HTMLInputElement
const priorityHigh = root.querySelector(
'input[aria-label="1024x1024 high 图像输出价格"]',
@@ -754,9 +1047,9 @@ describe('TieredPricingEditor processing tiers', () => {
await nextTick()
expect(onUpdate).not.toHaveBeenCalled()
expect(getValidationError()).toContain('Priority')
expect(getValidationError()).toContain('Fast(OpenAI)')
expect(getValidationError()).toContain('上限必须大于前一个阶梯')
expect(() => getFinalPricing()).toThrow('Priority')
expect(() => getFinalPricing()).toThrow('Fast(OpenAI)')
})
it('rejects negative known prices before they reach the billing contract', () => {