mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
feat(pricing): support processing tier multipliers
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user