feat(pricing): support processing tier multipliers

This commit is contained in:
elky
2026-07-16 23:30:42 +08:00
parent 312583f055
commit 0be380243b
40 changed files with 5012 additions and 565 deletions
@@ -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,