feat(frontend): expose processing tier pricing

This commit is contained in:
MMEXA
2026-07-11 12:27:09 +08:00
parent 0b30cc6b0f
commit 8f1070a451
25 changed files with 2488 additions and 340 deletions
@@ -602,6 +602,7 @@ import {
buildGlobalModelCreatePayload,
buildGlobalModelUpdatePayload,
} from './global-model-form-helpers'
import { tieredPricingHasImageOutputPricing } from '../utils/tiered-pricing'
const props = defineProps<{
open: boolean
@@ -1204,6 +1205,12 @@ async function handleSubmit() {
return
}
const pricingValidationError = tieredPricingEditorRef.value?.getValidationError()
if (pricingValidationError) {
showError(pricingValidationError, '价格配置错误')
return
}
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
if (!finalTieredPricing?.tiers?.length) {
@@ -1244,28 +1251,4 @@ async function handleSubmit() {
}
}
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
if (!pricing) return false
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
if (!prices || typeof prices !== 'object') return false
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})) return true
return (pricing.image_output_price_ranges || []).some((range) => {
if (!range || typeof range !== 'object') return false
const prices = range.prices && typeof range.prices === 'object'
? range.prices
: range as Record<string, unknown>
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})
}
function toFinitePrice(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
</script>
@@ -137,6 +137,7 @@
</p>
</div>
</div>
</div>
<!-- 默认定价 -->
@@ -145,6 +146,8 @@
默认定价
</h4>
<ProcessingTierPricingSummary :pricing="model.default_tiered_pricing" />
<!-- 图片输出计费 -->
<div
v-if="hasImagePricing"
@@ -556,6 +559,7 @@ import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import RoutingTab from './RoutingTab.vue'
import ModelMappingsTab from './ModelMappingsTab.vue'
import ProcessingTierPricingSummary from './ProcessingTierPricingSummary.vue'
import { sortResolutionEntries } from '@/utils/form'
import { parseApiError } from '@/utils/errorParser'
import { formatCompactNumber, formatTokens } from '@/utils/format'
@@ -0,0 +1,390 @@
<template>
<div
v-if="processingTierEntries.length > 0 && activeEntry"
class="space-y-3 border-t border-border/60 pt-4"
data-testid="processing-tier-pricing-summary"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h5 class="text-sm font-medium text-foreground">
处理层级定价
</h5>
<p class="text-xs text-muted-foreground">
{{ activeEntry.label }}
</p>
</div>
<div
class="flex max-w-full flex-wrap gap-1"
role="group"
aria-label="处理层级定价"
>
<Button
v-for="entry in processingTierEntries"
:key="entry.key"
type="button"
size="sm"
:variant="activeTierKey === entry.key ? 'secondary' : 'ghost'"
class="h-8 max-w-full px-2.5"
:aria-pressed="activeTierKey === entry.key"
:data-processing-tier="entry.key"
@click="activeTierKey = entry.key"
>
<span class="truncate">{{ entry.label }}</span>
</Button>
</div>
</div>
<div
v-if="activeTokenTiers.length > 0"
class="overflow-x-auto rounded-md border"
>
<Table class="min-w-[680px]">
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="h-9 text-xs">
Token 区间
</TableHead>
<TableHead class="h-9 text-right text-xs">
输入 ($/M)
</TableHead>
<TableHead class="h-9 text-right text-xs">
输出 ($/M)
</TableHead>
<TableHead class="h-9 text-right text-xs">
缓存创建
</TableHead>
<TableHead class="h-9 text-right text-xs">
缓存读取
</TableHead>
<TableHead class="h-9 text-right text-xs">
1h 创建
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="(tier, index) in activeTokenTiers"
:key="index"
class="text-xs"
data-testid="processing-token-tier-row"
>
<TableCell class="py-2 whitespace-nowrap">
{{ formatTokenRange(activeTokenTiers, index) }}
</TableCell>
<TableCell class="py-2 text-right font-mono">
{{ formatPrice(tier.input_price_per_1m) }}
</TableCell>
<TableCell class="py-2 text-right font-mono">
{{ formatPrice(tier.output_price_per_1m) }}
</TableCell>
<TableCell class="py-2 text-right font-mono text-muted-foreground">
{{ formatPrice(tier.cache_creation_price_per_1m) }}
</TableCell>
<TableCell class="py-2 text-right font-mono text-muted-foreground">
{{ formatPrice(tier.cache_read_price_per_1m) }}
</TableCell>
<TableCell class="py-2 text-right font-mono text-muted-foreground">
{{ formatPrice(cacheCreationPriceForTtl(tier, 60)) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div
v-if="hasActiveImagePricing"
class="space-y-2"
data-testid="processing-image-pricing"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs">
<span class="font-medium text-foreground">图片输出</span>
<span
v-if="activeImageDefaultPrice !== null"
class="font-mono text-muted-foreground"
>默认 {{ formatPrice(activeImageDefaultPrice) }}/张</span>
</div>
<div
v-if="activeImageRows.length > 0"
class="overflow-x-auto rounded-md border"
>
<Table :class="imageTableMinWidthClass">
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="h-9 text-xs">
分辨率
</TableHead>
<TableHead
v-for="quality in activeImageQualities"
:key="quality"
class="h-9 text-right text-xs"
>
{{ quality }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in activeImageRows"
:key="row.size"
class="text-xs"
>
<TableCell class="py-2 font-mono whitespace-nowrap">
{{ formatImageSize(row.size) }}
</TableCell>
<TableCell
v-for="quality in activeImageQualities"
:key="`${row.size}-${quality}`"
class="py-2 text-right font-mono"
>
{{ formatPrice(row.prices[quality]) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div
v-if="activeImageRangeRows.length > 0"
class="overflow-x-auto rounded-md border"
>
<Table :class="imageTableMinWidthClass">
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="h-9 text-xs">
像素区间
</TableHead>
<TableHead
v-for="quality in activeImageQualities"
:key="quality"
class="h-9 text-right text-xs"
>
{{ quality }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="(row, index) in activeImageRangeRows"
:key="`${row.upToPixels ?? 'unbounded'}-${index}`"
class="text-xs"
>
<TableCell class="py-2 whitespace-nowrap">
{{ row.label || formatPixelRange(activeImageRangeRows, index) }}
</TableCell>
<TableCell
v-for="quality in activeImageQualities"
:key="`${index}-${quality}`"
class="py-2 text-right font-mono"
>
{{ formatPrice(row.prices[quality]) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import Button from '@/components/ui/button.vue'
import Table from '@/components/ui/table.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableCell from '@/components/ui/table-cell.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableHeader from '@/components/ui/table-header.vue'
import TableRow from '@/components/ui/table-row.vue'
import { formatTokens } from '@/utils/format'
import type {
PricingTier,
ProcessingTierPricingConfig,
TieredPricingConfig,
} from '@/api/endpoints/types'
import { comparePricingUpperBounds } from '@/features/models/utils/tiered-pricing'
type ProcessingTierEntry = {
key: string
label: string
config: ProcessingTierPricingConfig
}
type ImagePriceRow = {
size: string
prices: Record<string, number>
}
type ImageRangeRow = {
upToPixels: number | null
label: string | null
prices: Record<string, number>
}
const props = defineProps<{
pricing: TieredPricingConfig | null | undefined
}>()
const KNOWN_PROCESSING_TIERS = [
{ key: 'priority', label: 'Priority' },
{ key: 'flex', label: 'Flex' },
{ key: 'batch', label: 'Batch' },
] as const
const KNOWN_IMAGE_QUALITIES = ['low', 'medium', 'high'] as const
const activeTierKey = ref('')
const processingTierEntries = computed<ProcessingTierEntry[]>(() => {
const processingTiers = props.pricing?.processing_tiers
if (!isRecord(processingTiers)) return []
const labels = new Map(KNOWN_PROCESSING_TIERS.map(entry => [entry.key, entry.label]))
const order = new Map(KNOWN_PROCESSING_TIERS.map((entry, index) => [entry.key, index]))
return Object.entries(processingTiers)
.filter((entry): entry is [string, ProcessingTierPricingConfig] => (
isRecord(entry[1]) && processingPricingHasFacts(entry[1])
))
.sort(([left], [right]) => {
const leftOrder = order.get(left) ?? KNOWN_PROCESSING_TIERS.length
const rightOrder = order.get(right) ?? KNOWN_PROCESSING_TIERS.length
return leftOrder - rightOrder || left.localeCompare(right)
})
.map(([key, config]) => ({ key, label: labels.get(key) ?? key, config }))
})
watch(processingTierEntries, (entries) => {
if (!entries.some(entry => entry.key === activeTierKey.value)) {
activeTierKey.value = entries[0]?.key ?? ''
}
}, { immediate: true })
const activeEntry = computed(() =>
processingTierEntries.value.find(entry => entry.key === activeTierKey.value) ?? null,
)
const activeTokenTiers = computed<PricingTier[]>(() =>
Array.isArray(activeEntry.value?.config.tiers)
? activeEntry.value.config.tiers.filter(isRecord) as PricingTier[]
: [],
)
const activeImageDefaultPrice = computed(() =>
toFiniteNumber(activeEntry.value?.config.image_output_price_default),
)
const activeImageRows = computed<ImagePriceRow[]>(() => {
const prices = activeEntry.value?.config.image_output_prices
if (!isRecord(prices)) return []
return Object.entries(prices)
.filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]))
.map(([size, values]) => ({ size, prices: finitePriceRecord(values) }))
.filter(row => Object.keys(row.prices).length > 0)
.sort((left, right) => imageSizeArea(left.size) - imageSizeArea(right.size)
|| left.size.localeCompare(right.size))
})
const activeImageRangeRows = computed<ImageRangeRow[]>(() => {
const ranges = activeEntry.value?.config.image_output_price_ranges
if (!Array.isArray(ranges)) return []
return ranges
.filter(isRecord)
.map(range => ({
upToPixels: range.up_to_pixels === null ? null : toFiniteNumber(range.up_to_pixels),
label: typeof range.label === 'string' && range.label.trim() ? range.label.trim() : null,
prices: isRecord(range.prices) ? finitePriceRecord(range.prices) : {},
}))
.filter(row => Object.keys(row.prices).length > 0)
.sort((left, right) => comparePricingUpperBounds(left.upToPixels, right.upToPixels))
})
const activeImageQualities = computed(() => {
const present = new Set<string>()
for (const row of [...activeImageRows.value, ...activeImageRangeRows.value]) {
Object.keys(row.prices).forEach(quality => present.add(quality))
}
const known = KNOWN_IMAGE_QUALITIES.filter(quality => present.has(quality))
const custom = [...present]
.filter(quality => !KNOWN_IMAGE_QUALITIES.includes(quality as typeof KNOWN_IMAGE_QUALITIES[number]))
.sort((left, right) => left.localeCompare(right))
return [...known, ...custom]
})
const hasActiveImagePricing = computed(() =>
activeImageDefaultPrice.value !== null
|| activeImageRows.value.length > 0
|| activeImageRangeRows.value.length > 0,
)
const imageTableMinWidthClass = computed(() =>
activeImageQualities.value.length > 3 ? 'min-w-[620px]' : 'min-w-[460px]',
)
function processingPricingHasFacts(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)) {
for (const prices of Object.values(config.image_output_prices)) {
if (isRecord(prices) && Object.keys(finitePriceRecord(prices)).length > 0) return true
}
}
return 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
))
}
function formatTokenRange(tiers: PricingTier[], index: number): string {
const lower = index === 0 ? 0 : toFiniteNumber(tiers[index - 1]?.up_to)
const upper = tiers[index]?.up_to === null ? null : toFiniteNumber(tiers[index]?.up_to)
if (upper === null) return lower && lower > 0 ? `> ${formatTokens(lower)}` : '所有'
return `${formatTokens(lower ?? 0)} - ${formatTokens(upper)}`
}
function cacheCreationPriceForTtl(tier: PricingTier, ttlMinutes: number): number | null {
const entry = Array.isArray(tier.cache_ttl_pricing)
? tier.cache_ttl_pricing.find(item => item.ttl_minutes === ttlMinutes)
: undefined
return toFiniteNumber(entry?.cache_creation_price_per_1m)
}
function formatPixelRange(rows: ImageRangeRow[], index: number): string {
const lower = index === 0 ? 0 : rows[index - 1]?.upToPixels
const upper = rows[index]?.upToPixels
if (upper === null) return lower && lower > 0 ? `> ${formatTokens(lower)} px` : '所有像素'
return `${formatTokens(lower ?? 0)} - ${formatTokens(upper)} px`
}
function formatPrice(value: unknown): string {
const price = toFiniteNumber(value)
if (price === null) return '-'
return `$${price.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
useGrouping: false,
})}`
}
function formatImageSize(value: string): string {
return value.replace(/\s*[xX×]\s*/g, ' x ')
}
function imageSizeArea(value: string): number {
const match = value.match(/^(\d+)\s*[xX×]\s*(\d+)$/)
return match ? Number(match[1]) * Number(match[2]) : Number.MAX_SAFE_INTEGER
}
function finitePriceRecord(value: Record<string, unknown>): Record<string, number> {
const entries: Array<[string, number]> = []
for (const [key, rawPrice] of Object.entries(value)) {
const price = toFiniteNumber(rawPrice)
if (price !== null) entries.push([key, price])
}
return Object.fromEntries(entries)
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import type { TieredPricingConfig } from '@/api/endpoints/types'
import ProcessingTierPricingSummary from '../ProcessingTierPricingSummary.vue'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountSummary(pricing: TieredPricingConfig) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup: () => () => h(ProcessingTierPricingSummary, { pricing }),
}))
app.mount(root)
mountedApps.push({ app, root })
return root
}
function clickTier(root: HTMLElement, tier: string) {
const button = root.querySelector(`[data-processing-tier="${tier}"]`)
if (!(button instanceof HTMLButtonElement)) throw new Error(`Missing ${tier} tier button`)
button.click()
}
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('ProcessingTierPricingSummary', () => {
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 }],
processing_tiers: {
hyperlane: {
tiers: [{ up_to: 128_000, input_price_per_1m: 7, output_price_per_1m: 35 }],
},
priority: {
tiers: [
{
up_to: 272_000,
input_price_per_1m: 10,
output_price_per_1m: 60,
cache_creation_price_per_1m: 12.5,
cache_read_price_per_1m: 1,
cache_ttl_pricing: [{ ttl_minutes: 60, cache_creation_price_per_1m: 20 }],
},
{ up_to: null, input_price_per_1m: 20, output_price_per_1m: 120 },
],
},
empty: {},
},
})
expect([...root.querySelectorAll('[data-processing-tier]')].map(element => (
element.getAttribute('data-processing-tier')
))).toEqual(['priority', 'hyperlane'])
expect(root.querySelectorAll('[data-testid="processing-token-tier-row"]')).toHaveLength(2)
expect(root.textContent).toContain('0 - 272K')
expect(root.textContent).toContain('> 272K')
expect(root.textContent).toContain('$20.00')
})
it('renders image-only overlays, zero prices and future qualities', async () => {
const root = mountSummary({
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
flex: {
image_output_price_default: 0,
image_output_prices: {
'1024x1024': { low: 0, high: 0.04 },
},
},
hyperlane: {
image_output_price_ranges: [
{ up_to_pixels: null, prices: { ultra: 0.05 } },
{ up_to_pixels: 1_048_576, prices: { ultra: 0.03 } },
],
},
},
})
expect(root.querySelectorAll('[data-testid="processing-token-tier-row"]')).toHaveLength(0)
expect(root.textContent).toContain('默认 $0.00/张')
expect(root.textContent).toContain('1024 x 1024')
expect(root.textContent).toContain('$0.04')
clickTier(root, 'hyperlane')
await nextTick()
expect(root.textContent).toContain('ultra')
expect(root.textContent).toContain('0 - 1.05M px')
expect(root.textContent).toContain('> 1.05M px')
expect(root.textContent).toContain('$0.03')
expect(root.textContent!.indexOf('0 - 1.05M px')).toBeLessThan(
root.textContent!.indexOf('> 1.05M px'),
)
})
})
@@ -0,0 +1,615 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App, type ComponentPublicInstance } from 'vue'
import type { TieredPricingConfig } from '@/api/endpoints/types'
import TieredPricingEditor from '../TieredPricingEditor.vue'
interface TieredPricingEditorExposed extends ComponentPublicInstance {
getFinalPricing: () => TieredPricingConfig
getValidationError: () => string | null
}
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountEditor(
modelValue: TieredPricingConfig,
options: {
showCache1h?: boolean
showImagePricing?: boolean
showTokenPricing?: boolean
showImageEditor?: boolean
} = {},
) {
const root = document.createElement('div')
document.body.appendChild(root)
const onUpdate = vi.fn()
let editor: TieredPricingEditorExposed | null = null
const app = createApp(defineComponent({
setup() {
return () => h(TieredPricingEditor, {
ref: (instance: unknown) => {
editor = instance as TieredPricingEditorExposed | null
},
modelValue,
showCache1h: options.showCache1h,
showImagePricing: options.showImagePricing,
showTokenPricing: options.showTokenPricing,
showImageEditor: options.showImageEditor,
'onUpdate:modelValue': onUpdate,
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
return {
root,
onUpdate,
getFinalPricing: () => {
if (!editor) throw new Error('TieredPricingEditor ref was not mounted')
return editor.getFinalPricing()
},
getValidationError: () => {
if (!editor) throw new Error('TieredPricingEditor ref was not mounted')
return editor.getValidationError()
},
}
}
function click(element: Element | null) {
if (!(element instanceof HTMLButtonElement)) {
throw new Error('Expected a button')
}
element.click()
}
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('TieredPricingEditor processing tiers', () => {
it('round-trips root, overlay and pricing-tier extension fields', () => {
const pricing = {
tiers: [{
up_to: null,
input_price_per_1m: 5,
output_price_per_1m: 30,
vendor_tier_note: 'keep-standard-tier',
}],
future_root_option: { enabled: true },
processing_tiers: {
priority: {
tiers: [{
up_to: null,
input_price_per_1m: 10,
output_price_per_1m: 60,
vendor_tier_note: 'keep-priority-tier',
}],
contract_reference: 'priority-2026',
},
hyperlane: {
tiers: [{
up_to: null,
input_price_per_1m: 7.5,
output_price_per_1m: 42,
}],
future_overlay_option: { mode: 'reserved' },
},
},
} as TieredPricingConfig
const { getFinalPricing } = mountEditor(pricing)
const result = getFinalPricing()
expect(result.future_root_option).toEqual({ enabled: true })
expect(result.tiers[0].vendor_tier_note).toBe('keep-standard-tier')
expect(result.processing_tiers?.priority.contract_reference).toBe('priority-2026')
expect(result.processing_tiers?.priority.tiers?.[0].vendor_tier_note).toBe('keep-priority-tier')
expect(result.processing_tiers?.hyperlane.future_overlay_option).toEqual({ mode: 'reserved' })
})
it('shows known and discovered tiers and edits a discovered tier through the shared rate controls', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
future_root_option: 'keep-root',
processing_tiers: {
hyperlane: {
tiers: [{ up_to: null, input_price_per_1m: 7.5, output_price_per_1m: 42 }],
future_overlay_option: 'keep-overlay',
},
},
} as TieredPricingConfig
const { root, onUpdate } = mountEditor(pricing)
expect(root.querySelectorAll('[data-processing-tier]')).toHaveLength(5)
expect(root.textContent).toContain('Standard')
expect(root.textContent).toContain('Priority')
expect(root.textContent).toContain('Flex')
expect(root.textContent).toContain('Batch')
expect(root.textContent).toContain('hyperlane')
click(root.querySelector('[data-processing-tier="hyperlane"]'))
await nextTick()
const input = root.querySelector('[data-testid="tier-input-price"]') as HTMLInputElement | null
if (!input) throw new Error('Expected the shared input-price control')
input.value = '9.75'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const emitted = onUpdate.mock.lastCall?.[0] as TieredPricingConfig
expect(emitted.processing_tiers?.hyperlane.tiers?.[0].input_price_per_1m).toBe(9.75)
expect(emitted.processing_tiers?.hyperlane.future_overlay_option).toBe('keep-overlay')
expect(emitted.future_root_option).toBe('keep-root')
})
it('adds and removes an explicit known-tier overlay without changing Standard', async () => {
const pricing = {
tiers: [{
up_to: null,
input_price_per_1m: 5,
output_price_per_1m: 30,
future_tier_option: 'keep-on-clone',
}],
} as TieredPricingConfig
const { root, onUpdate } = mountEditor(pricing)
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
expect(root.querySelector('[data-testid="processing-tier-empty"]')).not.toBeNull()
click(root.querySelector('[data-testid="processing-tier-add"]'))
await nextTick()
let emitted = onUpdate.mock.lastCall?.[0] as TieredPricingConfig
expect(emitted.tiers[0].input_price_per_1m).toBe(5)
expect(emitted.processing_tiers?.priority.tiers?.[0]).toMatchObject(pricing.tiers[0])
expect(emitted.processing_tiers?.priority.tiers?.[0].cache_creation_price_per_1m).toBe(6.25)
expect(emitted.processing_tiers?.priority.tiers?.[0].cache_read_price_per_1m).toBe(0.5)
expect(root.querySelector('[data-testid="processing-tier-remove"]'), root.innerHTML).not.toBeNull()
click(root.querySelector('[data-testid="processing-tier-remove"]'))
await nextTick()
emitted = onUpdate.mock.lastCall?.[0] as TieredPricingConfig
expect(emitted.processing_tiers).toBeUndefined()
expect(emitted.tiers[0]).toMatchObject(pricing.tiers[0])
expect(emitted.tiers[0].cache_creation_price_per_1m).toBe(6.25)
expect(emitted.tiers[0].cache_read_price_per_1m).toBe(0.5)
})
it('keeps an unconfigured tier tab outside the persisted pricing contract', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
image_output_price_default: 0.01,
} as TieredPricingConfig
const { getFinalPricing, getValidationError, root } = mountEditor(pricing, {
showImagePricing: true,
})
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
expect(root.querySelector('[data-testid="processing-tier-empty"]')).not.toBeNull()
expect(getValidationError()).toBeNull()
const result = getFinalPricing()
expect(result.tiers[0].input_price_per_1m).toBe(5)
expect(result.image_output_price_default).toBe(0.01)
expect(result.processing_tiers).toBeUndefined()
})
it.each([
['absent', undefined],
['null', null],
['empty object', {}],
] as const)('preserves an unedited %s processing_tiers value', (_, processingTiers) => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
...(processingTiers === undefined ? {} : { processing_tiers: processingTiers }),
} as TieredPricingConfig
const { getFinalPricing } = mountEditor(pricing)
const result = getFinalPricing()
expect(Object.prototype.hasOwnProperty.call(result, 'processing_tiers'))
.toBe(processingTiers !== undefined)
expect(result.processing_tiers).toEqual(processingTiers)
})
it('edits every configured official processing tier through the same controls', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: Object.fromEntries(['priority', 'flex', 'batch'].map((key, index) => [
key,
{
tiers: [{
up_to: null,
input_price_per_1m: index + 1,
output_price_per_1m: (index + 1) * 6,
}],
},
])),
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing)
for (const [index, key] of ['priority', 'flex', 'batch'].entries()) {
click(root.querySelector(`[data-processing-tier="${key}"]`))
await nextTick()
const input = root.querySelector('[data-testid="tier-input-price"]') as HTMLInputElement | null
if (!input) throw new Error(`Expected input-price control for ${key}`)
input.value = String(11 + index)
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
const result = getFinalPricing()
expect(result.tiers[0].input_price_per_1m).toBe(5)
expect(result.processing_tiers?.priority.tiers?.[0].input_price_per_1m).toBe(11)
expect(result.processing_tiers?.flex.tiers?.[0].input_price_per_1m).toBe(12)
expect(result.processing_tiers?.batch.tiers?.[0].input_price_per_1m).toBe(13)
})
it('keeps cache multiplier drafts isolated by processing scope', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
tiers: [{ up_to: 272000, input_price_per_1m: 10, output_price_per_1m: 60 }],
},
},
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing)
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
const multiplier = root.querySelector(
'input[aria-label="Priority 阶梯 1 缓存创建倍率"]',
) as HTMLInputElement
multiplier.value = '2'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const result = getFinalPricing()
expect(result.tiers[0].cache_creation_price_per_1m).toBe(6.25)
expect(result.processing_tiers?.priority.tiers?.[0].cache_creation_price_per_1m).toBe(20)
})
it('keeps processing image catalogs editable when token controls are hidden', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: { image_output_price_default: 0.05 },
},
} as TieredPricingConfig
const { root } = mountEditor(pricing, {
showTokenPricing: false,
showImagePricing: true,
showImageEditor: true,
})
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
expect(root.querySelector('[data-testid="tier-input-price"]')).toBeNull()
expect(root.querySelector('input[aria-label="Priority 图像输出默认价格"]')).not.toBeNull()
})
it('accepts a finite terminal tier for any processing overlay', () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
tiers: [{ up_to: 272000, input_price_per_1m: 10, output_price_per_1m: 60 }],
},
hyperlane: {
tiers: [{ up_to: 180000, input_price_per_1m: 7, output_price_per_1m: 42 }],
},
},
} as TieredPricingConfig
const { getFinalPricing, getValidationError } = mountEditor(pricing)
expect(getValidationError()).toBeNull()
expect(getFinalPricing().processing_tiers?.priority.tiers?.[0].up_to).toBe(272000)
expect(getFinalPricing().processing_tiers?.hyperlane.tiers?.[0].up_to).toBe(180000)
})
it('keeps Standard terminal coverage unbounded', () => {
const pricing = {
tiers: [{ up_to: 272000, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { getValidationError } = mountEditor(pricing)
expect(getValidationError()).toBe('Standard: 最后一个阶梯必须是无上限的')
})
it('switches a processing terminal tier between finite and unbounded coverage', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
tiers: [{ up_to: 272000, input_price_per_1m: 10, output_price_per_1m: 60 }],
},
},
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing)
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
const terminal = root.querySelector(
'select[aria-label="Priority 阶梯 1 上限"]',
) as HTMLSelectElement
expect(terminal.value).toBe('272000')
terminal.value = '-2'
terminal.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(getFinalPricing().processing_tiers?.priority.tiers?.[0].up_to).toBeNull()
terminal.value = '272000'
terminal.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(getFinalPricing().processing_tiers?.priority.tiers?.[0].up_to).toBe(272000)
})
it('preserves processing coverage when tiers are added and removed', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
tiers: [{ up_to: 272000, input_price_per_1m: 10, output_price_per_1m: 60 }],
},
},
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing)
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
const addButton = [...root.querySelectorAll('button')]
.find(button => button.textContent?.includes('添加价格阶梯'))
click(addButton ?? null)
await nextTick()
expect(getFinalPricing().processing_tiers?.priority.tiers?.map(tier => tier.up_to))
.toEqual([272000, null])
click(root.querySelector('button[aria-label="删除 Priority 阶梯 2"]'))
await nextTick()
expect(getFinalPricing().processing_tiers?.priority.tiers?.map(tier => tier.up_to))
.toEqual([272000])
})
it('preserves a special unknown processing tier key without prototype coercion', () => {
const pricing = JSON.parse(`{
"tiers": [{"up_to": null, "input_price_per_1m": 5, "output_price_per_1m": 30}],
"processing_tiers": {
"__proto__": {
"tiers": [{"up_to": null, "input_price_per_1m": 7, "output_price_per_1m": 42}],
"future_overlay_option": "keep"
}
}
}`) as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing)
expect(root.textContent).toContain('__proto__')
const result = getFinalPricing()
expect(Object.prototype.hasOwnProperty.call(result.processing_tiers, '__proto__')).toBe(true)
expect(result.processing_tiers?.__proto__.future_overlay_option).toBe('keep')
expect(result.processing_tiers?.__proto__.tiers?.[0].input_price_per_1m).toBe(7)
})
it('preserves future image pricing fields when image pricing is enabled', () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
image_output_prices: {
'1024x1024': { low: 0.01, ultra: 0.09 },
},
image_output_price_ranges: [{
up_to_pixels: 1_048_576,
prices: { low: 0.01, ultra: 0.09 },
future_range_option: { billing_unit: 'image' },
}],
} as TieredPricingConfig
const { getFinalPricing } = mountEditor(pricing, { showImagePricing: true })
const result = getFinalPricing()
expect(result.image_output_prices?.['1024x1024'].ultra).toBe(0.09)
expect(result.image_output_price_ranges?.[0].prices.ultra).toBe(0.09)
expect(result.image_output_price_ranges?.[0].future_range_option)
.toEqual({ billing_unit: 'image' })
})
it('rejects fractional image pixel limits without coercing them to integers', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
image_output_price_ranges: [{
up_to_pixels: 1_048_576,
prices: { high: 0.07 },
}],
} as TieredPricingConfig
const { getValidationError, root } = mountEditor(pricing, { showImagePricing: true })
const limit = root.querySelector(
'input[aria-label="图像像素区间 1 上限"]',
) as HTMLInputElement
limit.value = '1.5'
limit.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toBe('Standard: 图像像素区间 1 的上限必须是正整数')
})
it('treats an image-only processing overlay as a valid tier configuration', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
hyperlane: {
image_output_price_default: 0.08,
future_overlay_option: { billing_unit: 'image' },
},
},
} as TieredPricingConfig
const { getFinalPricing, root } = mountEditor(pricing, { showImagePricing: true })
click(root.querySelector('[data-processing-tier="hyperlane"]'))
await nextTick()
expect(root.textContent).not.toContain('至少需要一个价格阶梯')
expect(getFinalPricing().processing_tiers?.hyperlane).toEqual(
pricing.processing_tiers?.hyperlane,
)
})
it('edits image pricing through the active processing-tier scope', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
image_output_price_default: 0.01,
processing_tiers: {
priority: {
image_output_price_default: 0.05,
image_output_prices: {
'1024x1024': { high: 0.08, ultra: 0.12 },
},
image_output_price_ranges: [{
up_to_pixels: 1_048_576,
prices: { high: 0.07, ultra: 0.11 },
future_range_option: 'keep-priority',
}],
},
flex: {
image_output_price_default: 0.02,
},
},
} as TieredPricingConfig
const { getFinalPricing, root } = mountEditor(pricing, { showImagePricing: true })
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
const priorityDefault = root.querySelector(
'input[aria-label="Priority 图像输出默认价格"]',
) as HTMLInputElement
const priorityHigh = root.querySelector(
'input[aria-label="1024x1024 high 图像输出价格"]',
) as HTMLInputElement
expect(priorityDefault.value).toBe('0.05')
expect(priorityHigh.value).toBe('0.08')
priorityDefault.value = '0.06'
priorityDefault.dispatchEvent(new Event('input', { bubbles: true }))
priorityHigh.value = '0.09'
priorityHigh.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
click(root.querySelector('[data-processing-tier="flex"]'))
await nextTick()
const flexDefault = root.querySelector(
'input[aria-label="Flex 图像输出默认价格"]',
) as HTMLInputElement
expect(flexDefault.value).toBe('0.02')
const result = getFinalPricing()
expect(result.image_output_price_default).toBe(0.01)
expect(result.processing_tiers?.priority.image_output_price_default).toBe(0.06)
expect(result.processing_tiers?.priority.image_output_prices?.['1024x1024'].high).toBe(0.09)
expect(result.processing_tiers?.priority.image_output_prices?.['1024x1024'].ultra).toBe(0.12)
expect(result.processing_tiers?.priority.image_output_price_ranges?.[0].future_range_option)
.toBe('keep-priority')
expect(result.processing_tiers?.flex.image_output_price_default).toBe(0.02)
})
it('clears threshold editing state when removing and then adding tiers', async () => {
const pricing = {
tiers: [
{ up_to: 64_000, input_price_per_1m: 5, output_price_per_1m: 30 },
{ up_to: 128_000, input_price_per_1m: 7, output_price_per_1m: 42 },
{ up_to: null, input_price_per_1m: 9, output_price_per_1m: 54 },
],
} as TieredPricingConfig
const { root } = mountEditor(pricing)
const thresholdSelects = root.querySelectorAll('select')
const secondThreshold = thresholdSelects.item(1) as HTMLSelectElement
secondThreshold.value = '-1'
secondThreshold.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(root.querySelectorAll('input[placeholder="K"]')).toHaveLength(1)
const tierRemoveButtons = Array.from(root.querySelectorAll('button'))
.filter(button => button.querySelector('.lucide-x'))
click(tierRemoveButtons[0] ?? null)
await nextTick()
const addTierButton = Array.from(root.querySelectorAll('button'))
.find(button => button.textContent?.includes('添加价格阶梯'))
click(addTierButton ?? null)
await nextTick()
expect(root.querySelectorAll('input[placeholder="K"]')).toHaveLength(0)
})
it('gives every compact pricing control an accessible name', () => {
const pricing = {
tiers: [
{ up_to: 64_000, input_price_per_1m: 5, output_price_per_1m: 30 },
{ up_to: null, input_price_per_1m: 7, output_price_per_1m: 42 },
],
} as TieredPricingConfig
const { root } = mountEditor(pricing, {
showCache1h: true,
showImagePricing: true,
})
for (const control of root.querySelectorAll('input, select')) {
expect(control.getAttribute('aria-label'), control.outerHTML).toBeTruthy()
}
const iconOnlyButtons = Array.from(root.querySelectorAll('button'))
.filter(button => button.textContent?.trim() === '')
for (const button of iconOnlyButtons) {
expect(button.getAttribute('aria-label'), button.outerHTML).toBeTruthy()
}
})
it('blocks serialization when an inactive processing tier is invalid', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
tiers: [
{ up_to: 128_000, input_price_per_1m: 10, output_price_per_1m: 60 },
{ up_to: 64_000, input_price_per_1m: 11, output_price_per_1m: 66 },
{ up_to: null, input_price_per_1m: 12, output_price_per_1m: 72 },
],
},
},
} as TieredPricingConfig
const { getFinalPricing, getValidationError, onUpdate, root } = mountEditor(pricing)
const standardInput = root.querySelector('[data-testid="tier-input-price"]') as HTMLInputElement
standardInput.value = '6'
standardInput.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(onUpdate).not.toHaveBeenCalled()
expect(getValidationError()).toContain('Priority')
expect(getValidationError()).toContain('上限必须大于前一个阶梯')
expect(() => getFinalPricing()).toThrow('Priority')
})
it('rejects negative known prices before they reach the billing contract', () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: -1, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { getFinalPricing, getValidationError } = mountEditor(pricing)
expect(getValidationError()).toBe('Standard: 阶梯 1 的输入价格必须是非负有限数值')
expect(() => getFinalPricing()).toThrow('输入价格必须是非负有限数值')
})
})
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import type { TieredPricingConfig } from '@/api/endpoints/types'
import {
tieredPricingHasCacheTtl,
tieredPricingHasImageOutputPricing,
} from '../tiered-pricing'
function pricingWithProcessingTier(
processingTier: NonNullable<TieredPricingConfig['processing_tiers']>[string],
): TieredPricingConfig {
return {
tiers: [{ up_to: null, input_price_per_1m: 1, output_price_per_1m: 2 }],
processing_tiers: { priority: processingTier },
}
}
describe('tiered pricing capabilities', () => {
it('detects image prices in the base and processing-tier catalogs', () => {
expect(tieredPricingHasImageOutputPricing({
tiers: [],
image_output_price_default: 0,
})).toBe(true)
expect(tieredPricingHasImageOutputPricing(pricingWithProcessingTier({
image_output_prices: { '1024x1024': { high: 0.08 } },
}))).toBe(true)
expect(tieredPricingHasImageOutputPricing(pricingWithProcessingTier({
image_output_price_ranges: [{
up_to_pixels: null,
prices: { medium: 0.04 },
}],
}))).toBe(true)
expect(tieredPricingHasImageOutputPricing(pricingWithProcessingTier({}))).toBe(false)
})
it('detects cache TTL prices in the base and processing-tier catalogs', () => {
expect(tieredPricingHasCacheTtl({
tiers: [{
up_to: null,
input_price_per_1m: 1,
output_price_per_1m: 2,
cache_ttl_pricing: [{ ttl_minutes: 60, cache_creation_price_per_1m: 3 }],
}],
}, 60)).toBe(true)
expect(tieredPricingHasCacheTtl(pricingWithProcessingTier({
tiers: [{
up_to: null,
input_price_per_1m: 1,
output_price_per_1m: 2,
cache_ttl_pricing: [{ ttl_minutes: 60, cache_creation_price_per_1m: 3 }],
}],
}), 60)).toBe(true)
expect(tieredPricingHasCacheTtl(pricingWithProcessingTier({}), 60)).toBe(false)
})
})
@@ -0,0 +1,67 @@
import type {
ProcessingTierPricingConfig,
TieredPricingConfig,
} from '@/api/endpoints/types'
type PricingCatalog = TieredPricingConfig | ProcessingTierPricingConfig
export function comparePricingUpperBounds(
left: number | null,
right: number | null,
): number {
if (left === null && right === null) return 0
if (left === null) return 1
if (right === null) return -1
return left - right
}
function pricingCatalogs(pricing: TieredPricingConfig | null | undefined): PricingCatalog[] {
if (!pricing) return []
const processingTiers = pricing.processing_tiers
? Object.values(pricing.processing_tiers).filter(isRecord)
: []
return [pricing, ...processingTiers]
}
export function tieredPricingHasImageOutputPricing(
pricing: TieredPricingConfig | null | undefined,
): boolean {
return pricingCatalogs(pricing).some((catalog) => {
if (toFinitePrice(catalog.image_output_price_default) !== null) return true
if (Object.values(catalog.image_output_prices || {}).some(prices => (
isRecord(prices)
&& Object.values(prices).some(price => toFinitePrice(price) !== null)
))) return true
return (catalog.image_output_price_ranges || []).some(range => (
isRecord(range)
&& isRecord(range.prices)
&& Object.values(range.prices).some(price => toFinitePrice(price) !== null)
))
})
}
export function tieredPricingHasCacheTtl(
pricing: TieredPricingConfig | null | undefined,
ttlMinutes: number,
): boolean {
return pricingCatalogs(pricing).some(catalog => (
Array.isArray(catalog.tiers)
&& catalog.tiers.some(tier => (
Array.isArray(tier.cache_ttl_pricing)
&& tier.cache_ttl_pricing.some(entry => entry.ttl_minutes === ttlMinutes)
))
))
}
function toFinitePrice(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}