feat(pricing): support processing tier multipliers

This commit is contained in:
elky
2026-07-16 23:30:42 +08:00
parent 312583f055
commit 0be380243b
40 changed files with 5012 additions and 565 deletions
@@ -183,4 +183,98 @@ describe('resolveModelsDevTieredPricing', () => {
it('does not synthesize pricing when the fetched cost is absent', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', undefined)).toBeNull()
})
it('keeps a models.dev fast cost as an explicit Priority catalog when bands differ', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', {
input: 5,
output: 30,
tiers: [{
input: 10,
output: 45,
tier: { type: 'context', size: 272_000 },
}],
}, {
fast: {
cost: { input: 10, output: 60 },
provider: { body: { service_tier: 'priority' } },
},
})).toEqual({
tiers: [
{ up_to: 271_999, input_price_per_1m: 5, output_price_per_1m: 30 },
{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 45 },
],
processing_tiers: {
priority: {
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
},
},
})
})
it('uses a multiplier only when every fast price has the same ratio', () => {
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-4.8', {
input: 5,
output: 25,
cache_read: 0.5,
cache_write: 6.25,
}, {
fast: {
cost: { input: 10, output: 50, cache_read: 1, cache_write: 12.5 },
provider: { body: { speed: 'fast' } },
},
})?.processing_tiers).toEqual({
fast: { price_multiplier: 2 },
})
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-4.7', {
input: 5,
output: 25,
}, {
fast: {
cost: { input: 30, output: 150 },
provider: { body: { speed: 'fast' } },
},
})?.processing_tiers).toEqual({
fast: { price_multiplier: 6 },
})
})
it('prefers Anthropic speed=fast when the mode body also carries a standard service tier', () => {
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-fast', {
input: 5,
output: 25,
}, {
fast: {
cost: { input: 10, output: 50 },
provider: { body: { speed: ' FAST ', service_tier: 'default' } },
},
})?.processing_tiers).toEqual({
fast: { price_multiplier: 2 },
})
})
it('falls back to the mode key and keeps non-uniform prices explicit', () => {
expect(resolveModelsDevTieredPricing('vendor', 'model', {
input: 2,
output: 4,
}, {
flex: { cost: { input: 1, output: 3 } },
})?.processing_tiers).toEqual({
flex: {
tiers: [{ up_to: null, input_price_per_1m: 1, output_price_per_1m: 3 }],
},
})
})
it('does not reinterpret unrelated or special experimental modes as processing tiers', () => {
const modes = JSON.parse(
'{"pro":{"cost":{"input":2,"output":4}},"__proto__":{"cost":{"input":2,"output":4}}}',
)
const pricing = resolveModelsDevTieredPricing('vendor', 'model', {
input: 1,
output: 2,
}, modes)
expect(pricing?.processing_tiers).toBeUndefined()
})
})
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
get: vi.fn(),
}))
vi.mock('@/api/client', () => ({
default: { get: apiMocks.get },
}))
import { clearModelsDevCache, getModelsDevList } from '@/api/models-dev'
beforeEach(() => {
clearModelsDevCache()
localStorage.clear()
apiMocks.get.mockReset()
})
describe('getModelsDevList', () => {
it('uses current modalities and experimental mode pricing while keeping legacy fallbacks', async () => {
apiMocks.get.mockResolvedValue({
data: {
openai: {
id: 'openai',
name: 'OpenAI',
official: true,
models: {
'gpt-test': {
id: 'gpt-test',
name: 'GPT Test',
input: ['text'],
output: ['text'],
modalities: {
input: ['text', 'image'],
output: ['text', 'image'],
},
cost: { input: 2, output: 4 },
experimental: {
modes: {
fast: {
cost: { input: 4, output: 8 },
provider: { body: { service_tier: 'priority' } },
},
},
},
},
legacy: {
id: 'legacy',
name: 'Legacy',
input: ['text', 'image'],
output: ['text'],
cost: { input: 1, output: 2 },
},
},
},
},
})
const models = await getModelsDevList()
const current = models.find(model => model.modelId === 'gpt-test')
const legacy = models.find(model => model.modelId === 'legacy')
expect(current).toMatchObject({
supportsVision: true,
inputModalities: ['text', 'image'],
outputModalities: ['text', 'image'],
tieredPricing: {
processing_tiers: { priority: { price_multiplier: 2 } },
},
})
expect(legacy).toMatchObject({
supportsVision: true,
inputModalities: ['text', 'image'],
outputModalities: ['text'],
})
})
})
+26 -24
View File
@@ -154,10 +154,34 @@ export interface RequestSchedulingFailure {
no_upstream_attempt?: boolean | null
}
export interface RequestPricingTier {
up_to?: number | null
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
cache_ttl_pricing?: Array<{
ttl_minutes?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
}> | null
[key: string]: unknown
}
export interface RequestSettlementTieredPricing {
tiers?: RequestPricingTier[] | null
[key: string]: unknown
}
export interface RequestSettlementPricingSnapshot {
requested_processing_tier?: string | null
actual_processing_tier?: string | null
billing_processing_tier?: string | null
processing_tier_price_multiplier?: number | null
pricing_source?: string | null
tiered_pricing_source?: string | null
price_per_request_source?: string | null
tiered_pricing?: RequestSettlementTieredPricing | null
[key: string]: unknown
}
@@ -283,30 +307,8 @@ export interface RequestDetail {
tier_index: number // 命中的阶梯索引 (0-based)
tier_count: number // 阶梯总数
source?: 'provider' | 'global' // 定价来源: 提供商或全局
current_tier: { // 当前命中的阶梯配置
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}
tiers: Array<{ // 完整阶梯配置列表
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}>
current_tier: RequestPricingTier // 当前命中的阶梯配置
tiers: RequestPricingTier[] // 完整阶梯配置列表
} | null
// 视频/图像/音频计费信息
video_billing?: VideoBilling | null
+19 -4
View File
@@ -35,6 +35,8 @@ export interface ImageOutputPriceRange {
/** 按处理层级覆盖的费率配置。允许图像或未来计费字段独立扩展。 */
export interface ProcessingTierPricingConfig {
/** 相对 Standard 目录的统一价格倍率。新写入应与显式目录二选一;读取混合配置时显式目录优先。 */
price_multiplier?: number
tiers?: PricingTier[]
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
image_output_price_default?: number | null
@@ -52,6 +54,19 @@ export interface TieredPricingConfig {
[key: string]: unknown
}
/**
* Provider 价格覆盖可以只声明 processing_tiers,并继续从 GlobalModel
* 继承 Standard 目录,因此 tiers 在原始 Provider 配置中是可选的。
*/
export interface ProviderTieredPricingConfig {
tiers?: PricingTier[]
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 {
id: string
provider_id: string
@@ -61,7 +76,7 @@ export interface Model {
config?: Record<string, unknown> | null // 额外配置(如 billing/video 等)
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
price_per_request?: number | null // 按次计费价格
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
tiered_pricing?: ProviderTieredPricingConfig | null // Provider 原始覆盖,可仅包含 processing_tiers
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
@@ -69,7 +84,7 @@ export interface Model {
supports_image_generation?: boolean | null
supports_embedding?: boolean | null
// 有效值(合并 Model 和 GlobalModel 默认值后的结果)
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置
effective_tiered_pricing?: ProviderTieredPricingConfig | null // 当前响应可能是 Provider partial 覆盖
effective_input_price?: number | null
effective_output_price?: number | null
effective_price_per_request?: number | null // 有效按次计费价格
@@ -97,7 +112,7 @@ export interface ModelCreate {
global_model_id: string // 关联的 GlobalModel ID(必填)
// 计费配置(可选,为空时使用 GlobalModel 默认值)
price_per_request?: number // 按次计费价格
tiered_pricing?: TieredPricingConfig // 阶梯计费配置
tiered_pricing?: ProviderTieredPricingConfig // Provider 阶梯计费覆盖
// 能力配置(可选,为空时使用 GlobalModel 默认值)
supports_vision?: boolean
supports_function_calling?: boolean
@@ -113,7 +128,7 @@ export interface ModelUpdate {
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
global_model_id?: string
price_per_request?: number | null // 按次计费价格(null 表示清空/使用默认值)
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
tiered_pricing?: ProviderTieredPricingConfig | null // Provider 阶梯计费覆盖
supports_vision?: boolean
supports_function_calling?: boolean
supports_streaming?: boolean
+86 -1
View File
@@ -21,6 +21,14 @@ export interface ModelsDevCost extends ModelsDevTokenCost {
tiers?: ModelsDevCostTier[]
}
const TOKEN_PRICE_FIELDS = [
'input_price_per_1m',
'output_price_per_1m',
'cache_creation_price_per_1m',
'cache_read_price_per_1m',
] as const
const PROCESSING_MODE_FALLBACK_KEYS = new Set(['fast', 'priority', 'flex', 'batch'])
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -91,11 +99,88 @@ export function buildModelsDevTieredPricing(cost: unknown): TieredPricingConfig
return { tiers }
}
function uniformPriceMultiplier(
standard: TieredPricingConfig,
processing: TieredPricingConfig,
): number | null {
if (standard.tiers.length !== processing.tiers.length) return null
let candidate: number | null = null
for (const [index, standardTier] of standard.tiers.entries()) {
const processingTier = processing.tiers[index]
if (standardTier.up_to !== processingTier?.up_to) return null
for (const field of TOKEN_PRICE_FIELDS) {
const standardPrice = standardTier[field]
const processingPrice = processingTier[field]
if (standardPrice === undefined || processingPrice === undefined) {
if (standardPrice !== processingPrice) return null
continue
}
if (standardPrice === 0) {
if (processingPrice !== 0) return null
continue
}
const ratio = processingPrice / standardPrice
if (!Number.isFinite(ratio) || ratio < 0) return null
if (candidate === null) candidate = ratio
if (Math.abs(processingPrice - standardPrice * candidate) > 1e-9) return null
}
}
return candidate
}
export function resolveModelsDevTieredPricing(
_providerId: string,
_modelId: string,
cost: unknown,
experimentalModes?: unknown,
): TieredPricingConfig | null {
// Provider/model identities must never inject local prices over the fetched catalog.
return buildModelsDevTieredPricing(cost)
const standard = buildModelsDevTieredPricing(cost)
if (!standard || !isRecord(experimentalModes)) return standard
const processingTierEntries: Array<[string, NonNullable<TieredPricingConfig['processing_tiers']>[string]]> = []
const seenProcessingTiers = new Set<string>()
for (const [modeKey, rawMode] of Object.entries(experimentalModes)) {
if (!isRecord(rawMode)) continue
const modePricing = buildModelsDevTieredPricing(rawMode.cost)
if (!modePricing) continue
const provider = isRecord(rawMode.provider) ? rawMode.provider : null
const body = provider && isRecord(provider.body) ? provider.body : null
// Anthropic Fast is expressed with `speed=fast`. A provider body may also carry a
// `service_tier` fact (commonly `default`/`standard`), but runtime settlement deliberately
// gives Fast speed precedence, so catalog import must resolve the same processing-tier key.
const mappedProcessingTier = typeof body?.speed === 'string'
&& body.speed.trim().toLowerCase() === 'fast'
? body.speed
: typeof body?.service_tier === 'string'
? body.service_tier
: null
const normalizedModeKey = modeKey.trim().toLowerCase()
const rawProcessingTier = mappedProcessingTier
?? (PROCESSING_MODE_FALLBACK_KEYS.has(normalizedModeKey) ? normalizedModeKey : '')
const processingTier = rawProcessingTier.trim().toLowerCase()
if (
!processingTier
|| processingTier.length > 64
|| ['auto', 'default', 'standard'].includes(processingTier)
|| seenProcessingTiers.has(processingTier)
) {
continue
}
const multiplier = uniformPriceMultiplier(standard, modePricing)
seenProcessingTiers.add(processingTier)
processingTierEntries.push([processingTier, multiplier === null
? { tiers: modePricing.tiers }
: { price_multiplier: multiplier }])
}
if (processingTierEntries.length === 0) return standard
return {
...standard,
processing_tiers: Object.fromEntries(processingTierEntries),
}
}
+24 -4
View File
@@ -36,8 +36,21 @@ export interface ModelsDevModel {
last_updated?: string
input?: string[] // 输入模态: text, image, audio, video, pdf
output?: string[] // 输出模态: text, image, audio
modalities?: {
input?: string[]
output?: string[]
}
open_weights?: boolean
cost?: ModelsDevCost
experimental?: {
modes?: Record<string, {
cost?: ModelsDevCost
provider?: {
body?: Record<string, unknown>
headers?: Record<string, string>
}
}>
}
limit?: ModelsDevLimit
deprecated?: boolean
}
@@ -166,7 +179,14 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
if (!provider.models) continue
for (const [modelId, model] of Object.entries(provider.models)) {
const tieredPricing = resolveModelsDevTieredPricing(providerId, modelId, model.cost)
const inputModalities = model.modalities?.input ?? model.input
const outputModalities = model.modalities?.output ?? model.output
const tieredPricing = resolveModelsDevTieredPricing(
providerId,
modelId,
model.cost,
model.experimental?.modes,
)
const basePricingTier = tieredPricing?.tiers[0]
items.push({
providerId,
@@ -179,7 +199,7 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
tieredPricing: tieredPricing ?? undefined,
contextLimit: model.limit?.context,
outputLimit: model.limit?.output,
supportsVision: model.input?.includes('image'),
supportsVision: inputModalities?.includes('image'),
supportsToolCall: model.tool_call,
supportsReasoning: model.reasoning,
supportsStructuredOutput: model.structured_output,
@@ -194,8 +214,8 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
// display_metadata 相关字段
knowledgeCutoff: model.knowledge,
releaseDate: model.release_date,
inputModalities: model.input,
outputModalities: model.output,
inputModalities,
outputModalities,
})
}
}
@@ -398,6 +398,7 @@
:show-token-pricing="billingMode === 'token'"
:show-image-pricing="isImageGenerationEnabled"
:show-image-editor="billingMode === 'image'"
:show-processing-tier-multiplier-controls="true"
/>
<TabsContent
@@ -137,7 +137,6 @@
</p>
</div>
</div>
</div>
<!-- 默认定价 -->
@@ -34,6 +34,15 @@
</div>
</div>
<div
v-if="activePriceMultiplier !== null"
class="flex items-center justify-between rounded-md border bg-muted/20 px-3 py-2 text-xs"
data-testid="processing-tier-price-multiplier"
>
<span class="text-muted-foreground">相对 Standard</span>
<span class="font-mono font-medium text-foreground">{{ formatMultiplier(activePriceMultiplier) }}×</span>
</div>
<div
v-if="activeTokenTiers.length > 0"
class="overflow-x-auto rounded-md border"
@@ -226,7 +235,8 @@ const props = defineProps<{
}>()
const KNOWN_PROCESSING_TIERS = [
{ key: 'priority', label: 'Priority' },
{ key: 'priority', label: 'FastOpenAI' },
{ key: 'fast', label: 'FastClaude' },
{ key: 'flex', label: 'Flex' },
{ key: 'batch', label: 'Batch' },
] as const
@@ -264,6 +274,14 @@ const activeTokenTiers = computed<PricingTier[]>(() =>
? activeEntry.value.config.tiers.filter(isRecord) as PricingTier[]
: [],
)
const activePriceMultiplier = computed(() => {
const config = activeEntry.value?.config
if (!config || processingPricingHasExplicitFacts(config)) return null
const multiplier = config.price_multiplier
return typeof multiplier === 'number' && Number.isFinite(multiplier) && multiplier >= 0
? multiplier
: null
})
const activeImageDefaultPrice = computed(() =>
toFiniteNumber(activeEntry.value?.config.image_output_price_default),
)
@@ -311,6 +329,15 @@ const imageTableMinWidthClass = computed(() =>
)
function processingPricingHasFacts(config: ProcessingTierPricingConfig): boolean {
if (
typeof config.price_multiplier === 'number'
&& Number.isFinite(config.price_multiplier)
&& config.price_multiplier >= 0
) return true
return processingPricingHasExplicitFacts(config)
}
function processingPricingHasExplicitFacts(config: ProcessingTierPricingConfig): boolean {
if (Array.isArray(config.tiers) && config.tiers.length > 0) return true
if (toFiniteNumber(config.image_output_price_default) !== null) return true
if (isRecord(config.image_output_prices)) {
@@ -318,12 +345,26 @@ function processingPricingHasFacts(config: ProcessingTierPricingConfig): boolean
if (isRecord(prices) && Object.keys(finitePriceRecord(prices)).length > 0) return true
}
}
return Array.isArray(config.image_output_price_ranges)
if (Array.isArray(config.image_output_price_ranges)
&& config.image_output_price_ranges.some(range => (
isRecord(range)
&& isRecord(range.prices)
&& Object.keys(finitePriceRecord(range.prices)).length > 0
))
))) return true
return [
'image_output_price_per_image',
'image_output_price_matrix',
'image_prices',
].some(key => valueHasEntries(config[key]))
}
function valueHasEntries(value: unknown): boolean {
return (Array.isArray(value) && value.length > 0)
|| (isRecord(value) && Object.keys(value).length > 0)
}
function formatMultiplier(value: number): string {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6)))
}
function formatTokenRange(tiers: PricingTier[], index: number): string {
@@ -1,6 +1,9 @@
<template>
<div class="space-y-3">
<div class="space-y-2 border-b border-border/60 pb-3">
<div
v-if="showProcessingTierControls"
class="space-y-2 border-b border-border/60 pb-3"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-foreground">
@@ -55,210 +58,355 @@
</div>
<div
v-if="!isActivePricingScopeConfigured"
v-if="showProcessingTierControls && !isActivePricingScopeConfigured"
class="flex flex-wrap items-center justify-between gap-3 py-4"
data-testid="processing-tier-empty"
>
<p class="text-sm text-muted-foreground">
未配置 {{ activeProcessingTierLabel }} 费率
</p>
<Button
type="button"
variant="outline"
size="sm"
data-testid="processing-tier-add"
@click="addActiveProcessingTier"
>
<Plus class="mr-2 h-4 w-4" />
添加费率
</Button>
<div class="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
data-testid="processing-tier-add-multiplier"
@click="startActiveProcessingTierMultiplier"
>
<Plus class="mr-2 h-4 w-4" />
使用倍率
</Button>
<Button
type="button"
variant="outline"
size="sm"
data-testid="processing-tier-add"
@click="addActiveProcessingTier"
>
<Plus class="mr-2 h-4 w-4" />
添加自定义费率
</Button>
</div>
</div>
<template v-else>
<template v-if="showTokenPricing !== false">
<!-- 阶梯列表 -->
<div
v-for="(tier, index) in localTiers"
:key="index"
class="space-y-3 border-b border-border/60 pb-3 last:border-b-0"
>
<!-- 阶梯头部 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
<span class="text-muted-foreground">-</span>
<template v-if="isTierUpperBoundEditable(index)">
<template v-if="customInputMode[index]">
<Input
v-model="customInputValue[index]"
type="number"
min="1"
class="h-7 w-20 text-sm"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 自定义上限 Token`"
placeholder="K"
@keyup.enter="confirmCustomInput(index)"
@blur="confirmCustomInput(index)"
/>
<span class="text-xs text-muted-foreground">K</span>
</template>
<select
v-else
:value="getSelectValue(index)"
class="h-7 px-2 text-sm border rounded bg-background"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 上限`"
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
>
<option
v-for="opt in getAvailableThresholds(index)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</template>
<span
v-else
class="font-medium"
>无上限</span>
</div>
<div class="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-muted-foreground"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 切换缓存价格输入方式`"
@click="toggleCachePriceMode(index)"
>
<Repeat2 class="mr-1 h-3.5 w-3.5" />
{{ getCachePriceMode(index) === 'multiplier' ? '价格' : '倍率' }}
</Button>
<Button
v-if="localTiers.length > 1"
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
:aria-label="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
:title="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
@click="removeTier(index)"
>
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<div
v-if="showProcessingTierControls && activeProcessingTierUsesMultiplier"
class="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3"
data-testid="processing-tier-multiplier-editor"
>
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="space-y-1">
<Label class="text-xs font-medium">层级倍率相对 Standard</Label>
<p class="text-xs text-muted-foreground">
该层级按 Standard 的完整价格目录统一缩放
</p>
</div>
<!-- 价格输入 -->
<div
class="grid gap-3"
:class="[showCache1h ? 'grid-cols-2 lg:grid-cols-5' : 'grid-cols-2 lg:grid-cols-4']"
<Button
type="button"
variant="outline"
size="sm"
data-testid="processing-tier-use-custom"
@click="useCustomPricingForActiveProcessingTier"
>
<div class="space-y-1">
<Label class="text-xs">输入 ($/M)</Label>
<Input
:model-value="tier.input_price_per_1m"
data-testid="tier-input-price"
:data-tier-index="index"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输入价格美元/百万 Token`"
placeholder="0"
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs">输出 ($/M)</Label>
<Input
:model-value="tier.output_price_per_1m"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输出价格(美元/百万 Token`"
placeholder="0"
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">
{{ getCachePriceMode(index) === 'multiplier' ? '创建(倍率)' : '创建 ($/M)' }}
</Label>
<div class="relative">
<Input
:model-value="getCacheCreationEditorValue(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存创建${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
placeholder="0"
@update:model-value="(v) => updateCacheCreation(index, v)"
/>
改用自定义价格
</Button>
</div>
<div class="relative max-w-40">
<Input
:model-value="activeProcessingTierMultiplierDraft?.value ?? ''"
type="number"
min="0"
step="0.01"
class="h-8 pr-7"
data-testid="processing-tier-multiplier-input"
:aria-label="`${activeProcessingTierLabel} 层级倍率`"
placeholder="请输入倍率"
@update:model-value="updateActiveProcessingTierMultiplier"
/>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">×</span>
</div>
</div>
<template v-else-if="isActivePricingScopeConfigured">
<template v-if="showTokenPricing !== false">
<!-- 阶梯列表 -->
<div
v-for="(tier, index) in localTiers"
:key="index"
class="space-y-3 border-b border-border/60 pb-3 last:border-b-0"
>
<!-- 阶梯头部 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
<span class="text-muted-foreground">-</span>
<template v-if="isTierUpperBoundEditable(index)">
<template v-if="customInputMode[index]">
<Input
v-model="customInputValue[index]"
type="number"
min="1"
class="h-7 w-20 text-sm"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 自定义上限 Token`"
placeholder="K"
@keyup.enter="confirmCustomInput(index)"
@blur="confirmCustomInput(index)"
/>
<span class="text-xs text-muted-foreground">K</span>
</template>
<select
v-else
:value="getSelectValue(index)"
class="h-7 px-2 text-sm border rounded bg-background"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 上限`"
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
>
<option
v-for="opt in getAvailableThresholds(index)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</template>
<span
v-if="getCachePriceMode(index) === 'multiplier'"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
>×</span>
v-else
class="font-medium"
>无上限</span>
</div>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">
{{ getCachePriceMode(index) === 'multiplier' ? '读取(倍率)' : '读取 ($/M)' }}
</Label>
<div class="relative">
<Input
:model-value="getCacheReadEditorValue(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存读取${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
placeholder="0"
@update:model-value="(v) => updateCacheRead(index, v)"
/>
<span
v-if="getCachePriceMode(index) === 'multiplier'"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
>×</span>
<div class="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-muted-foreground"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 切换缓存价格输入方式`"
@click="toggleCachePriceMode(index)"
>
<Repeat2 class="mr-1 h-3.5 w-3.5" />
{{ getCachePriceMode(index) === 'multiplier' ? '价格' : '倍率' }}
</Button>
<Button
v-if="localTiers.length > 1"
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
:aria-label="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
:title="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
@click="removeTier(index)"
>
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
</div>
<!-- 价格输入 -->
<div
v-if="showCache1h"
class="space-y-1"
class="grid gap-3"
:class="[showCache1h ? 'grid-cols-2 lg:grid-cols-5' : 'grid-cols-2 lg:grid-cols-4']"
>
<Label class="text-xs text-muted-foreground">1h 缓存</Label>
<Input
:model-value="getCache1hDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 一小时缓存价格`"
:placeholder="getCache1hPlaceholder(index)"
@update:model-value="(v) => updateCache1h(index, v)"
/>
<div class="space-y-1">
<Label class="text-xs">输入 ($/M)</Label>
<Input
:model-value="tier.input_price_per_1m"
data-testid="tier-input-price"
:data-tier-index="index"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输入价格美元/百万 Token`"
placeholder="0"
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs">输出 ($/M)</Label>
<Input
:model-value="tier.output_price_per_1m"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输出价格(美元/百万 Token`"
placeholder="0"
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">
{{ getCachePriceMode(index) === 'multiplier' ? '创建(倍率)' : '创建 ($/M)' }}
</Label>
<div class="relative">
<Input
:model-value="getCacheCreationEditorValue(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存创建${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
placeholder="0"
@update:model-value="(v) => updateCacheCreation(index, v)"
/>
<span
v-if="getCachePriceMode(index) === 'multiplier'"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
>×</span>
</div>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">
{{ getCachePriceMode(index) === 'multiplier' ? '读取(倍率)' : '读取 ($/M)' }}
</Label>
<div class="relative">
<Input
:model-value="getCacheReadEditorValue(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存读取${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
placeholder="0"
@update:model-value="(v) => updateCacheRead(index, v)"
/>
<span
v-if="getCachePriceMode(index) === 'multiplier'"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
>×</span>
</div>
</div>
<div
v-if="showCache1h"
class="space-y-1"
>
<Label class="text-xs text-muted-foreground">1h 缓存</Label>
<Input
:model-value="getCache1hDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 一小时缓存价格`"
:placeholder="getCache1hPlaceholder(index)"
@update:model-value="(v) => updateCache1h(index, v)"
/>
</div>
</div>
</div>
</div>
<!-- 添加阶梯按钮 -->
<Button
variant="outline"
size="sm"
class="w-full"
@click="addTier"
>
<Plus class="w-4 h-4 mr-2" />
添加价格阶梯
</Button>
<!-- 添加阶梯按钮 -->
<Button
variant="outline"
size="sm"
class="w-full"
@click="addTier"
>
<Plus class="w-4 h-4 mr-2" />
添加价格阶梯
</Button>
</template>
</template>
<div
v-if="showImagePricing && showImageEditor !== false && isActivePricingScopeConfigured"
v-if="showProcessingTierMultiplierControls && showTokenPricing !== false"
class="space-y-3 border-t border-border/60 pt-3"
data-testid="processing-tier-multiplier-list"
>
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">
层级倍率(相对标准价格)
</p>
<p class="text-xs text-muted-foreground">
配置模型级默认倍率;层级是否可用由 Provider 端点/API 格式决定。
</p>
</div>
<div class="space-y-3">
<div
v-for="group in compactProcessingTierGroups"
:key="group.key"
class="space-y-2"
:data-processing-tier-group="group.key"
>
<p
v-if="group.label"
class="px-1 text-sm font-medium text-foreground"
:data-testid="`processing-tier-group-${group.key}`"
>
{{ group.label }}
</p>
<div
class="space-y-2"
:class="group.label ? 'border-l-2 border-border/60 pl-3' : ''"
>
<div
v-for="option in group.options"
:key="option.key"
class="flex min-h-10 flex-wrap items-center gap-3 rounded-md border border-border/60 px-3 py-2"
:data-processing-tier-multiplier="option.key"
>
<Checkbox
:checked="option.enabled"
:aria-label="`启用 ${option.accessibleLabel} 层级倍率`"
@update:checked="enabled => setCompactProcessingTierEnabled(option.key, enabled)"
/>
<div class="min-w-32 flex-1">
<p class="text-sm font-medium">
{{ option.label }}
</p>
<p
v-if="option.detail"
class="text-xs text-muted-foreground"
>
{{ option.detail }}
</p>
<p
v-if="option.mode === 'custom'"
class="text-xs text-muted-foreground"
>
已配置自定义价格目录
</p>
</div>
<template v-if="option.mode === 'custom'">
<span class="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">自定义价格</span>
<Button
type="button"
variant="outline"
size="sm"
:data-testid="`processing-tier-convert-${option.key}`"
@click="startProcessingTierMultiplier(option.key)"
>
改用倍率
</Button>
</template>
<div
v-else
class="relative w-36"
>
<Input
:model-value="option.value"
type="number"
min="0"
step="0.01"
class="h-8 pr-7"
:disabled="!option.enabled"
:data-testid="`processing-tier-multiplier-${option.key}`"
:aria-label="`${option.accessibleLabel} 层级倍率`"
placeholder="未设置"
@update:model-value="value => updateProcessingTierMultiplier(option.key, value)"
/>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">×</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-if="showImagePricing && showImageEditor !== false && isActivePricingScopeConfigured && !activeProcessingTierUsesMultiplier"
class="space-y-3 border-t border-border/60 pt-3"
>
<div class="flex flex-wrap items-end justify-between gap-3">
@@ -424,7 +572,7 @@
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue'
import { Plus, Repeat2, Trash2, X } from 'lucide-vue-next'
import { Button, Input, Label } from '@/components/ui'
import { Button, Checkbox, Input, Label } from '@/components/ui'
import { formatTokens } from '@/utils/format'
import type {
ImageOutputQualityPricing,
@@ -470,6 +618,23 @@ type ProcessingTierOption = {
label: string
configured: boolean
}
type ProcessingTierMultiplierDraft = {
enabled: boolean
mode: 'multiplier' | 'custom'
value: string
}
type CompactProcessingTierOption = ProcessingTierMultiplierDraft & {
key: string
label: string
detail?: string
group?: string
accessibleLabel: string
}
type CompactProcessingTierGroup = {
key: string
label: string | null
options: CompactProcessingTierOption[]
}
type PricingScopePolicy = {
allowEmptyTiers: boolean
terminalUpperBound: 'require-unbounded' | 'finite-or-unbounded'
@@ -481,10 +646,15 @@ const props = withDefaults(defineProps<{
showCache1h?: boolean
showImagePricing?: boolean
showImageEditor?: boolean
showProcessingTierControls?: boolean
showProcessingTierMultiplierControls?: boolean
autoFillMissingCachePrices?: boolean
}>(), {
modelValue: null,
showTokenPricing: true,
showImageEditor: true,
showProcessingTierControls: true,
showProcessingTierMultiplierControls: false,
autoFillMissingCachePrices: true,
})
const emit = defineEmits<{
@@ -497,7 +667,14 @@ const STANDARD_PRICING_SCOPE = 'standard'
const PROCESSING_PRICING_SCOPE_PREFIX = 'processing:'
const UNBOUNDED_THRESHOLD_VALUE = -2
const KNOWN_PROCESSING_TIERS = [
{ key: 'priority', label: 'Priority' },
{ key: 'priority', label: 'FastOpenAI' },
{ key: 'fast', label: 'FastClaude' },
{ key: 'flex', label: 'Flex' },
{ key: 'batch', label: 'Batch' },
] as const
const COMPACT_PROCESSING_TIERS = [
{ key: 'priority', label: 'OpenAI', detail: 'Chat / Responses', group: 'Fast' },
{ key: 'fast', label: 'Claude', detail: 'Messages', group: 'Fast' },
{ key: 'flex', label: 'Flex' },
{ key: 'batch', label: 'Batch' },
] as const
@@ -519,11 +696,17 @@ const cacheManualStateByScope = reactive<Record<string, Record<number, CacheManu
const cachePriceModesByScope = reactive<Record<string, Record<number, CachePriceMode>>>({})
const cacheMultiplierDraftsByScope = reactive<Record<string, Record<number, CacheMultiplierDraft>>>({})
const imagePricingStateByScope = reactive<Record<string, ImagePricingState>>({})
const processingTierMultiplierDrafts = reactive<Record<string, ProcessingTierMultiplierDraft>>(
Object.create(null) as Record<string, ProcessingTierMultiplierDraft>,
)
const activeProcessingTierKey = computed(() => processingTierKeyFromScope(activePricingScope.value))
const isActiveProcessingTierConfigured = computed(() => {
const key = activeProcessingTierKey.value
return key !== null && hasOwn(processingTierConfigs.value, key)
return key !== null && (
hasOwn(processingTierConfigs.value, key)
|| processingTierMultiplierDrafts[key]?.enabled === true
)
})
const isActivePricingScopeConfigured = computed(() => (
activePricingScope.value === STANDARD_PRICING_SCOPE || isActiveProcessingTierConfigured.value
@@ -533,6 +716,41 @@ const activeProcessingTierLabel = computed(() => {
if (key === null) return 'Standard'
return KNOWN_PROCESSING_TIERS.find(tier => tier.key === key)?.label ?? key
})
const activeProcessingTierMultiplierDraft = computed(() => {
const key = activeProcessingTierKey.value
return key === null ? null : processingTierMultiplierDrafts[key] ?? null
})
const activeProcessingTierUsesMultiplier = computed(() => (
activeProcessingTierKey.value !== null
&& activeProcessingTierMultiplierDraft.value?.enabled === true
&& activeProcessingTierMultiplierDraft.value.mode === 'multiplier'
))
const compactProcessingTierOptions = computed<CompactProcessingTierOption[]>(() => (
COMPACT_PROCESSING_TIERS.map(option => ({
...option,
accessibleLabel: [option.group, option.label, 'detail' in option ? option.detail : null]
.filter((part): part is string => Boolean(part))
.join(' · '),
...(processingTierMultiplierDrafts[option.key] ?? {
enabled: false,
mode: 'multiplier' as const,
value: '',
}),
}))
))
const compactProcessingTierGroups = computed<CompactProcessingTierGroup[]>(() => {
const groups: CompactProcessingTierGroup[] = []
for (const option of compactProcessingTierOptions.value) {
const key = option.group ? option.group.toLowerCase() : option.key
const existing = groups.find(group => group.key === key)
if (existing) {
existing.options.push(option)
} else {
groups.push({ key, label: option.group ?? null, options: [option] })
}
}
return groups
})
const processingTierOptions = computed<ProcessingTierOption[]>(() => {
const knownKeys = new Set<string>(KNOWN_PROCESSING_TIERS.map(tier => tier.key))
const options: ProcessingTierOption[] = [{
@@ -636,6 +854,7 @@ watch(
: 'absent'
processingTierKeysEdited.value = false
resetScopeState()
initializeProcessingTierMultiplierDrafts()
initializeScopeCacheState(STANDARD_PRICING_SCOPE, standardTiers.value)
initializeScopeImagePricingState(STANDARD_PRICING_SCOPE, clonedValue)
for (const [key, config] of Object.entries(processingTierConfigs.value)) {
@@ -660,6 +879,7 @@ watch(
processingTierKeysEdited.value = false
originalEmptyProcessingTiers.value = 'absent'
resetScopeState()
initializeProcessingTierMultiplierDrafts()
initializeScopeCacheState(STANDARD_PRICING_SCOPE, standardTiers.value)
initializeScopeImagePricingState(STANDARD_PRICING_SCOPE, {})
activePricingScope.value = STANDARD_PRICING_SCOPE
@@ -668,6 +888,16 @@ watch(
{ immediate: true }
)
watch(
() => props.showProcessingTierControls,
(showProcessingTierControls) => {
if (showProcessingTierControls) return
activePricingScope.value = STANDARD_PRICING_SCOPE
resetCustomThresholdState()
},
{ immediate: true },
)
function processingTierScope(key: string): string {
return `${PROCESSING_PRICING_SCOPE_PREFIX}${key}`
}
@@ -701,11 +931,53 @@ function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
function processingTierHasExplicitPricingData(config: ProcessingTierPricingConfig): boolean {
return (Array.isArray(config.tiers) && config.tiers.length > 0)
|| (typeof config.image_output_price_default === 'number'
&& Number.isFinite(config.image_output_price_default))
|| [
'image_output_prices',
'image_output_price_ranges',
'image_output_price_per_image',
'image_output_price_matrix',
'image_prices',
].some(key => valueHasEntries(config[key]))
}
function valueHasEntries(value: unknown): boolean {
return (Array.isArray(value) && value.length > 0)
|| (isRecord(value) && Object.keys(value).length > 0)
}
function initializeProcessingTierMultiplierDrafts() {
const keys = new Set<string>([
...KNOWN_PROCESSING_TIERS.map(tier => tier.key),
...Object.keys(processingTierConfigs.value),
])
for (const key of keys) {
const config = processingTierConfigs.value[key]
const hasMultiplier = isRecord(config)
&& !processingTierHasExplicitPricingData(config)
&& hasOwn(config, 'price_multiplier')
processingTierMultiplierDrafts[key] = {
enabled: config !== undefined,
mode: hasMultiplier ? 'multiplier' : 'custom',
value: hasMultiplier && config.price_multiplier != null
? String(config.price_multiplier)
: '',
}
if (config === undefined) {
processingTierMultiplierDrafts[key].mode = 'multiplier'
}
}
}
function resetScopeState() {
for (const scope of Object.keys(cacheManualStateByScope)) delete cacheManualStateByScope[scope]
for (const scope of Object.keys(cachePriceModesByScope)) delete cachePriceModesByScope[scope]
for (const scope of Object.keys(cacheMultiplierDraftsByScope)) delete cacheMultiplierDraftsByScope[scope]
for (const scope of Object.keys(imagePricingStateByScope)) delete imagePricingStateByScope[scope]
for (const key of Object.keys(processingTierMultiplierDrafts)) delete processingTierMultiplierDrafts[key]
resetCustomThresholdState()
}
@@ -868,6 +1140,104 @@ function selectPricingScope(scope: string) {
resetCustomThresholdState()
}
function setProcessingTierConfig(key: string, config: ProcessingTierPricingConfig | null) {
processingTierConfigs.value = Object.fromEntries(
config === null
? Object.entries(processingTierConfigs.value).filter(([existingKey]) => existingKey !== key)
: [
...Object.entries(processingTierConfigs.value)
.filter(([existingKey]) => existingKey !== key),
[key, config],
],
)
processingTierKeysEdited.value = true
}
function requireProcessingTierMultiplierDraft(key: string): ProcessingTierMultiplierDraft {
if (!processingTierMultiplierDrafts[key]) {
processingTierMultiplierDrafts[key] = {
enabled: false,
mode: 'multiplier',
value: '',
}
}
return processingTierMultiplierDrafts[key]
}
function parseProcessingTierMultiplier(value: string | number): number | null {
const raw = String(value ?? '').trim()
if (!raw) return null
const multiplier = Number(raw)
return Number.isFinite(multiplier) && multiplier >= 0 ? multiplier : null
}
function startProcessingTierMultiplier(key: string) {
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = true
draft.mode = 'multiplier'
draft.value = ''
// Keep an existing explicit catalog intact until a valid multiplier is entered.
// This lets validation stop an incomplete conversion without silently deleting
// the catalog when the parent form is submitted.
syncToParent()
}
function startActiveProcessingTierMultiplier() {
const key = activeProcessingTierKey.value
if (key !== null) startProcessingTierMultiplier(key)
}
function updateProcessingTierMultiplier(key: string, value: string | number) {
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = true
draft.mode = 'multiplier'
draft.value = String(value ?? '')
const multiplier = parseProcessingTierMultiplier(value)
if (multiplier !== null) {
setProcessingTierConfig(key, { price_multiplier: multiplier })
const scope = processingTierScope(key)
initializeScopeCacheState(scope, [])
initializeScopeImagePricingState(scope, {})
}
syncToParent()
}
function updateActiveProcessingTierMultiplier(value: string | number) {
const key = activeProcessingTierKey.value
if (key !== null) updateProcessingTierMultiplier(key, value)
}
function setCompactProcessingTierEnabled(key: string, enabled: boolean) {
const draft = requireProcessingTierMultiplierDraft(key)
if (!enabled) {
draft.enabled = false
draft.mode = 'multiplier'
draft.value = ''
setProcessingTierConfig(key, null)
syncToParent()
return
}
if (draft.enabled) return
startProcessingTierMultiplier(key)
}
function useCustomPricingForActiveProcessingTier() {
const key = activeProcessingTierKey.value
if (key === null) return
const existingConfig = processingTierConfigs.value[key]
const restoredConfig = existingConfig && processingTierHasExplicitPricingData(existingConfig)
? cloneJson(existingConfig)
: { tiers: cloneJson(standardTiers.value) }
setProcessingTierConfig(key, restoredConfig)
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = true
draft.mode = 'custom'
draft.value = ''
initializeScopeCacheState(activePricingScope.value, restoredConfig.tiers ?? [])
initializeScopeImagePricingState(activePricingScope.value, restoredConfig)
syncToParent()
}
function addActiveProcessingTier() {
const key = activeProcessingTierKey.value
if (key === null || hasOwn(processingTierConfigs.value, key)) return
@@ -878,6 +1248,10 @@ function addActiveProcessingTier() {
[key, { tiers }],
])
processingTierKeysEdited.value = true
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = true
draft.mode = 'custom'
draft.value = ''
initializeScopeCacheState(activePricingScope.value, tiers)
initializeScopeImagePricingState(activePricingScope.value, {})
syncToParent()
@@ -895,6 +1269,10 @@ function removeActiveProcessingTier() {
delete cacheMultiplierDraftsByScope[activePricingScope.value]
delete imagePricingStateByScope[activePricingScope.value]
processingTierKeysEdited.value = true
const draft = requireProcessingTierMultiplierDraft(key)
draft.enabled = false
draft.mode = 'multiplier'
draft.value = ''
if (!KNOWN_PROCESSING_TIERS.some(tier => tier.key === key)) {
activePricingScope.value = STANDARD_PRICING_SCOPE
}
@@ -934,9 +1312,14 @@ function replaceCacheTtlPrice(
}
const validationError = computed(() => {
const multiplierError = validateProcessingTierMultipliers()
if (multiplierError) return multiplierError
const scopes = [
STANDARD_PRICING_SCOPE,
...Object.keys(processingTierConfigs.value).map(processingTierScope),
...(props.showProcessingTierControls
? Object.keys(processingTierConfigs.value).map(processingTierScope)
: []),
]
for (const scope of new Set(scopes)) {
const error = validatePricingScope(scope)
@@ -945,6 +1328,39 @@ const validationError = computed(() => {
return null
})
function processingTierDisplayLabel(key: string): string {
const compactTier = COMPACT_PROCESSING_TIERS.find(tier => tier.key === key)
if (compactTier) {
return [
'group' in compactTier ? compactTier.group : null,
compactTier.label,
'detail' in compactTier ? compactTier.detail : null,
].filter((part): part is string => Boolean(part)).join(' · ')
}
return KNOWN_PROCESSING_TIERS.find(tier => tier.key === key)?.label ?? key
}
function validateProcessingTierMultipliers(): string | null {
const keys = new Set<string>([
...Object.keys(processingTierConfigs.value),
...Object.keys(processingTierMultiplierDrafts),
...(props.showProcessingTierMultiplierControls
? COMPACT_PROCESSING_TIERS.map(tier => tier.key)
: []),
])
for (const key of keys) {
const draft = processingTierMultiplierDrafts[key]
if (!draft?.enabled || draft.mode !== 'multiplier') continue
if (!draft.value.trim()) {
return `${processingTierDisplayLabel(key)}: 请输入层级倍率`
}
if (parseProcessingTierMultiplier(draft.value) === null) {
return `${processingTierDisplayLabel(key)}: 层级倍率必须是非负有限数值`
}
}
return null
}
function validatePricingScope(scope: string): string | null {
const processingTierKey = processingTierKeyFromScope(scope)
const tierError = validatePricingTiers(tiersForScope(scope), pricingScopePolicy(scope))
@@ -1152,25 +1568,37 @@ function buildPricingConfig(includeAutomaticCache: boolean): TieredPricingConfig
const config = cloneJson(basePricingConfig.value) as TieredPricingConfig
config.tiers = buildTiersForScope(STANDARD_PRICING_SCOPE, includeAutomaticCache)
const processingTierEntries: Array<[string, ProcessingTierPricingConfig]> = []
for (const [key, overlay] of Object.entries(processingTierConfigs.value)) {
const serializedOverlay = cloneJson(overlay)
if (Array.isArray(overlay.tiers)) {
serializedOverlay.tiers = buildTiersForScope(processingTierScope(key), includeAutomaticCache)
if (props.showProcessingTierControls) {
const processingTierEntries: Array<[string, ProcessingTierPricingConfig]> = []
for (const [key, overlay] of Object.entries(processingTierConfigs.value)) {
const serializedOverlay = cloneJson(overlay)
if (Array.isArray(overlay.tiers)) {
serializedOverlay.tiers = buildTiersForScope(processingTierScope(key), includeAutomaticCache)
}
if (props.showImagePricing) {
applyImagePricing(serializedOverlay, processingTierScope(key))
}
processingTierEntries.push([key, serializedOverlay])
}
if (props.showImagePricing) {
applyImagePricing(serializedOverlay, processingTierScope(key))
const processingTiers = Object.fromEntries(processingTierEntries)
delete config.processing_tiers
if (Object.keys(processingTiers).length > 0) {
config.processing_tiers = processingTiers
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'null') {
config.processing_tiers = null
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'object') {
config.processing_tiers = {}
}
} else {
const processingTiers = cloneJson(processingTierConfigs.value)
delete config.processing_tiers
if (Object.keys(processingTiers).length > 0) {
config.processing_tiers = processingTiers
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'null') {
config.processing_tiers = null
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'object') {
config.processing_tiers = {}
}
processingTierEntries.push([key, serializedOverlay])
}
const processingTiers = Object.fromEntries(processingTierEntries)
delete config.processing_tiers
if (Object.keys(processingTiers).length > 0) {
config.processing_tiers = processingTiers
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'null') {
config.processing_tiers = null
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'object') {
config.processing_tiers = {}
}
if (props.showImagePricing) {
@@ -167,6 +167,10 @@ describe('GlobalModelFormDialog preset replacement', () => {
findButton('Stale Model').click()
await settle()
expect(document.body.querySelector('[data-processing-tier="standard"]')).not.toBeNull()
expect(document.body.querySelector('[data-processing-tier="priority"]')).not.toBeNull()
expect(document.body.textContent).toContain('自定义价格')
await setInput(
document.body.querySelector<HTMLInputElement>('input[placeholder="如 0.01"]'),
'0.25',
@@ -246,4 +250,32 @@ describe('GlobalModelFormDialog preset replacement', () => {
},
])
})
it('submits a compact processing-tier multiplier without a Standard overlay', async () => {
mountDialog()
await settle()
findButton('Fresh Model').click()
await settle()
const priorityToggle = document.body.querySelector(
'input[aria-label="启用 Fast · OpenAI · Chat / Responses 层级倍率"]',
) as HTMLInputElement
priorityToggle.click()
await nextTick()
await setInput(
document.body.querySelector<HTMLInputElement>(
'[data-testid="processing-tier-multiplier-priority"]',
),
'2.5',
)
findExactButton('添加').click()
await settle()
const payload = globalModelMocks.createGlobalModel.mock.calls[0][0]
expect(payload.default_tiered_pricing.processing_tiers).toEqual({
priority: { price_multiplier: 2.5 },
})
expect(payload.default_tiered_pricing.processing_tiers).not.toHaveProperty('standard')
})
})
@@ -31,6 +31,37 @@ afterEach(() => {
})
describe('ProcessingTierPricingSummary', () => {
it('shows multiplier-only processing tiers', async () => {
const root = mountSummary({
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: { price_multiplier: 2.5 },
fast: { price_multiplier: 2 },
flex: {
price_multiplier: 99,
tiers: [{ up_to: null, input_price_per_1m: 2.5, output_price_per_1m: 15 }],
},
},
})
expect(root.querySelector('[data-processing-tier="priority"]')?.textContent)
.toContain('FastOpenAI')
expect(root.querySelector('[data-processing-tier="fast"]')?.textContent)
.toContain('FastClaude')
expect(root.textContent).not.toContain('Priority')
expect(root.querySelector('[data-testid="processing-tier-price-multiplier"]')?.textContent)
.toContain('2.5×')
clickTier(root, 'fast')
await nextTick()
expect(root.querySelector('[data-testid="processing-tier-price-multiplier"]')?.textContent)
.toContain('2×')
clickTier(root, 'flex')
await nextTick()
expect(root.querySelector('[data-testid="processing-tier-price-multiplier"]')).toBeNull()
expect(root.querySelectorAll('[data-testid="processing-token-tier-row"]')).toHaveLength(1)
})
it('shows finite and unbounded token tiers in stable processing-tier order', () => {
const root = mountSummary({
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
@@ -27,12 +27,15 @@ function mountEditor(
showImagePricing?: boolean
showTokenPricing?: boolean
showImageEditor?: boolean
showProcessingTierControls?: boolean
showProcessingTierMultiplierControls?: boolean
} = {},
) {
const root = document.createElement('div')
document.body.appendChild(root)
const onUpdate = vi.fn()
const currentModelValue = shallowRef(modelValue)
const showProcessingTierControls = shallowRef(options.showProcessingTierControls)
let editor: TieredPricingEditorExposed | null = null
const app = createApp(defineComponent({
@@ -47,6 +50,8 @@ function mountEditor(
showImagePricing: options.showImagePricing,
showTokenPricing: options.showTokenPricing,
showImageEditor: options.showImageEditor,
showProcessingTierControls: showProcessingTierControls.value,
showProcessingTierMultiplierControls: options.showProcessingTierMultiplierControls,
'onUpdate:modelValue': onUpdate,
})
},
@@ -61,6 +66,9 @@ function mountEditor(
setModelValue: (value: TieredPricingConfig) => {
currentModelValue.value = value
},
setShowProcessingTierControls: (value: boolean) => {
showProcessingTierControls.value = value
},
getFinalPricing: () => {
if (!editor) throw new Error('TieredPricingEditor ref was not mounted')
return editor.getFinalPricing()
@@ -87,6 +95,288 @@ afterEach(() => {
})
describe('TieredPricingEditor processing tiers', () => {
it('hides processing-tier controls while editing Standard and preserving overlays', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
price_multiplier: 999,
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
future_overlay_option: 'keep-hidden-overlay',
},
},
} as TieredPricingConfig
const {
root,
onUpdate,
getFinalPricing,
setShowProcessingTierControls,
} = mountEditor(pricing)
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
expect(root.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value)
.toBe('10')
setShowProcessingTierControls(false)
await nextTick()
expect(root.querySelector('[data-processing-tier]')).toBeNull()
expect(root.textContent).not.toContain('处理层级')
const input = root.querySelector('[data-testid="tier-input-price"]') as HTMLInputElement | null
if (!input) throw new Error('Expected the Standard input-price control')
input.value = '7.5'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const emitted = onUpdate.mock.lastCall?.[0] as TieredPricingConfig
expect(emitted.tiers[0].input_price_per_1m).toBe(7.5)
expect(emitted.processing_tiers).toEqual(pricing.processing_tiers)
expect(getFinalPricing().processing_tiers).toEqual(pricing.processing_tiers)
})
it('edits compact processing-tier multipliers without writing a Standard overlay', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, getFinalPricing, getValidationError } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
expect(root.querySelector('[data-testid="processing-tier-group-fast"]')?.textContent)
.toBe('Fast')
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
.toContain('OpenAI')
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
.toContain('Chat / Responses')
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
.toContain('Claude')
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
.toContain('Messages')
const priorityToggle = root.querySelector(
'input[aria-label="启用 Fast · OpenAI · Chat / Responses 层级倍率"]',
) as HTMLInputElement
priorityToggle.click()
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-priority"]',
) as HTMLInputElement
multiplier.value = '2.5'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toBeNull()
expect(getFinalPricing().processing_tiers).toEqual({
priority: { price_multiplier: 2.5 },
})
expect(getFinalPricing().processing_tiers).not.toHaveProperty('standard')
priorityToggle.click()
await nextTick()
expect(getFinalPricing()).not.toHaveProperty('processing_tiers')
})
it('keeps the grouped Claude Fast option mapped to the internal fast key', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
const fastToggle = root.querySelector(
'input[aria-label="启用 Fast · Claude · Messages 层级倍率"]',
) as HTMLInputElement
fastToggle.click()
await nextTick()
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-fast"]',
) as HTMLInputElement
multiplier.value = '2'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getFinalPricing().processing_tiers).toEqual({
fast: { price_multiplier: 2 },
})
})
it('requires an enabled processing-tier multiplier instead of clearing the saved value', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: { priority: { price_multiplier: 2.5 } },
} as TieredPricingConfig
const { root, onUpdate, getFinalPricing, getValidationError } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-priority"]',
) as HTMLInputElement
multiplier.value = ''
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
expect(onUpdate).not.toHaveBeenCalled()
})
it('preserves a custom catalog until the user explicitly replaces it with a multiplier', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
future_overlay_option: 'replace-with-catalog',
},
hyperlane: {
tiers: [{ up_to: null, input_price_per_1m: 7, output_price_per_1m: 42 }],
future_overlay_option: 'keep-unknown',
},
},
} as TieredPricingConfig
const { root, onUpdate, getFinalPricing, getValidationError } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
expect(root.textContent).toContain('自定义价格')
expect(getFinalPricing().processing_tiers).toEqual(pricing.processing_tiers)
click(root.querySelector('[data-testid="processing-tier-convert-priority"]'))
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
expect(onUpdate).not.toHaveBeenCalled()
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-priority"]',
) as HTMLInputElement
multiplier.value = '2'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getFinalPricing().processing_tiers).toEqual({
priority: { price_multiplier: 2 },
hyperlane: pricing.processing_tiers?.hyperlane,
})
})
it('validates compact multipliers as finite non-negative numbers', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: { flex: { price_multiplier: 0.5 } },
} as TieredPricingConfig
const { root, getValidationError } = mountEditor(pricing, {
showProcessingTierControls: false,
showProcessingTierMultiplierControls: true,
})
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-flex"]',
) as HTMLInputElement
expect(multiplier.value).toBe('0.5')
multiplier.value = '-1'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toContain('必须是非负有限数值')
})
it('requires a full-editor multiplier before persisting the new tier', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, getFinalPricing, getValidationError } = mountEditor(pricing, {
autoFillMissingCachePrices: false,
showProcessingTierMultiplierControls: true,
})
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
click(root.querySelector('[data-testid="processing-tier-add-multiplier"]'))
await nextTick()
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-input"]',
) as HTMLInputElement
expect(getValidationError()).toContain('请输入层级倍率')
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
multiplier.value = '-1'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toContain('必须是非负有限数值')
multiplier.value = ''
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
})
it('restores an explicit catalog when an incomplete multiplier conversion is cancelled', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: {
tiers: [{ up_to: null, input_price_per_1m: 11, output_price_per_1m: 66 }],
future_overlay_option: 'keep-on-cancel',
},
},
} as TieredPricingConfig
const { root, getFinalPricing, getValidationError } = mountEditor(pricing, {
autoFillMissingCachePrices: false,
showProcessingTierMultiplierControls: true,
})
click(root.querySelector('[data-testid="processing-tier-convert-priority"]'))
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
expect(getValidationError()).toContain('请输入层级倍率')
click(root.querySelector('[data-testid="processing-tier-use-custom"]'))
await nextTick()
expect(getValidationError()).toBeNull()
expect(getFinalPricing().processing_tiers?.priority).toEqual(
pricing.processing_tiers?.priority,
)
})
it('lets the full Provider editor edit a multiplier or replace it with explicit prices', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: { priority: { price_multiplier: 2.5 } },
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing)
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
const multiplier = root.querySelector(
'[data-testid="processing-tier-multiplier-input"]',
) as HTMLInputElement
expect(multiplier.value).toBe('2.5')
multiplier.value = '3'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getFinalPricing().processing_tiers?.priority).toEqual({ price_multiplier: 3 })
click(root.querySelector('[data-testid="processing-tier-use-custom"]'))
await nextTick()
expect(getFinalPricing().processing_tiers?.priority).toMatchObject({
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
})
expect(getFinalPricing().processing_tiers?.priority).not.toHaveProperty('price_multiplier')
})
it('round-trips root, overlay and pricing-tier extension fields', () => {
const pricing = {
tiers: [{
@@ -140,9 +430,11 @@ describe('TieredPricingEditor processing tiers', () => {
} as TieredPricingConfig
const { root, onUpdate } = mountEditor(pricing)
expect(root.querySelectorAll('[data-processing-tier]')).toHaveLength(5)
expect(root.querySelectorAll('[data-processing-tier]')).toHaveLength(6)
expect(root.textContent).toContain('Standard')
expect(root.textContent).toContain('Priority')
expect(root.textContent).toContain('FastOpenAI')
expect(root.textContent).toContain('FastClaude')
expect(root.textContent).not.toContain('Priority')
expect(root.textContent).toContain('Flex')
expect(root.textContent).toContain('Batch')
expect(root.textContent).toContain('hyperlane')
@@ -283,7 +575,7 @@ describe('TieredPricingEditor processing tiers', () => {
click(root.querySelector('[data-processing-tier="priority"]'))
await nextTick()
const multiplier = root.querySelector(
'input[aria-label="Priority 阶梯 1 缓存创建倍率"]',
'input[aria-label="FastOpenAI 阶梯 1 缓存创建倍率"]',
) as HTMLInputElement
multiplier.value = '2'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
@@ -453,7 +745,8 @@ describe('TieredPricingEditor processing tiers', () => {
await nextTick()
expect(root.querySelector('[data-testid="tier-input-price"]')).toBeNull()
expect(root.querySelector('input[aria-label="Priority 图像输出默认价格"]')).not.toBeNull()
expect(root.querySelector('input[aria-label="FastOpenAI 图像输出默认价格"]'))
.not.toBeNull()
})
it('accepts a finite terminal tier for any processing overlay', () => {
@@ -500,7 +793,7 @@ describe('TieredPricingEditor processing tiers', () => {
await nextTick()
const terminal = root.querySelector(
'select[aria-label="Priority 阶梯 1 上限"]',
'select[aria-label="FastOpenAI 阶梯 1 上限"]',
) as HTMLSelectElement
expect(terminal.value).toBe('272000')
@@ -535,7 +828,7 @@ describe('TieredPricingEditor processing tiers', () => {
expect(getFinalPricing().processing_tiers?.priority.tiers?.map(tier => tier.up_to))
.toEqual([272000, null])
click(root.querySelector('button[aria-label="删除 Priority 阶梯 2"]'))
click(root.querySelector('button[aria-label="删除 FastOpenAI 阶梯 2"]'))
await nextTick()
expect(getFinalPricing().processing_tiers?.priority.tiers?.map(tier => tier.up_to))
.toEqual([272000])
@@ -650,7 +943,7 @@ describe('TieredPricingEditor processing tiers', () => {
await nextTick()
const priorityDefault = root.querySelector(
'input[aria-label="Priority 图像输出默认价格"]',
'input[aria-label="FastOpenAI 图像输出默认价格"]',
) as HTMLInputElement
const priorityHigh = root.querySelector(
'input[aria-label="1024x1024 high 图像输出价格"]',
@@ -754,9 +1047,9 @@ describe('TieredPricingEditor processing tiers', () => {
await nextTick()
expect(onUpdate).not.toHaveBeenCalled()
expect(getValidationError()).toContain('Priority')
expect(getValidationError()).toContain('FastOpenAI')
expect(getValidationError()).toContain('上限必须大于前一个阶梯')
expect(() => getFinalPricing()).toThrow('Priority')
expect(() => getFinalPricing()).toThrow('FastOpenAI')
})
it('rejects negative known prices before they reach the billing contract', () => {
@@ -1,9 +1,11 @@
import type {
ProviderTieredPricingConfig,
ProcessingTierPricingConfig,
TieredPricingConfig,
} from '@/api/endpoints/types'
type PricingCatalog = TieredPricingConfig | ProcessingTierPricingConfig
type PricingCatalog = ProviderTieredPricingConfig | ProcessingTierPricingConfig
type RootPricingCatalog = TieredPricingConfig | ProviderTieredPricingConfig
export function comparePricingUpperBounds(
left: number | null,
@@ -15,7 +17,7 @@ export function comparePricingUpperBounds(
return left - right
}
function pricingCatalogs(pricing: TieredPricingConfig | null | undefined): PricingCatalog[] {
function pricingCatalogs(pricing: RootPricingCatalog | null | undefined): PricingCatalog[] {
if (!pricing) return []
const processingTiers = pricing.processing_tiers
? Object.values(pricing.processing_tiers).filter(isRecord)
@@ -24,7 +26,7 @@ function pricingCatalogs(pricing: TieredPricingConfig | null | undefined): Prici
}
export function tieredPricingHasImageOutputPricing(
pricing: TieredPricingConfig | null | undefined,
pricing: RootPricingCatalog | null | undefined,
): boolean {
return pricingCatalogs(pricing).some((catalog) => {
if (toFinitePrice(catalog.image_output_price_default) !== null) return true
@@ -41,7 +43,7 @@ export function tieredPricingHasImageOutputPricing(
}
export function tieredPricingHasCacheTtl(
pricing: TieredPricingConfig | null | undefined,
pricing: RootPricingCatalog | null | undefined,
ttlMinutes: number,
): boolean {
return pricingCatalogs(pricing).some(catalog => (
@@ -160,122 +160,169 @@
</div>
<!-- 价格配置 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm border-b pb-2">
价格配置
<section class="space-y-3 rounded-lg border bg-card p-4">
<h4 class="font-medium text-sm">
选择计费模式
</h4>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-image-pricing="isImageGenerationEnabled"
/>
<Tabs
v-model="billingMode"
@update:model-value="handleBillingModeChange"
>
<TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="token">
Token
</TabsTrigger>
<TabsTrigger value="request">
按次
</TabsTrigger>
<TabsTrigger value="image">
图片
</TabsTrigger>
<TabsTrigger value="video">
视频
</TabsTrigger>
</TabsList>
<!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费 ($/)</Label>
<Input
:model-value="form.price_per_request ?? ''"
type="number"
step="0.001"
min="0"
class="w-32"
placeholder="留空使用默认值"
@update:model-value="(v) => form.price_per_request = parseNumberInput(v, { allowFloat: true })"
<TieredPricingEditor
v-show="billingMode === 'token' || billingMode === 'image'"
ref="tieredPricingEditorRef"
v-model="tieredPricing"
class="mt-3"
:auto-fill-missing-cache-prices="autoFillMissingCachePrices"
:show-token-pricing="billingMode === 'token'"
:show-image-pricing="isImageGenerationEnabled"
:show-image-editor="billingMode === 'image'"
:show-processing-tier-multiplier-controls="true"
/>
<span class="text-xs text-muted-foreground">每次请求固定费用,留空使用全局模型默认值</span>
</div>
<!-- 视频计费(可选覆盖) -->
<div class="pt-3 border-t space-y-2">
<div class="text-sm font-medium">
视频计费(可选覆盖)
</div>
<div class="flex items-center gap-1.5 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
>
通用
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
>
Sora
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
>
Veo
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
>
<Plus class="w-3.5 h-3.5 mr-0.5" />
自定义
</Button>
</div>
<div
v-if="videoResolutionPrices.length > 0"
class="rounded-lg border border-border overflow-hidden"
<TabsContent
value="request"
class="pt-2"
>
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
<span>分辨率</span>
<span>单价($/秒)</span>
<span />
<div class="rounded-lg border bg-muted/20 p-4 space-y-2">
<Label class="text-xs">每次请求价格美元</Label>
<Input
:model-value="form.price_per_request ?? ''"
type="number"
step="0.001"
min="0"
class="max-w-48"
placeholder="留空使用全局模型默认值"
@update:model-value="updatePricePerRequest"
/>
<p class="text-xs text-muted-foreground">
按每次 API 请求收取固定费用未修改时继续继承全局模型
</p>
</div>
<div class="divide-y divide-border">
<div
v-for="(row, idx) in videoResolutionPrices"
:key="idx"
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
>
<Input
v-model="row.resolution"
class="h-7 text-sm"
placeholder=" 720p"
@update:model-value="() => { configTouched = true }"
/>
<Input
:model-value="row.price_per_second ?? ''"
type="number"
step="0.0001"
min="0"
class="h-7 text-sm"
placeholder="0"
@update:model-value="(v) => { row.price_per_second = parseNumberInput(v, { allowFloat: true }); configTouched = true }"
/>
</TabsContent>
<TabsContent
value="video"
class="pt-2"
>
<div class="space-y-3 rounded-lg border bg-muted/20 p-4">
<div>
<div class="text-sm font-medium">
视频计费分辨率 × 时长
</div>
<p class="mt-1 text-xs text-muted-foreground">
根据输出分辨率配置每秒视频价格未修改时继续继承全局模型
</p>
</div>
<div class="flex items-center gap-1.5 flex-wrap">
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除"
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
>
<Trash2 class="w-3.5 h-3.5" />
通用
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
>
Sora
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
>
Veo
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
>
<Plus class="w-3.5 h-3.5 mr-0.5" />
自定义
</Button>
</div>
<div
v-if="videoResolutionPrices.length > 0"
class="rounded-lg border border-border overflow-hidden"
>
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
<span>分辨率</span>
<span>单价$/</span>
<span />
</div>
<div class="divide-y divide-border">
<div
v-for="(row, idx) in videoResolutionPrices"
:key="idx"
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
>
<Input
v-model="row.resolution"
class="h-7 text-sm"
placeholder="如 720p"
@update:model-value="() => { configTouched = true }"
/>
<Input
:model-value="row.price_per_second ?? ''"
type="number"
step="0.0001"
min="0"
class="h-7 text-sm"
placeholder="0"
@update:model-value="(v) => { row.price_per_second = parseNumberInput(v, { allowFloat: true }); configTouched = true }"
/>
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除"
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
<div
v-else
class="rounded-lg border border-dashed py-8 text-center text-xs text-muted-foreground"
>
选择一个价格预设或添加自定义分辨率
</div>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</section>
</form>
<template #footer>
@@ -315,17 +362,32 @@ import {
SelectItem,
Badge,
Checkbox,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
import { createGlobalModel, listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import {
createGlobalModel,
getGlobalModel,
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 type {
Model,
ProviderTieredPricingConfig,
TieredPricingConfig,
} from '@/api/endpoints'
import {
buildProviderTieredPricingOverride,
buildProviderModelCreatePayload,
buildProviderModelUpdatePayload,
mergeProviderTieredPricingForEditing,
modelSupportsEmbedding,
} from './provider-model-form-helpers'
@@ -385,6 +447,7 @@ const submitting = ref(false)
const loadingGlobalModels = ref(false)
const availableGlobalModels = ref<GlobalModelResponse[]>([])
const manualGlobalModelMode = ref(false)
const billingMode = ref('token')
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
@@ -392,6 +455,10 @@ const tieredPricing = ref<TieredPricingConfig | null>(null)
const tieredPricingModified = ref(false)
// 保存原始配置用于比较
const originalTieredPricing = ref<string>('')
const originalEditorTieredPricing = ref<TieredPricingConfig | null>(null)
const originalProviderTieredPricing = ref<ProviderTieredPricingConfig | null>(null)
const pricePerRequestModified = ref(false)
const originalPricePerRequest = ref<number | undefined>(undefined)
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
@@ -439,6 +506,7 @@ const form = ref({
is_active: true
})
const imageGenerationExplicitOverride = ref<boolean | null>(null)
const autoFillMissingCachePrices = computed(() => !isEditing.value && manualGlobalModelMode.value)
const canSubmitCreate = computed(() => {
if (isEditing.value) return true
@@ -455,7 +523,6 @@ watch(() => props.open, async (newOpen) => {
// 编辑模式:填充表单
// 使用有效配置(合并全局模型的默认值)供用户查看和编辑
const effectiveConfig = props.editingModel.effective_config || props.editingModel.config || {}
const supportsImageGeneration = modelSupportsImageGeneration(props.editingModel)
form.value = {
global_model_id: props.editingModel.global_model_id || '',
provider_model_name: props.editingModel.provider_model_name || '',
@@ -468,16 +535,45 @@ watch(() => props.open, async (newOpen) => {
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
supports_streaming: props.editingModel.supports_streaming ?? undefined,
supports_extended_thinking: props.editingModel.supports_extended_thinking ?? undefined,
supports_image_generation: supportsImageGeneration ? true : props.editingModel.supports_image_generation ?? undefined,
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
is_active: props.editingModel.is_active
}
// 从有效配置中加载视频费用
loadVideoPricingFromConfig(effectiveConfig)
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
// Provider 可以只覆盖 processing_tiers。此时后端 effective_tiered_pricing
// 仍是原始 partial JSON,需要取 GlobalModel 默认目录合成完整编辑视图。
const providerPricing = props.editingModel.tiered_pricing
? JSON.parse(JSON.stringify(props.editingModel.tiered_pricing)) as ProviderTieredPricingConfig
: null
let globalDefaultPricing = providerPricing
? null
: props.editingModel.effective_tiered_pricing
if (providerPricing && props.editingModel.global_model_id) {
try {
const globalModel = await getGlobalModel(props.editingModel.global_model_id)
globalDefaultPricing = globalModel.default_tiered_pricing
} catch (err: unknown) {
if (!providerPricing.tiers?.length) {
showError(parseApiError(err, '加载 GlobalModel 默认价格失败'), '错误')
}
}
}
const pricing = mergeProviderTieredPricingForEditing(globalDefaultPricing, providerPricing)
|| (props.editingModel.effective_tiered_pricing?.tiers?.length
? props.editingModel.effective_tiered_pricing
: null)
if (pricing) {
tieredPricing.value = JSON.parse(JSON.stringify(pricing))
}
originalEditorTieredPricing.value = tieredPricing.value
? JSON.parse(JSON.stringify(tieredPricing.value))
: null
originalProviderTieredPricing.value = providerPricing
originalTieredPricing.value = JSON.stringify(tieredPricing.value)
tieredPricingModified.value = false
originalPricePerRequest.value = form.value.price_per_request
pricePerRequestModified.value = false
selectInitialBillingMode()
} else {
// 添加模式:加载可用全局模型
await loadAvailableGlobalModels()
@@ -497,21 +593,30 @@ watch(() => form.value.global_model_id, (newId) => {
// 深拷贝阶梯计费配置用于预览
const pricingCopy = JSON.parse(JSON.stringify(selectedModel.default_tiered_pricing))
tieredPricing.value = pricingCopy
originalEditorTieredPricing.value = JSON.parse(JSON.stringify(pricingCopy))
originalProviderTieredPricing.value = null
// 保存原始配置用于比较
originalTieredPricing.value = JSON.stringify(pricingCopy)
} else {
tieredPricing.value = null
originalTieredPricing.value = ''
originalEditorTieredPricing.value = null
originalProviderTieredPricing.value = null
originalTieredPricing.value = JSON.stringify(null)
}
tieredPricingModified.value = false
// 同时继承按次计费(仅供预览)
form.value.price_per_request = selectedModel?.default_price_per_request ?? undefined
originalPricePerRequest.value = form.value.price_per_request
pricePerRequestModified.value = false
loadVideoPricingFromConfig(selectedModel?.config || {})
configTouched.value = false
selectInitialBillingMode()
}
})
// 监听阶梯配置变化,标记为已修改
watch(tieredPricing, (newValue) => {
if (!isEditing.value && originalTieredPricing.value) {
if (originalTieredPricing.value) {
const newJson = JSON.stringify(newValue)
tieredPricingModified.value = newJson !== originalTieredPricing.value
}
@@ -539,8 +644,37 @@ function resetForm() {
tieredPricing.value = null
tieredPricingModified.value = false
originalTieredPricing.value = ''
originalEditorTieredPricing.value = null
originalProviderTieredPricing.value = null
pricePerRequestModified.value = false
originalPricePerRequest.value = undefined
availableGlobalModels.value = []
manualGlobalModelMode.value = false
billingMode.value = 'token'
}
function updatePricePerRequest(value: string | number) {
form.value.price_per_request = parseNumberInput(value, { allowFloat: true })
pricePerRequestModified.value = form.value.price_per_request !== originalPricePerRequest.value
}
function handleBillingModeChange(mode: string) {
billingMode.value = mode
if (mode === 'image' && !isImageGenerationEnabled.value) {
setImageGenerationEnabled(true)
}
}
function selectInitialBillingMode() {
if (videoResolutionPrices.value.length > 0) {
billingMode.value = 'video'
} else if (isImageGenerationEnabled.value) {
billingMode.value = 'image'
} else if (form.value.price_per_request !== undefined) {
billingMode.value = 'request'
} else {
billingMode.value = 'token'
}
}
function handleGlobalModelSelect(value: string) {
@@ -556,8 +690,8 @@ function modelSupportsImageGeneration(model: {
supports_image_generation?: boolean | null
effective_supports_image_generation?: boolean | null
default_tiered_pricing?: TieredPricingConfig | null
tiered_pricing?: TieredPricingConfig | null
effective_tiered_pricing?: TieredPricingConfig | null
tiered_pricing?: ProviderTieredPricingConfig | null
effective_tiered_pricing?: ProviderTieredPricingConfig | null
config?: Record<string, unknown> | null
} | null | undefined): boolean {
if (!model) return false
@@ -798,8 +932,14 @@ async function handleSubmit() {
try {
// 获取包含自动计算缓存价格的最终数据
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
const supportsImageGeneration = isImageGenerationEnabled.value
|| tieredPricingHasImageOutputPricing(finalTieredPricing)
const providerTieredPricingOverride = tieredPricingModified.value
? buildProviderTieredPricingOverride(
finalTieredPricing,
originalEditorTieredPricing.value,
originalProviderTieredPricing.value,
)
: null
const supportsImageGeneration = form.value.supports_image_generation
// Apply billing (video) pricing into config.
applyVideoPricingToConfig(form.value.config)
@@ -809,11 +949,14 @@ async function handleSubmit() {
if (isEditing.value && props.editingModel) {
// 编辑模式
// 注意:使用 null 而不是 undefined 来显式清空字段(undefined 会被 JSON 序列化忽略)
// 仅提交实际修改的 Provider 覆盖;未修改字段继续继承全局模型。
await updateModel(props.providerId, props.editingModel.id, buildProviderModelUpdatePayload({
finalTieredPricing,
finalTieredPricing: providerTieredPricingOverride,
tieredPricingModified: tieredPricingModified.value,
pricePerRequest: form.value.price_per_request,
pricePerRequestModified: pricePerRequestModified.value,
cleanConfig,
configTouched: configTouched.value,
supportsVision: form.value.supports_vision,
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
@@ -834,9 +977,10 @@ async function handleSubmit() {
await createModel(props.providerId, buildProviderModelCreatePayload({
globalModelId: selectedModel.id,
providerModelName: form.value.provider_model_name.trim(),
finalTieredPricing,
finalTieredPricing: providerTieredPricingOverride,
tieredPricingModified: manualGlobalModelMode.value ? false : tieredPricingModified.value,
pricePerRequest: manualGlobalModelMode.value ? undefined : form.value.price_per_request,
pricePerRequestModified: manualGlobalModelMode.value ? false : pricePerRequestModified.value,
cleanConfig,
configTouched: manualGlobalModelMode.value ? false : configTouched.value,
supportsVision: form.value.supports_vision,
@@ -0,0 +1,329 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { Model } from '@/api/endpoints'
import ProviderModelFormDialog from '../ProviderModelFormDialog.vue'
const modelMocks = vi.hoisted(() => ({
createModel: vi.fn(),
updateModel: vi.fn(),
getProviderModels: vi.fn(),
}))
const globalModelMocks = vi.hoisted(() => ({
createGlobalModel: vi.fn(),
getGlobalModel: vi.fn(),
listGlobalModels: vi.fn(),
}))
vi.mock('@/api/endpoints/models', () => modelMocks)
vi.mock('@/api/global-models', () => globalModelMocks)
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
error: vi.fn(),
success: vi.fn(),
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
const editingModel = {
id: 'provider-model-1',
provider_id: 'provider-1',
global_model_id: 'global-model-1',
provider_model_name: 'gpt-test',
tiered_pricing: null,
price_per_request: null,
effective_price_per_request: 0.25,
config: null,
effective_config: {
billing: {
video: {
price_per_second_by_resolution: { '720p': 0.1 },
},
},
},
effective_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
processing_tiers: {
priority: { price_multiplier: 2.5 },
fast: { price_multiplier: 2 },
hyperlane: {
tiers: [{ up_to: null, input_price_per_1m: 8, output_price_per_1m: 48 }],
},
},
},
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
} as Model
function mountDialog(model: Model | null = editingModel) {
const root = document.createElement('div')
document.body.appendChild(root)
const open = ref(false)
const app = createApp(defineComponent({
setup() {
return () => h(ProviderModelFormDialog, {
open: open.value,
providerId: 'provider-1',
editingModel: model,
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
}
function findButton(text: string): HTMLButtonElement {
const button = [...document.body.querySelectorAll('button')]
.find(candidate => candidate.textContent?.trim() === text)
if (!(button instanceof HTMLButtonElement)) throw new Error(`Missing button: ${text}`)
return button
}
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
beforeEach(() => {
modelMocks.createModel.mockReset()
modelMocks.updateModel.mockReset()
modelMocks.updateModel.mockResolvedValue(editingModel)
modelMocks.getProviderModels.mockReset()
globalModelMocks.createGlobalModel.mockReset()
globalModelMocks.getGlobalModel.mockReset()
globalModelMocks.listGlobalModels.mockReset()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('ProviderModelFormDialog processing-tier pricing', () => {
it('uses the same compact Fast grouping for inherited global-model pricing', async () => {
mountDialog()
await settle()
expect(document.body.textContent).toContain('选择计费模式')
for (const tab of ['Token', '按次', '图片', '视频']) {
expect(findButton(tab)).toBeDefined()
}
findButton('Token').click()
await nextTick()
expect(document.body.querySelector('[data-processing-tier="standard"]')).not.toBeNull()
expect(document.body.querySelector('[data-processing-tier="hyperlane"]')).not.toBeNull()
const fastGroup = document.body.querySelector('[data-processing-tier-group="fast"]')
expect(fastGroup?.textContent).toContain('Fast')
expect(fastGroup?.textContent).toContain('OpenAI')
expect(fastGroup?.textContent).toContain('Chat / Responses')
expect(fastGroup?.textContent).toContain('Claude')
expect(fastGroup?.textContent).toContain('Messages')
expect(document.body.querySelector<HTMLInputElement>(
'[data-testid="processing-tier-multiplier-priority"]',
)?.value).toBe('2.5')
expect(document.body.querySelector<HTMLInputElement>(
'[data-testid="processing-tier-multiplier-fast"]',
)?.value).toBe('2')
findButton('保存').click()
await settle()
const payload = modelMocks.updateModel.mock.calls[0][2]
expect(payload).not.toHaveProperty('tiered_pricing')
expect(payload).not.toHaveProperty('price_per_request')
expect(payload).not.toHaveProperty('config')
})
it('creates a Provider price override only after the inherited price is edited', async () => {
mountDialog()
await settle()
findButton('Token').click()
await nextTick()
const multiplier = document.body.querySelector<HTMLInputElement>(
'[data-testid="processing-tier-multiplier-priority"]',
)
if (!multiplier) throw new Error('Missing OpenAI Fast multiplier')
multiplier.value = '3'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
findButton('保存').click()
await settle()
const payload = modelMocks.updateModel.mock.calls[0][2]
expect(payload.tiered_pricing.processing_tiers.priority).toEqual({
price_multiplier: 3,
})
expect(payload.tiered_pricing).not.toHaveProperty('tiers')
expect(payload.tiered_pricing.processing_tiers).not.toHaveProperty('fast')
expect(payload.tiered_pricing.processing_tiers).not.toHaveProperty('hyperlane')
expect(payload).not.toHaveProperty('price_per_request')
expect(payload).not.toHaveProperty('config')
})
it('reopens a processing-only override with inherited Standard and keeps the next save partial', async () => {
const partialOverride = {
processing_tiers: {
priority: { price_multiplier: 3 },
},
}
const reopenedModel = {
...editingModel,
tiered_pricing: partialOverride,
// The current backend returns raw-or-global here, so a partial raw value has no tiers.
effective_tiered_pricing: partialOverride,
} as Model
globalModelMocks.getGlobalModel.mockResolvedValue({
id: 'global-model-1',
name: 'gpt-test',
display_name: 'GPT Test',
is_active: true,
default_tiered_pricing: editingModel.effective_tiered_pricing,
created_at: '2026-01-01T00:00:00Z',
total_models: 1,
total_providers: 1,
price_range: {},
})
mountDialog(reopenedModel)
await settle()
findButton('Token').click()
await nextTick()
expect(globalModelMocks.getGlobalModel).toHaveBeenCalledWith('global-model-1')
expect(document.body.querySelector<HTMLInputElement>(
'input[aria-label="Standard 阶梯 1 输入价格(美元/百万 Token"]',
)?.value).toBe('5')
expect(document.body.querySelector<HTMLInputElement>(
'[data-testid="processing-tier-multiplier-priority"]',
)?.value).toBe('3')
expect(document.body.querySelector<HTMLInputElement>(
'[data-testid="processing-tier-multiplier-fast"]',
)?.value).toBe('2')
expect(document.body.querySelector('[data-processing-tier="hyperlane"]')).not.toBeNull()
const multiplier = document.body.querySelector<HTMLInputElement>(
'[data-testid="processing-tier-multiplier-priority"]',
)
if (!multiplier) throw new Error('Missing OpenAI Fast multiplier')
multiplier.value = '4'
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
findButton('保存').click()
await settle()
expect(modelMocks.updateModel.mock.calls[0][2].tiered_pricing).toEqual({
processing_tiers: {
priority: { price_multiplier: 4 },
},
})
})
it('reopens and edits an explicit unknown Provider processing tier', async () => {
const providerHyperlane = {
tiers: [{ up_to: null, input_price_per_1m: 9, output_price_per_1m: 54 }],
future_overlay_option: 'keep-provider-hyperlane',
}
const partialOverride = {
processing_tiers: {
hyperlane: providerHyperlane,
},
}
const reopenedModel = {
...editingModel,
tiered_pricing: partialOverride,
effective_tiered_pricing: partialOverride,
} as Model
globalModelMocks.getGlobalModel.mockResolvedValue({
id: 'global-model-1',
name: 'gpt-test',
display_name: 'GPT Test',
is_active: true,
default_tiered_pricing: editingModel.effective_tiered_pricing,
created_at: '2026-01-01T00:00:00Z',
total_models: 1,
total_providers: 1,
price_range: {},
})
mountDialog(reopenedModel)
await settle()
findButton('Token').click()
await nextTick()
const hyperlane = document.body.querySelector<HTMLButtonElement>(
'[data-processing-tier="hyperlane"]',
)
if (!hyperlane) throw new Error('Missing hyperlane pricing entry')
hyperlane.click()
await nextTick()
const input = document.body.querySelector<HTMLInputElement>(
'input[aria-label="hyperlane 阶梯 1 输入价格(美元/百万 Token"]',
)
if (!input) throw new Error('Missing hyperlane input-price editor')
expect(input.value).toBe('9')
input.value = '10'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
findButton('保存').click()
await settle()
expect(modelMocks.updateModel.mock.calls[0][2].tiered_pricing).toEqual({
processing_tiers: {
hyperlane: {
...providerHyperlane,
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 54 }],
},
},
})
})
it('edits the per-request override through the same billing-mode tabs', async () => {
mountDialog()
await settle()
findButton('按次').click()
await nextTick()
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="留空使用全局模型默认值"]',
)
if (!input) throw new Error('Missing per-request price input')
input.value = '0.5'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
findButton('保存').click()
await settle()
const payload = modelMocks.updateModel.mock.calls[0][2]
expect(payload.price_per_request).toBe(0.5)
expect(payload).not.toHaveProperty('tiered_pricing')
expect(payload).not.toHaveProperty('config')
})
it('enables the Provider image capability when the Image tab is explicitly selected', async () => {
mountDialog()
await settle()
findButton('图片').click()
await nextTick()
findButton('保存').click()
await settle()
const payload = modelMocks.updateModel.mock.calls[0][2]
expect(payload.supports_image_generation).toBe(true)
expect(payload).not.toHaveProperty('tiered_pricing')
expect(payload).not.toHaveProperty('price_per_request')
expect(payload).not.toHaveProperty('config')
})
})
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'
import {
buildProviderModelCreatePayload,
buildProviderModelUpdatePayload,
buildProviderTieredPricingOverride,
mergeProviderTieredPricingForEditing,
modelSupportsEmbedding,
} from '../provider-model-form-helpers'
@@ -29,7 +31,8 @@ describe('provider model form embedding helpers', () => {
providerModelName: 'text-embedding-3-small',
finalTieredPricing: pricing,
tieredPricingModified: false,
pricePerRequest: undefined,
pricePerRequest: 0.25,
pricePerRequestModified: false,
cleanConfig: {
embedding: true,
model_type: 'embedding',
@@ -44,6 +47,7 @@ describe('provider model form embedding helpers', () => {
global_model_id: 'gm-embedding',
provider_model_name: 'text-embedding-3-small',
tiered_pricing: undefined,
price_per_request: undefined,
config: undefined,
supports_streaming: false,
})
@@ -57,6 +61,7 @@ describe('provider model form embedding helpers', () => {
finalTieredPricing: pricing,
tieredPricingModified: false,
pricePerRequest: undefined,
pricePerRequestModified: false,
cleanConfig: undefined,
configTouched: false,
isActive: true,
@@ -75,13 +80,16 @@ describe('provider model form embedding helpers', () => {
it('preserves edited provider embedding config without posting unsupported embedding controls', () => {
const payload = buildProviderModelUpdatePayload({
finalTieredPricing: pricing,
tieredPricingModified: true,
pricePerRequest: undefined,
pricePerRequestModified: false,
cleanConfig: {
streaming: false,
embedding: true,
model_type: 'embedding',
api_formats: ['gemini:embedding'],
},
configTouched: true,
supportsStreaming: false,
isActive: true,
})
@@ -92,7 +100,150 @@ describe('provider model form embedding helpers', () => {
model_type: 'embedding',
api_formats: ['gemini:embedding'],
})
expect(payload.tiered_pricing).toEqual(pricing)
expect(payload.supports_streaming).toBe(false)
expect('supports_embedding' in payload).toBe(false)
})
it('keeps inherited pricing and config out of an unchanged provider update', () => {
const payload = buildProviderModelUpdatePayload({
finalTieredPricing: pricing,
tieredPricingModified: false,
pricePerRequest: 0.25,
pricePerRequestModified: false,
cleanConfig: { billing: { video: { price_per_second_by_resolution: { '720p': 0.1 } } } },
configTouched: false,
isActive: true,
})
expect(payload).not.toHaveProperty('tiered_pricing')
expect(payload).not.toHaveProperty('price_per_request')
expect(payload).not.toHaveProperty('config')
})
it('writes an explicitly edited per-request price and supports clearing it', () => {
const edited = buildProviderModelUpdatePayload({
finalTieredPricing: pricing,
tieredPricingModified: false,
pricePerRequest: 0.5,
pricePerRequestModified: true,
cleanConfig: undefined,
configTouched: false,
isActive: true,
})
const cleared = buildProviderModelUpdatePayload({
finalTieredPricing: pricing,
tieredPricingModified: false,
pricePerRequest: undefined,
pricePerRequestModified: true,
cleanConfig: undefined,
configTouched: false,
isActive: true,
})
expect(edited.price_per_request).toBe(0.5)
expect(cleared.price_per_request).toBeNull()
})
})
describe('provider model pricing override helpers', () => {
const inheritedPricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
future_global_option: 'inherit-only',
processing_tiers: {
priority: { price_multiplier: 2.5 },
fast: { price_multiplier: 2 },
},
}
it('projects a processing-tier edit without freezing inherited Standard or other tiers', () => {
const finalPricing = structuredClone(inheritedPricing)
finalPricing.processing_tiers.priority.price_multiplier = 3
const override = buildProviderTieredPricingOverride(
finalPricing,
inheritedPricing,
null,
)
expect(override).toEqual({
processing_tiers: {
priority: { price_multiplier: 3 },
},
})
expect(override).not.toHaveProperty('tiers')
expect(override?.processing_tiers).not.toHaveProperty('fast')
expect(override).not.toHaveProperty('future_global_option')
const createPayload = buildProviderModelCreatePayload({
globalModelId: 'global-model-1',
providerModelName: 'gpt-test',
finalTieredPricing: override,
tieredPricingModified: true,
pricePerRequestModified: false,
configTouched: false,
isActive: true,
})
expect(createPayload.tiered_pricing).toEqual(override)
expect(createPayload.tiered_pricing).not.toHaveProperty('tiers')
})
it('keeps an existing Provider Standard override while adding only the edited tier', () => {
const providerStandard = {
tiers: [{ up_to: null, input_price_per_1m: 7, output_price_per_1m: 42 }],
provider_contract: 'keep-provider-standard',
}
const editorPricing = {
...structuredClone(providerStandard),
processing_tiers: structuredClone(inheritedPricing.processing_tiers),
}
const finalPricing = structuredClone(editorPricing)
finalPricing.processing_tiers.priority.price_multiplier = 3
const override = buildProviderTieredPricingOverride(
finalPricing,
editorPricing,
providerStandard,
)
expect(override).toEqual({
...providerStandard,
processing_tiers: {
priority: { price_multiplier: 3 },
},
})
expect(override?.processing_tiers).not.toHaveProperty('fast')
})
it('merges a saved processing-only override for editing and stays partial on the next save', () => {
const savedOverride = {
processing_tiers: {
priority: { price_multiplier: 3 },
},
}
const reopenedEditorPricing = mergeProviderTieredPricingForEditing(
inheritedPricing,
savedOverride,
)
expect(reopenedEditorPricing).toEqual({
...inheritedPricing,
processing_tiers: {
priority: { price_multiplier: 3 },
fast: { price_multiplier: 2 },
},
})
const finalPricing = structuredClone(reopenedEditorPricing!)
finalPricing.processing_tiers!.priority.price_multiplier = 4
expect(buildProviderTieredPricingOverride(
finalPricing,
reopenedEditorPricing,
savedOverride,
)).toEqual({
processing_tiers: {
priority: { price_multiplier: 4 },
},
})
})
})
@@ -1,4 +1,9 @@
import type { ModelCreate, ModelUpdate, TieredPricingConfig } from '@/api/endpoints'
import type {
ModelCreate,
ModelUpdate,
ProviderTieredPricingConfig,
TieredPricingConfig,
} from '@/api/endpoints'
interface EmbeddingMetadataCarrier {
supported_capabilities?: string[] | null
@@ -15,9 +20,10 @@ function isEmbeddingApiFormat(format: unknown): boolean {
export interface ProviderModelCreatePayloadInput {
globalModelId: string
providerModelName: string
finalTieredPricing: TieredPricingConfig | null
finalTieredPricing: ProviderTieredPricingConfig | null
tieredPricingModified: boolean
pricePerRequest?: number
pricePerRequestModified: boolean
cleanConfig?: Record<string, unknown>
configTouched: boolean
supportsVision?: boolean
@@ -29,9 +35,12 @@ export interface ProviderModelCreatePayloadInput {
}
export interface ProviderModelUpdatePayloadInput {
finalTieredPricing: TieredPricingConfig | null
finalTieredPricing: ProviderTieredPricingConfig | null
tieredPricingModified: boolean
pricePerRequest?: number
pricePerRequestModified: boolean
cleanConfig?: Record<string, unknown>
configTouched: boolean
supportsVision?: boolean
supportsFunctionCalling?: boolean
supportsStreaming?: boolean
@@ -53,12 +62,180 @@ export function modelSupportsEmbedding(model: EmbeddingMetadataCarrier | null |
|| (Array.isArray(config.api_formats) && config.api_formats.some(isEmbeddingApiFormat))
}
const STANDARD_PRICING_KEYS = new Set([
'tiers',
'image_output_prices',
'image_output_price_default',
'image_output_price_ranges',
'image_output_price_per_image',
'image_output_price_matrix',
'image_prices',
])
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
function hasOwn(object: object, key: PropertyKey): boolean {
return Object.prototype.hasOwnProperty.call(object, key)
}
function valueHasEntries(value: unknown): boolean {
return (Array.isArray(value) && value.length > 0)
|| (isRecord(value) && Object.keys(value).length > 0)
}
function hasStandardPricingData(pricing: ProviderTieredPricingConfig): boolean {
return (Array.isArray(pricing.tiers) && pricing.tiers.length > 0)
|| (typeof pricing.image_output_price_default === 'number'
&& Number.isFinite(pricing.image_output_price_default))
|| [
'image_output_prices',
'image_output_price_ranges',
'image_output_price_per_image',
'image_output_price_matrix',
'image_prices',
].some(key => valueHasEntries(pricing[key]))
}
function pricingRoot(pricing: ProviderTieredPricingConfig): Record<string, unknown> {
return Object.fromEntries(
Object.entries(pricing).filter(([key]) => key !== 'processing_tiers'),
)
}
function processingTierEntries(pricing: ProviderTieredPricingConfig | null | undefined) {
return isRecord(pricing?.processing_tiers)
? Object.entries(pricing.processing_tiers)
: []
}
function jsonValuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left)
&& Array.isArray(right)
&& left.length === right.length
&& left.every((value, index) => jsonValuesEqual(value, right[index]))
}
if (!isRecord(left) || !isRecord(right)) return false
const leftKeys = Object.keys(left).sort()
const rightKeys = Object.keys(right).sort()
return leftKeys.length === rightKeys.length
&& leftKeys.every((key, index) => (
key === rightKeys[index]
&& jsonValuesEqual(left[key], right[key])
))
}
/**
* Build the complete catalog shown by the editor from the two independent
* runtime sources: GlobalModel Standard/default overlays and Provider overrides.
*/
export function mergeProviderTieredPricingForEditing(
globalDefault: ProviderTieredPricingConfig | null | undefined,
providerOverride: ProviderTieredPricingConfig | null | undefined,
): TieredPricingConfig | null {
if (!providerOverride) {
return Array.isArray(globalDefault?.tiers)
? cloneJson(globalDefault) as TieredPricingConfig
: null
}
const providerHasStandard = hasStandardPricingData(providerOverride)
const providerRoot = pricingRoot(providerOverride)
let mergedRoot: Record<string, unknown>
if (providerHasStandard) {
mergedRoot = providerRoot
} else if (globalDefault) {
const providerMetadata = Object.fromEntries(
Object.entries(providerRoot).filter(([key]) => !STANDARD_PRICING_KEYS.has(key)),
)
mergedRoot = {
...pricingRoot(globalDefault),
...providerMetadata,
}
} else {
return null
}
if (!Array.isArray(mergedRoot.tiers)) return null
const mergedProcessingTiers = Object.fromEntries([
...processingTierEntries(globalDefault),
...processingTierEntries(providerOverride),
])
if (Object.keys(mergedProcessingTiers).length > 0) {
mergedRoot.processing_tiers = mergedProcessingTiers
}
return cloneJson(mergedRoot) as TieredPricingConfig
}
/**
* Project the complete editor catalog back to the smallest Provider override.
* Unchanged Standard and processing-tier values keep inheriting from GlobalModel.
*/
export function buildProviderTieredPricingOverride(
finalPricing: TieredPricingConfig | null,
originalEditorPricing: TieredPricingConfig | null,
originalProviderOverride: ProviderTieredPricingConfig | null | undefined,
): ProviderTieredPricingConfig | null {
if (!finalPricing) return null
if (!originalEditorPricing) return cloneJson(finalPricing)
const originalProcessingTiers = originalProviderOverride?.processing_tiers
const preservedProcessingTiers = hasOwn(originalProviderOverride || {}, 'processing_tiers')
? originalProcessingTiers === undefined
? undefined
: cloneJson(originalProcessingTiers)
: undefined
let result = cloneJson(originalProviderOverride || {})
if (!jsonValuesEqual(pricingRoot(finalPricing), pricingRoot(originalEditorPricing))) {
result = cloneJson(pricingRoot(finalPricing)) as ProviderTieredPricingConfig
if (preservedProcessingTiers !== undefined || originalProcessingTiers === null) {
result.processing_tiers = preservedProcessingTiers ?? null
}
}
const finalProcessingTiers = new Map(processingTierEntries(finalPricing))
const baselineProcessingTiers = new Map(processingTierEntries(originalEditorPricing))
const nextProcessingTiers = new Map(processingTierEntries(result))
let processingTiersChanged = false
const keys = new Set([
...finalProcessingTiers.keys(),
...baselineProcessingTiers.keys(),
])
for (const key of keys) {
const finalOverlay = finalProcessingTiers.get(key)
const baselineOverlay = baselineProcessingTiers.get(key)
if (jsonValuesEqual(finalOverlay, baselineOverlay)) continue
processingTiersChanged = true
if (finalOverlay === undefined) nextProcessingTiers.delete(key)
else nextProcessingTiers.set(key, cloneJson(finalOverlay))
}
if (processingTiersChanged) {
if (nextProcessingTiers.size > 0) {
result.processing_tiers = Object.fromEntries(nextProcessingTiers)
} else {
delete result.processing_tiers
}
}
return Object.keys(result).length > 0 ? result : null
}
export function buildProviderModelCreatePayload(input: ProviderModelCreatePayloadInput): ModelCreate {
return {
global_model_id: input.globalModelId,
provider_model_name: input.providerModelName,
tiered_pricing: input.tieredPricingModified && input.finalTieredPricing ? input.finalTieredPricing : undefined,
price_per_request: input.pricePerRequest,
price_per_request: input.pricePerRequestModified ? input.pricePerRequest : undefined,
config: input.configTouched ? input.cleanConfig : undefined,
supports_vision: input.supportsVision,
supports_function_calling: input.supportsFunctionCalling,
@@ -71,9 +248,11 @@ export function buildProviderModelCreatePayload(input: ProviderModelCreatePayloa
export function buildProviderModelUpdatePayload(input: ProviderModelUpdatePayloadInput): ModelUpdate {
return {
tiered_pricing: input.finalTieredPricing,
price_per_request: input.pricePerRequest ?? null,
config: input.cleanConfig || null,
...(input.tieredPricingModified ? { tiered_pricing: input.finalTieredPricing } : {}),
...(input.pricePerRequestModified
? { price_per_request: input.pricePerRequest ?? null }
: {}),
...(input.configTouched ? { config: input.cleanConfig || null } : {}),
supports_vision: input.supportsVision,
supports_function_calling: input.supportsFunctionCalling,
supports_streaming: input.supportsStreaming,
@@ -250,11 +250,12 @@
</span>
</div>
<ServiceTierFacts
v-if="hasServiceTierFacts"
v-if="hasServiceTierFacts || processingTierPriceMultiplier !== null"
class="mt-3"
:requested="serviceTierFacts.requested"
:actual="serviceTierFacts.actual"
:billing="serviceTierFacts.billing"
:price-multiplier="processingTierPriceMultiplier"
/>
</div>
@@ -313,13 +314,13 @@
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-muted-foreground sm:hidden">
<div class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1">
<span>输入</span>
<span class="text-right">${{ formatPrice(tier.input_price_per_1m) }}/M</span>
<span class="text-right">{{ formatPricePerMillion(tier.input_price_per_1m) }}</span>
</div>
<div
class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1"
>
<span>输出</span>
<span class="text-right">${{ formatPrice(tier.output_price_per_1m) }}/M</span>
<span class="text-right">{{ formatPricePerMillion(tier.output_price_per_1m) }}</span>
</div>
<template v-if="getTierActiveCacheCreationDisplay(tier) || shouldShowCacheReadPrice(tier)">
<div
@@ -341,8 +342,8 @@
</template>
</div>
<div class="text-muted-foreground hidden items-center gap-2 flex-wrap sm:flex">
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
<span>输入 {{ formatPricePerMillion(tier.input_price_per_1m) }}</span>
<span>输出 {{ formatPricePerMillion(tier.output_price_per_1m) }}</span>
<span v-if="getTierActiveCacheCreationDisplay(tier)">
{{ getTierActiveCacheCreationDisplay(tier)?.label }}
${{ formatPrice(getTierActiveCacheCreationDisplay(tier)?.price || 0) }}/M
@@ -906,6 +907,12 @@ import {
resolveUsageStreamLabelSegments,
} from '../utils/status'
import { resolveRequestFailureNotice } from '../utils/errorNotice'
import {
formatPricePerMillion,
resolveProcessingTierPriceMultiplier,
resolveSettlementPricingSourceLabel,
resolveSettlementPricingTiers,
} from '../utils/settlement-pricing'
// 子组件
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
@@ -1355,6 +1362,9 @@ const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
const serviceTierFacts = computed(() => resolveServiceTierFacts(detail.value))
const hasServiceTierFacts = computed(() => hasServiceTierFact(serviceTierFacts.value))
const processingTierPriceMultiplier = computed(() => (
resolveProcessingTierPriceMultiplier(detail.value)
))
const settlementInfo = computed<JsonRecord | null>(() =>
asRecord(detail.value?.settlement ?? null),
@@ -1643,20 +1653,10 @@ const hasValidConversation = computed(() => {
return false
})
// 价格来源标签
// tiered_pricing.source 表示定价来源: 'provider' 或 'global'
// 价格来源优先使用 v3 结算快照,旧 tiered_pricing.source 仅作回退。
const priceSourceLabel = computed(() => {
if (!detail.value) return '历史定价'
const source = detail.value.tiered_pricing?.source
if (source === 'provider') {
return '提供商定价'
} else if (source === 'global') {
return '全局定价'
}
// 没有 tiered_pricing 时,使用历史价格
return '历史定价'
return resolveSettlementPricingSourceLabel(detail.value) ?? '历史定价'
})
const cacheCreationInputTokens5m = computed(() => {
@@ -1896,10 +1896,9 @@ const activeCacheTtlMinutes = computed(() => {
const displayTiers = computed(() => {
if (!detail.value) return []
// 如果有阶梯定价数据,直接使用
if (detail.value.tiered_pricing?.tiers && detail.value.tiered_pricing.tiers.length > 0) {
return detail.value.tiered_pricing.tiers
}
// 优先展示结算快照中已解析的价格目录,旧字段仅作回退。
const resolvedTiers = resolveSettlementPricingTiers(detail.value)
if (resolvedTiers) return resolvedTiers as PricingTierLike[]
// 否则用历史价格构建单阶梯(无上限)
return [{
@@ -1,6 +1,7 @@
<template>
<dl
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3"
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs"
:class="hasPriceMultiplier ? 'sm:grid-cols-4' : 'sm:grid-cols-3'"
data-testid="service-tier-facts"
>
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
@@ -9,9 +10,9 @@
</dt>
<dd
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
:title="requested || '-'"
:title="formatServiceTierFact(requested) || '-'"
>
{{ requested || '-' }}
{{ formatServiceTierFact(requested) || '-' }}
</dd>
</div>
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
@@ -20,9 +21,9 @@
</dt>
<dd
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
:title="actual || '-'"
:title="formatServiceTierFact(actual) || '-'"
>
{{ actual || '-' }}
{{ formatServiceTierFact(actual) || '-' }}
</dd>
</div>
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
@@ -31,18 +32,48 @@
</dt>
<dd
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
:title="billing || '-'"
:title="formatServiceTierFact(billing) || '-'"
>
{{ billing || '-' }}
{{ formatServiceTierFact(billing) || '-' }}
</dd>
</div>
<div
v-if="hasPriceMultiplier"
class="flex min-w-0 items-baseline justify-between gap-3 sm:block"
data-testid="service-tier-price-multiplier"
>
<dt class="text-muted-foreground">
{{ multiplierTierLabel }} 倍率
</dt>
<dd class="truncate font-mono font-medium text-foreground sm:mt-0.5">
{{ formattedPriceMultiplier }}×
</dd>
</div>
</dl>
</template>
<script setup lang="ts">
defineProps<{
import { computed } from 'vue'
import { formatServiceTierFact } from '../utils/service-tier'
const props = defineProps<{
requested: string | null
actual: string | null
billing: string | null
priceMultiplier?: number | null
}>()
const hasPriceMultiplier = computed(() => (
typeof props.priceMultiplier === 'number'
&& Number.isFinite(props.priceMultiplier)
&& props.priceMultiplier >= 0
))
const multiplierTierLabel = computed(() => (
formatServiceTierFact(props.billing ?? props.actual ?? props.requested) ?? '处理层级'
))
const formattedPriceMultiplier = computed(() => (
hasPriceMultiplier.value ? String(props.priceMultiplier) : ''
))
</script>
@@ -1105,6 +1105,7 @@ import { useRowClick } from '@/composables/useRowClick'
import { useDarkMode } from '@/composables/useDarkMode'
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
import { formatServiceTierFact } from '../utils/service-tier'
import type { DateRangeParams, UsageRecord } from '../types'
import { MultiSelect, TimeRangePicker } from '@/components/common'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
@@ -1635,6 +1636,9 @@ function canonicalServiceTier(value: string | null): string | null {
if (value === 'auto' || value === 'default' || value === 'standard') {
return 'standard'
}
if (value === 'fast') {
return 'priority'
}
return value
}
@@ -1661,10 +1665,13 @@ function buildServiceTierBadgePresentation(
billingTier: string | null,
): ServiceTierBadgePresentation {
const titleLines: string[] = []
if (requestedRaw) titleLines.push(`请求档位:${requestedRaw}`)
if (actualRaw) titleLines.push(`实际档位:${actualRaw}`)
if (billingTier) {
titleLines.push(`计费档位:${billingTier}`)
const requestedLabel = formatServiceTierFact(requestedRaw)
const actualLabel = formatServiceTierFact(actualRaw)
const billingLabel = formatServiceTierFact(billingTier)
if (requestedLabel) titleLines.push(`请求档位:${requestedLabel}`)
if (actualLabel) titleLines.push(`实际档位:${actualLabel}`)
if (billingLabel) {
titleLines.push(`计费档位:${billingLabel}`)
} else {
titleLines.push(`计费档位:${state === 'pending' ? '待上游确认' : '未确认'}`)
}
@@ -1689,7 +1696,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
if (actual) {
if (requestedFast && !actualFast) {
return buildServiceTierBadgePresentation(
`fast → ${actual}`,
`Fast → ${actual}`,
'downgraded',
requestedRaw,
actualRaw,
@@ -1699,7 +1706,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
if (!requestedFast && actualFast) {
const requestedLabel = requested ?? 'standard'
return buildServiceTierBadgePresentation(
requested ? `${requestedLabel}fast` : 'fast',
requested ? `${requestedLabel}Fast` : 'Fast',
requested ? 'upgraded' : 'confirmed',
requestedRaw,
actualRaw,
@@ -1708,7 +1715,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
}
if (actualFast) {
return buildServiceTierBadgePresentation(
'fast',
'Fast',
'confirmed',
requestedRaw,
actualRaw,
@@ -1722,7 +1729,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
const displayStatus = getDisplayStatus(record)
const isActive = displayStatus === 'pending' || displayStatus === 'streaming'
return buildServiceTierBadgePresentation(
isActive ? 'fast · 待确认' : 'fast · 未确认',
isActive ? 'Fast · 待确认' : 'Fast · 未确认',
isActive ? 'pending' : 'unconfirmed',
requestedRaw,
null,
@@ -1734,8 +1741,8 @@ function getServiceTierTitle(record: UsageRecord): string {
const badge = getServiceTierBadge(record)
if (badge) return badge.title
const requested = normalizeServiceTier(record.service_tier)
const actual = normalizeServiceTier(record.actual_service_tier)
const requested = formatServiceTierFact(record.service_tier)
const actual = formatServiceTierFact(record.actual_service_tier)
return [
requested ? `请求档位:${requested}` : null,
actual ? `实际档位:${actual}` : null,
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from 'vue'
import type { RequestDetail } from '@/api/dashboard'
import RequestDetailDrawer from '../RequestDetailDrawer.vue'
const apiMocks = vi.hoisted(() => ({
getRequestDetail: vi.fn(),
}))
vi.mock('@/api/dashboard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/dashboard')>()
return {
...actual,
dashboardApi: {
...actual.dashboardApi,
getRequestDetail: apiMocks.getRequestDetail,
},
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
apiMocks.getRequestDetail.mockReset()
})
function buildEmbeddingDetail(): RequestDetail {
return {
id: 'usage-embedding-1',
request_id: 'req-embedding-1',
user: {
id: 'user-1',
username: 'embedding-user',
email: 'embedding@example.com',
},
api_key: {
id: 'key-1',
name: 'test-key',
display: 'test-key',
},
provider: 'embedding-provider',
model: 'embedding-model',
tokens: { input: 100, output: 0, total: 100 },
cost: { input: 0.00001, output: 0, total: 0.00001 },
request_type: 'embedding',
is_stream: false,
status: 'completed',
status_code: 200,
response_time_ms: 10,
created_at: '2026-07-16T00:00:00Z',
request_headers: { 'content-type': 'application/json' },
settlement: {
settlement_snapshot: {
pricing_snapshot: {
pricing_source: 'global_default',
tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 0.1 }],
},
},
},
},
}
}
describe('RequestDetailDrawer settlement pricing', () => {
it('renders an input-only embedding tier without treating the missing output price as zero', async () => {
apiMocks.getRequestDetail.mockResolvedValue(buildEmbeddingDetail())
let isOpen!: Ref<boolean>
const Host = defineComponent({
setup() {
isOpen = ref(false)
return () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-embedding-1',
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => {
expect(document.body.textContent).toContain('输入 $0.1/M')
expect(document.body.textContent).toContain('输出 -')
})
expect(document.body.textContent).not.toContain('输出 $0/M')
})
})
@@ -33,9 +33,75 @@ describe('ServiceTierFacts', () => {
'计费层级',
])
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
'priority',
'Fast',
'-',
'flex',
])
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
'Fast',
'-',
'flex',
])
})
it('uses the same Fast label for raw priority and fast facts', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(ServiceTierFacts, {
requested: 'priority',
actual: 'fast',
billing: 'priority',
}),
})
app.mount(root)
mountedApps.push({ app, root })
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
'Fast',
'Fast',
'Fast',
])
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
'Fast',
'Fast',
'Fast',
])
})
it('renders the processing-tier multiplier with the billing tier label', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(ServiceTierFacts, {
requested: 'priority',
actual: 'fast',
billing: 'fast',
priceMultiplier: 2.5,
}),
})
app.mount(root)
mountedApps.push({ app, root })
const multiplier = root.querySelector('[data-testid="service-tier-price-multiplier"]')
expect(multiplier?.textContent).toContain('Fast 倍率')
expect(multiplier?.textContent).toContain('2.5×')
})
it('does not render an empty or invalid processing-tier multiplier', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(ServiceTierFacts, {
requested: 'priority',
actual: null,
billing: null,
priceMultiplier: null,
}),
})
app.mount(root)
mountedApps.push({ app, root })
expect(root.querySelector('[data-testid="service-tier-price-multiplier"]')).toBeNull()
})
})
@@ -162,11 +162,12 @@ function mountUsageRecordsTable(records: UsageRecord[], overrides: Record<string
return root
}
function expectServiceTierBadge(root: HTMLElement, label: string) {
const labels = [...root.querySelectorAll('span')]
.map((element) => element.textContent?.trim())
function expectServiceTierBadge(root: HTMLElement, label: string): HTMLElement {
const badge = [...root.querySelectorAll<HTMLElement>('span')]
.find(element => element.textContent?.trim() === label)
expect(labels).toContain(label)
expect(badge).toBeDefined()
return badge as HTMLElement
}
afterEach(() => {
@@ -295,13 +296,26 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('xhigh')
})
it('shows confirmed fast when requested and actual service tiers are priority', () => {
it.each([
['priority', 'priority'],
['fast', 'fast'],
['priority', 'fast'],
['fast', 'priority'],
])('shows confirmed Fast for requested %s and actual %s', (requested, actual) => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: 'priority',
actual_service_tier: 'priority',
service_tier: requested,
actual_service_tier: actual,
})])
expectServiceTierBadge(root, 'fast')
const badge = expectServiceTierBadge(root, 'Fast')
expect(badge.getAttribute('title')).toBe([
'请求档位:Fast',
'实际档位:Fast',
'计费档位:Fast',
].join('\n'))
expect(badge.getAttribute('aria-label')).toBe(
'请求档位:Fast,实际档位:Fast,计费档位:Fast',
)
})
it('shows fast to standard when the provider downgrades a priority request', () => {
@@ -310,7 +324,12 @@ describe('UsageRecordsTable', () => {
actual_service_tier: 'default',
})])
expectServiceTierBadge(root, 'fast → standard')
const badge = expectServiceTierBadge(root, 'Fast → standard')
expect(badge.getAttribute('title')).toBe([
'请求档位:Fast',
'实际档位:default',
'计费档位:standard',
].join('\n'))
})
it('shows fast to flex when the provider moves a priority request to flex', () => {
@@ -319,7 +338,7 @@ describe('UsageRecordsTable', () => {
actual_service_tier: 'flex',
})])
expectServiceTierBadge(root, 'fast → flex')
expectServiceTierBadge(root, 'Fast → flex')
})
it('shows standard to fast when the provider upgrades a default request', () => {
@@ -328,7 +347,7 @@ describe('UsageRecordsTable', () => {
actual_service_tier: 'priority',
})])
expectServiceTierBadge(root, 'standard → fast')
expectServiceTierBadge(root, 'standard → Fast')
})
it.each(['pending', 'streaming'] as const)(
@@ -340,7 +359,7 @@ describe('UsageRecordsTable', () => {
status,
})])
expectServiceTierBadge(root, 'fast · 待确认')
expectServiceTierBadge(root, 'Fast · 待确认')
},
)
@@ -351,7 +370,7 @@ describe('UsageRecordsTable', () => {
status: 'completed',
})])
expectServiceTierBadge(root, 'fast · 未确认')
expectServiceTierBadge(root, 'Fast · 未确认')
})
it('offers embedding API formats in the usage record filter', () => {
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatServiceTierFact,
hasServiceTierFact,
normalizeServiceTierFact,
resolveServiceTierFacts,
@@ -38,4 +39,16 @@ describe('service tier facts', () => {
expect(normalizeServiceTierFact(' ')).toBeNull()
expect(normalizeServiceTierFact(0)).toBeNull()
})
it.each(['priority', 'fast', ' Priority ', 'FAST'])(
'displays the raw %s tier as Fast',
(tier) => {
expect(formatServiceTierFact(tier)).toBe('Fast')
},
)
it('keeps non-fast tier labels unchanged', () => {
expect(formatServiceTierFact(' Batch ')).toBe('Batch')
expect(formatServiceTierFact(' ')).toBeNull()
})
})
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import {
formatPricePerMillion,
resolveProcessingTierPriceMultiplier,
resolveSettlementPricingSnapshot,
resolveSettlementPricingSourceLabel,
resolveSettlementPricingTiers,
} from '../settlement-pricing'
function buildSource(overrides: Record<string, unknown> = {}) {
return {
settlement: {
rate_multiplier: 9,
settlement_snapshot: {
pricing_snapshot: {
billing_processing_tier: 'priority',
pricing_source: 'provider_override',
tiered_pricing_source: 'global_default',
processing_tier_price_multiplier: 2.5,
tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
},
...overrides,
},
},
},
tiered_pricing: {
source: 'global',
tiers: [{ up_to: null, input_price_per_1m: 1 }],
},
}
}
describe('settlement pricing presentation', () => {
it('reads the resolved pricing snapshot and prefers its catalog over legacy tiers', () => {
const source = buildSource()
expect(resolveSettlementPricingSnapshot(source)?.billing_processing_tier).toBe('priority')
expect(resolveSettlementPricingTiers(source)).toEqual([
{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 },
])
})
it.each([
['provider_override', '提供商定价'],
['global_default', '全局定价'],
['mixed', '混合定价'],
])('maps the resolved %s pricing source', (pricingSource, label) => {
expect(resolveSettlementPricingSourceLabel(buildSource({
pricing_source: pricingSource,
}))).toBe(label)
})
it('falls back from pricing_source to tiered_pricing_source and then legacy source', () => {
expect(resolveSettlementPricingSourceLabel(buildSource({
pricing_source: null,
tiered_pricing_source: 'global_default',
}))).toBe('全局定价')
expect(resolveSettlementPricingSourceLabel({
tiered_pricing: { source: 'provider' },
})).toBe('提供商定价')
})
it('uses only processing_tier_price_multiplier, never settlement.rate_multiplier', () => {
expect(resolveProcessingTierPriceMultiplier(buildSource())).toBe(2.5)
expect(resolveProcessingTierPriceMultiplier(buildSource({
processing_tier_price_multiplier: null,
}))).toBeNull()
expect(resolveProcessingTierPriceMultiplier({
settlement: { rate_multiplier: 9 },
})).toBeNull()
})
it('falls back to legacy tiers when the resolved snapshot has no catalog', () => {
expect(resolveSettlementPricingTiers(buildSource({ tiered_pricing: null }))).toEqual([
{ up_to: null, input_price_per_1m: 1 },
])
})
it('formats an input-only embedding tier without inventing an output price', () => {
const tiers = resolveSettlementPricingTiers(buildSource({
tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 0.1 }],
},
}))
expect(formatPricePerMillion(tiers?.[0]?.input_price_per_1m)).toBe('$0.1/M')
expect(formatPricePerMillion(tiers?.[0]?.output_price_per_1m)).toBe('-')
expect(formatPricePerMillion(null)).toBe('-')
expect(formatPricePerMillion(0)).toBe('$0/M')
})
})
@@ -33,6 +33,21 @@ export function normalizeServiceTierFact(value: unknown): string | null {
return normalized || null
}
/**
* Provider contracts use both `priority` (OpenAI) and `fast` (Claude) for the
* same user-facing processing mode. Keep the raw fact in data structures and
* normalize only at the presentation boundary.
*/
export function formatServiceTierFact(value: unknown): string | null {
const normalized = normalizeServiceTierFact(value)
if (normalized === null) return null
const canonical = normalized.toLowerCase()
return canonical === 'priority' || canonical === 'fast'
? 'Fast'
: normalized
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
@@ -0,0 +1,85 @@
interface SettlementPricingSource {
settlement?: unknown
tiered_pricing?: unknown
}
type JsonRecord = Record<string, unknown>
const PRICING_SOURCE_LABELS: Record<string, string> = {
provider_override: '提供商定价',
global_default: '全局定价',
mixed: '混合定价',
provider: '提供商定价',
global: '全局定价',
unpriced: '未定价',
}
export function resolveSettlementPricingSnapshot(
source: SettlementPricingSource | null | undefined,
): JsonRecord | null {
const settlement = asRecord(source?.settlement)
const settlementSnapshot = asRecord(settlement?.settlement_snapshot)
return asRecord(settlementSnapshot?.pricing_snapshot)
}
export function resolveSettlementPricingSourceLabel(
source: SettlementPricingSource | null | undefined,
): string | null {
const snapshot = resolveSettlementPricingSnapshot(source)
const legacyPricing = asRecord(source?.tiered_pricing)
for (const value of [
snapshot?.pricing_source,
snapshot?.tiered_pricing_source,
legacyPricing?.source,
]) {
const key = normalizeString(value)?.toLowerCase()
if (key && PRICING_SOURCE_LABELS[key]) return PRICING_SOURCE_LABELS[key]
}
return null
}
export function resolveSettlementPricingTiers(
source: SettlementPricingSource | null | undefined,
): JsonRecord[] | null {
const snapshot = resolveSettlementPricingSnapshot(source)
const snapshotPricing = asRecord(snapshot?.tiered_pricing)
const snapshotTiers = nonEmptyRecordArray(snapshotPricing?.tiers)
if (snapshotTiers) return snapshotTiers
const legacyPricing = asRecord(source?.tiered_pricing)
return nonEmptyRecordArray(legacyPricing?.tiers)
}
export function resolveProcessingTierPriceMultiplier(
source: SettlementPricingSource | null | undefined,
): number | null {
const value = resolveSettlementPricingSnapshot(source)?.processing_tier_price_multiplier
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? value
: null
}
export function formatPricePerMillion(value: unknown): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '-'
const fixed = value.toFixed(4)
return `$${Number.parseFloat(fixed).toString()}/M`
}
function normalizeString(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim()
return normalized || null
}
function nonEmptyRecordArray(value: unknown): JsonRecord[] | null {
if (!Array.isArray(value) || value.length === 0) return null
const records = value.filter((item): item is JsonRecord => asRecord(item) !== null)
return records.length > 0 ? records : null
}
function asRecord(value: unknown): JsonRecord | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as JsonRecord
: null
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
MODEL_DIRECTIVE_API_FORMATS,
MODEL_DIRECTIVE_SUFFIX_METADATA,
MODEL_DIRECTIVE_SUFFIXES,
REASONING_EFFORTS,
createDefaultModelDirectivesConfig,
@@ -31,6 +32,7 @@ describe('modelDirectivesConfig', () => {
expect(defaultModelDirectiveSuffixesForApiFormat('openai:search')).toEqual(MODEL_DIRECTIVE_SUFFIXES)
expect(defaultModelDirectiveSuffixesForApiFormat('claude:messages')).not.toContain('ultra')
expect(defaultModelDirectiveSuffixesForApiFormat('gemini:generate_content')).not.toContain('ultra')
expect(MODEL_DIRECTIVE_SUFFIX_METADATA.fast.description).toBe('Fast 服务层级')
})
it('creates a config whose mappings contain overrides only', () => {
@@ -44,7 +44,7 @@ export const MODEL_DIRECTIVE_SUFFIX_METADATA: Readonly<
xhigh: { label: 'xhigh', description: '超高推理投入' },
max: { label: 'max', description: '模型支持时使用最大推理投入' },
ultra: { label: 'ultra', description: 'Codex Ultra 预设,请求推理强度为 max' },
fast: { label: 'fast', description: 'Priority 服务层级' },
fast: { label: 'fast', description: 'Fast 服务层级' },
}
export const MODEL_DIRECTIVE_API_FORMATS = [
@@ -165,7 +165,6 @@
</Badge>
</div>
</div>
</div>
<!-- 定价信息 -->