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