mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-11 21:50:19 +08:00
feat(frontend): expose processing tier pricing
This commit is contained in:
@@ -154,6 +154,18 @@ export interface RequestSchedulingFailure {
|
||||
no_upstream_attempt?: boolean | null
|
||||
}
|
||||
|
||||
export interface RequestSettlementPricingSnapshot {
|
||||
requested_processing_tier?: string | null
|
||||
actual_processing_tier?: string | null
|
||||
billing_processing_tier?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface RequestSettlementSnapshot {
|
||||
pricing_snapshot?: RequestSettlementPricingSnapshot | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface RequestDetail {
|
||||
id: string // UUID
|
||||
request_id: string
|
||||
@@ -175,6 +187,7 @@ export interface RequestDetail {
|
||||
target_model?: string | null // 映射后的目标模型名
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
@@ -262,6 +275,7 @@ export interface RequestDetail {
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
price_per_request?: number
|
||||
settlement_snapshot?: RequestSettlementSnapshot | null
|
||||
} | null
|
||||
// 阶梯计费信息
|
||||
tiered_pricing?: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ProviderModelMapping } from './provider'
|
||||
export interface CacheTTLPricing {
|
||||
ttl_minutes: number
|
||||
cache_creation_price_per_1m: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 单个价格阶梯配置 */
|
||||
@@ -16,22 +17,39 @@ export interface PricingTier {
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: CacheTTLPricing[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ImageOutputQuality = 'low' | 'medium' | 'high'
|
||||
|
||||
export interface ImageOutputQualityPricing extends Partial<Record<ImageOutputQuality, number>> {
|
||||
[quality: string]: unknown
|
||||
}
|
||||
|
||||
export interface ImageOutputPriceRange {
|
||||
up_to_pixels: number | null
|
||||
prices: Partial<Record<ImageOutputQuality, number>>
|
||||
prices: ImageOutputQualityPricing
|
||||
label?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 按处理层级覆盖的费率配置。允许图像或未来计费字段独立扩展。 */
|
||||
export interface ProcessingTierPricingConfig {
|
||||
tiers?: PricingTier[]
|
||||
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
|
||||
image_output_price_default?: number | null
|
||||
image_output_price_ranges?: ImageOutputPriceRange[] | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 阶梯计费配置 */
|
||||
export interface TieredPricingConfig {
|
||||
tiers: PricingTier[]
|
||||
image_output_prices?: Record<string, Record<string, number>> | null
|
||||
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
|
||||
image_output_price_default?: number | null
|
||||
image_output_price_ranges?: ImageOutputPriceRange[] | null
|
||||
processing_tiers?: Record<string, ProcessingTierPricingConfig> | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface UsageRecordDetail {
|
||||
model: string
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
@@ -369,6 +370,7 @@ export const meApi = {
|
||||
target_model?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids ? { ids } : {}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface UsageRecord {
|
||||
model: string
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
@@ -567,6 +568,7 @@ export const usageApi = {
|
||||
target_model?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
image_progress?: ImageProgress | null
|
||||
}>
|
||||
}> {
|
||||
|
||||
@@ -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
+102
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
+615
@@ -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)
|
||||
}
|
||||
@@ -321,6 +321,7 @@ import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
|
||||
import { createGlobalModel, listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
|
||||
import { tieredPricingHasImageOutputPricing } from '@/features/models/utils/tiered-pricing'
|
||||
import type { Model, TieredPricingConfig } from '@/api/endpoints'
|
||||
import {
|
||||
buildProviderModelCreatePayload,
|
||||
@@ -572,31 +573,6 @@ function modelSupportsImageGeneration(model: {
|
||||
|| tieredPricingHasImageOutputPricing(model.effective_tiered_pricing)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
|
||||
const enabled = value === true
|
||||
imageGenerationExplicitOverride.value = enabled
|
||||
@@ -812,6 +788,12 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
const pricingValidationError = tieredPricingEditorRef.value?.getValidationError()
|
||||
if (pricingValidationError) {
|
||||
showError(pricingValidationError, '价格配置错误')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// 获取包含自动计算缓存价格的最终数据
|
||||
|
||||
@@ -249,6 +249,13 @@
|
||||
<span class="ml-1 font-bold text-primary">{{ formatOutputRateValue(detailOutputRate) }}tps</span>
|
||||
</span>
|
||||
</div>
|
||||
<ServiceTierFacts
|
||||
v-if="hasServiceTierFacts"
|
||||
class="mt-3"
|
||||
:requested="serviceTierFacts.requested"
|
||||
:actual="serviceTierFacts.actual"
|
||||
:billing="serviceTierFacts.billing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
@@ -907,6 +914,8 @@ import JsonContentPanel from './JsonContentPanel.vue'
|
||||
import ConversationView from './RequestDetailDrawer/ConversationView.vue'
|
||||
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
||||
import ReplayDialog from './ReplayDialog.vue'
|
||||
import ServiceTierFacts from './ServiceTierFacts.vue'
|
||||
import { hasServiceTierFact, resolveServiceTierFacts } from '../utils/service-tier'
|
||||
|
||||
// 对话解析器
|
||||
import {
|
||||
@@ -952,6 +961,7 @@ const emit = defineEmits<{
|
||||
targetModel?: string | null
|
||||
reasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
actualServiceTier?: string | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
}]
|
||||
@@ -1134,6 +1144,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
|
||||
targetModel: nextDetail.target_model ?? null,
|
||||
reasoningEffort: nextDetail.reasoning_effort ?? null,
|
||||
serviceTier: nextDetail.service_tier ?? null,
|
||||
actualServiceTier: nextDetail.actual_service_tier ?? null,
|
||||
errorMessage: nextDetail.error_message ?? undefined,
|
||||
})
|
||||
}
|
||||
@@ -1342,6 +1353,9 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
|
||||
|
||||
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
||||
|
||||
const serviceTierFacts = computed(() => resolveServiceTierFacts(detail.value))
|
||||
const hasServiceTierFacts = computed(() => hasServiceTierFact(serviceTierFacts.value))
|
||||
|
||||
const settlementInfo = computed<JsonRecord | null>(() =>
|
||||
asRecord(detail.value?.settlement ?? null),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<dl
|
||||
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3"
|
||||
data-testid="service-tier-facts"
|
||||
>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
<dt class="text-muted-foreground">
|
||||
请求层级
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="requested || '-'"
|
||||
>
|
||||
{{ requested || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
<dt class="text-muted-foreground">
|
||||
实际层级
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="actual || '-'"
|
||||
>
|
||||
{{ actual || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
<dt class="text-muted-foreground">
|
||||
计费层级
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="billing || '-'"
|
||||
>
|
||||
{{ billing || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
requested: string | null
|
||||
actual: string | null
|
||||
billing: string | null
|
||||
}>()
|
||||
</script>
|
||||
@@ -1610,26 +1610,42 @@ function getReasoningEffortTitle(record: UsageRecord): string {
|
||||
return effort ? `Reasoning: ${effort}` : ''
|
||||
}
|
||||
|
||||
function getServiceTier(record: UsageRecord): string | null {
|
||||
const serviceTier = record.service_tier?.trim().toLowerCase()
|
||||
function normalizeServiceTier(value: string | null | undefined): string | null {
|
||||
const serviceTier = value?.trim().toLowerCase()
|
||||
return serviceTier || null
|
||||
}
|
||||
|
||||
function getRequestedServiceTier(record: UsageRecord): string | null {
|
||||
return normalizeServiceTier(record.service_tier)
|
||||
}
|
||||
|
||||
function getActualServiceTier(record: UsageRecord): string | null {
|
||||
return normalizeServiceTier(record.actual_service_tier)
|
||||
}
|
||||
|
||||
function getFastBadge(record: UsageRecord): boolean {
|
||||
return getServiceTier(record) === 'priority'
|
||||
return (getActualServiceTier(record) ?? getRequestedServiceTier(record)) === 'priority'
|
||||
}
|
||||
|
||||
function getFastBadgeTitle(record: UsageRecord): string {
|
||||
const serviceTier = getServiceTier(record)
|
||||
return serviceTier ? `Service tier: ${serviceTier}` : ''
|
||||
const requested = getRequestedServiceTier(record)
|
||||
const actual = getActualServiceTier(record)
|
||||
if (requested && actual) {
|
||||
return requested === actual
|
||||
? `Requested and actual service tier: ${actual}`
|
||||
: `Requested service tier: ${requested}\nActual service tier: ${actual}`
|
||||
}
|
||||
if (actual) return `Actual service tier: ${actual}`
|
||||
return requested ? `Requested service tier: ${requested}` : ''
|
||||
}
|
||||
|
||||
// 获取模型列的 tooltip
|
||||
function getModelTooltip(record: UsageRecord): string {
|
||||
const actualModel = getActualModel(record)
|
||||
const reasoningEffort = getReasoningEffort(record)
|
||||
const fastSuffix = getFastBadge(record) ? '\nService tier: priority' : ''
|
||||
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${fastSuffix}`
|
||||
const serviceTierTitle = getFastBadgeTitle(record)
|
||||
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
|
||||
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}`
|
||||
if (actualModel) {
|
||||
return `${record.model} -> ${actualModel}${suffix}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, h, type App } from 'vue'
|
||||
|
||||
import ServiceTierFacts from '../ServiceTierFacts.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('ServiceTierFacts', () => {
|
||||
it('renders all three facts and marks a missing actual tier explicitly', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: null,
|
||||
billing: 'flex',
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
expect(root.querySelector('[data-testid="service-tier-facts"]')).not.toBeNull()
|
||||
expect([...root.querySelectorAll('dt')].map(node => node.textContent?.trim())).toEqual([
|
||||
'请求层级',
|
||||
'实际层级',
|
||||
'计费层级',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'priority',
|
||||
'-',
|
||||
'flex',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -295,6 +295,22 @@ describe('UsageRecordsTable', () => {
|
||||
expect(root.textContent).toContain('fast')
|
||||
})
|
||||
|
||||
it('uses the actual service tier when the provider changes processing class', () => {
|
||||
const downgraded = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'default',
|
||||
})])
|
||||
expect(downgraded.textContent).not.toContain('fast')
|
||||
expect(downgraded.querySelector('[title*="Requested service tier: priority"]')).not.toBeNull()
|
||||
expect(downgraded.querySelector('[title*="Actual service tier: default"]')).not.toBeNull()
|
||||
|
||||
const upgraded = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'flex',
|
||||
actual_service_tier: 'priority',
|
||||
})])
|
||||
expect(upgraded.textContent).toContain('fast')
|
||||
})
|
||||
|
||||
it('offers embedding API formats in the usage record filter', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({ api_format: 'openai:chat' })])
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ describe('useUsageData', () => {
|
||||
target_model: 'gpt-5.5',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'auto',
|
||||
actual_service_tier: 'priority',
|
||||
})
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
@@ -246,6 +247,7 @@ describe('useUsageData', () => {
|
||||
target_model: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
actual_service_tier: null,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
@@ -279,6 +281,7 @@ describe('useUsageData', () => {
|
||||
target_model: 'gpt-5.5',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'auto',
|
||||
actual_service_tier: 'priority',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -631,7 +631,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
rate_multiplier: record.rate_multiplier ?? existing.rate_multiplier,
|
||||
target_model: record.target_model ?? existing.target_model,
|
||||
reasoning_effort: record.reasoning_effort ?? existing.reasoning_effort,
|
||||
service_tier: record.service_tier ?? existing.service_tier
|
||||
service_tier: record.service_tier ?? existing.service_tier,
|
||||
actual_service_tier: record.actual_service_tier ?? existing.actual_service_tier
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface UsageRecord {
|
||||
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
|
||||
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
|
||||
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
|
||||
actual_service_tier?: string | null // Provider 响应确认的实际服务层级
|
||||
api_format?: string
|
||||
endpoint_api_format?: string // 端点原生格式
|
||||
has_format_conversion?: boolean // 是否发生了格式转换
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
hasServiceTierFact,
|
||||
normalizeServiceTierFact,
|
||||
resolveServiceTierFacts,
|
||||
} from '../service-tier'
|
||||
|
||||
describe('service tier facts', () => {
|
||||
it('keeps requested, actual and billing tiers independent', () => {
|
||||
const facts = resolveServiceTierFacts({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'default',
|
||||
settlement: {
|
||||
settlement_snapshot: {
|
||||
pricing_snapshot: {
|
||||
requested_processing_tier: 'ignored-requested-snapshot',
|
||||
actual_processing_tier: 'ignored-actual-snapshot',
|
||||
billing_processing_tier: 'standard',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(facts).toEqual({ requested: 'priority', actual: 'default', billing: 'standard' })
|
||||
expect(hasServiceTierFact(facts)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not infer billing from requested or actual tiers', () => {
|
||||
expect(resolveServiceTierFacts({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'flex',
|
||||
})).toEqual({ requested: 'priority', actual: 'flex', billing: null })
|
||||
})
|
||||
|
||||
it('normalizes only non-empty string facts', () => {
|
||||
expect(normalizeServiceTierFact(' Batch ')).toBe('Batch')
|
||||
expect(normalizeServiceTierFact(' ')).toBeNull()
|
||||
expect(normalizeServiceTierFact(0)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface ServiceTierFacts {
|
||||
requested: string | null
|
||||
actual: string | null
|
||||
billing: string | null
|
||||
}
|
||||
|
||||
export interface ServiceTierFactSource {
|
||||
service_tier?: unknown
|
||||
actual_service_tier?: unknown
|
||||
settlement?: unknown
|
||||
}
|
||||
|
||||
export function resolveServiceTierFacts(
|
||||
source: ServiceTierFactSource | null | undefined,
|
||||
): ServiceTierFacts {
|
||||
const settlement = asRecord(source?.settlement)
|
||||
const settlementSnapshot = asRecord(settlement?.settlement_snapshot)
|
||||
const pricingSnapshot = asRecord(settlementSnapshot?.pricing_snapshot)
|
||||
return {
|
||||
requested: normalizeServiceTierFact(source?.service_tier),
|
||||
actual: normalizeServiceTierFact(source?.actual_service_tier),
|
||||
billing: normalizeServiceTierFact(pricingSnapshot?.billing_processing_tier),
|
||||
}
|
||||
}
|
||||
|
||||
export function hasServiceTierFact(facts: ServiceTierFacts): boolean {
|
||||
return facts.requested !== null || facts.actual !== null || facts.billing !== null
|
||||
}
|
||||
|
||||
export function normalizeServiceTierFact(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
@@ -568,6 +568,11 @@ async function pollActiveRequests() {
|
||||
? update.service_tier
|
||||
: null
|
||||
}
|
||||
if ('actual_service_tier' in update) {
|
||||
record.actual_service_tier = typeof update.actual_service_tier === 'string'
|
||||
? update.actual_service_tier
|
||||
: null
|
||||
}
|
||||
// 管理员接口返回额外字段
|
||||
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
|
||||
if ('provider' in update && typeof update.provider === 'string') {
|
||||
@@ -1039,6 +1044,7 @@ function handleDetailRequestState(update: {
|
||||
targetModel?: string | null
|
||||
reasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
actualServiceTier?: string | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
}) {
|
||||
@@ -1130,6 +1136,11 @@ function handleDetailRequestState(update: {
|
||||
if ('serviceTier' in update) {
|
||||
record.service_tier = typeof update.serviceTier === 'string' ? update.serviceTier : null
|
||||
}
|
||||
if ('actualServiceTier' in update) {
|
||||
record.actual_service_tier = typeof update.actualServiceTier === 'string'
|
||||
? update.actualServiceTier
|
||||
: null
|
||||
}
|
||||
if ('imageProgress' in update) {
|
||||
const nextProgress = update.imageProgress ?? null
|
||||
if (!sameImageProgress(record.image_progress, nextProgress)) {
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 定价信息 -->
|
||||
@@ -173,6 +174,8 @@
|
||||
定价信息
|
||||
</h4>
|
||||
|
||||
<ProcessingTierPricingSummary :pricing="model.default_tiered_pricing" />
|
||||
|
||||
<!-- 单阶梯(固定价格)展示 -->
|
||||
<div
|
||||
v-if="getTierCount(model.default_tiered_pricing) <= 1"
|
||||
@@ -336,6 +339,7 @@ import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens } from '@/utils/format'
|
||||
import ProcessingTierPricingSummary from '@/features/models/components/ProcessingTierPricingSummary.vue'
|
||||
|
||||
import type { PublicGlobalModel } from '@/api/public-models'
|
||||
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
|
||||
|
||||
Reference in New Issue
Block a user