feat(billing): add image output pricing and usage tracking

This commit is contained in:
ZheFox
2026-05-18 02:49:56 +08:00
parent 691ccaaa04
commit 0b2a8fafce
9 changed files with 884 additions and 15 deletions

View File

@@ -185,6 +185,7 @@ export interface RequestDetail {
total_cost?: number
cache_creation_cost?: number
cache_read_cost?: number
image_output_cost?: number
request_cost?: number // 按次计费费用
// Historical pricing fields (per 1M tokens)
input_price_per_1m?: number

View File

@@ -203,6 +203,21 @@
</div>
</div>
</div>
<div class="flex items-start gap-2 border-t border-border/60 pt-3">
<Checkbox
:model-value="isImageGenerationEnabled"
class="mt-0.5"
@update:model-value="setImageGenerationEnabled"
/>
<div class="space-y-1">
<div class="text-sm font-medium">
图片模型
</div>
<p class="text-xs text-muted-foreground">
启用图片输出计费,并展开尺寸 × 质量矩阵价格。
</p>
</div>
</div>
</div>
</section>
@@ -215,6 +230,7 @@
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="true"
:show-image-pricing="isImageGenerationEnabled"
/>
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费</Label>
@@ -534,6 +550,14 @@ const isEmbeddingEnabled = computed(() => {
|| form.value.config?.model_type === 'embedding'
})
const isImageGenerationEnabled = computed(() => {
return form.value.supported_capabilities?.includes('image_generation') === true
|| form.value.config?.image_generation === true
|| form.value.config?.model_type === 'image'
|| (Array.isArray(form.value.config?.api_formats)
&& form.value.config.api_formats.some((format) => String(format).endsWith(':image')))
})
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
// 设置 config 字段
@@ -576,6 +600,20 @@ function setEmbeddingEnabled(enabled: boolean) {
form.value.supported_capabilities = [...caps]
}
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
const enabled = value === true
const caps = new Set(form.value.supported_capabilities || [])
if (enabled) {
caps.add('image_generation')
setConfigField('image_generation', true)
} else {
caps.delete('image_generation')
setConfigField('image_generation', undefined)
if (form.value.config?.model_type === 'image') setConfigField('model_type', undefined)
}
form.value.supported_capabilities = [...caps]
}
function getNested(obj: unknown, path: string): unknown {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)
@@ -753,7 +791,10 @@ function selectModel(model: ModelsDevModelItem) {
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
form.value.config = config
form.value.supported_capabilities = model.supportsEmbedding ? ['embedding'] : []
const supportedCapabilities = new Set<string>()
if (model.supportsEmbedding) supportedCapabilities.add('embedding')
if (model.outputModalities?.includes('image')) supportedCapabilities.add('image_generation')
form.value.supported_capabilities = [...supportedCapabilities]
if (model.supportsEmbedding) {
setEmbeddingEnabled(true)
}

View File

@@ -137,7 +137,10 @@
添加价格阶梯
</Button>
<div class="rounded-lg border bg-muted/10 p-3 space-y-3">
<div
v-if="showImagePricing"
class="rounded-lg border bg-muted/10 p-3 space-y-3"
>
<div class="flex flex-wrap items-end justify-between gap-3">
<Label class="text-xs font-medium">图像输出矩阵 ($/张)</Label>
<div class="flex items-center gap-2">
@@ -211,6 +214,7 @@ const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
const props = defineProps<{
modelValue?: TieredPricingConfig | null
showCache1h?: boolean
showImagePricing?: boolean
}>()
const emit = defineEmits<{
@@ -481,6 +485,9 @@ defineExpose({
function buildPricingConfig(tiers: PricingTier[]): TieredPricingConfig {
const config: TieredPricingConfig = { tiers }
if (!props.showImagePricing) {
return config
}
const matrix = normalizedImageOutputPrices()
if (Object.keys(matrix).length > 0) {
config.image_output_prices = matrix

View File

@@ -126,6 +126,24 @@
</div>
</div>
<div class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<Checkbox
:model-value="isImageGenerationEnabled"
class="mt-0.5"
@update:model-value="setImageGenerationEnabled"
/>
<div class="space-y-1">
<div class="text-sm font-medium">
图片模型
</div>
<p class="text-xs text-muted-foreground">
启用图片输出计费并展开尺寸 × 质量矩阵价格
</p>
</div>
</div>
</div>
<!-- 价格配置 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm border-b pb-2">
@@ -135,6 +153,7 @@
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="showCache1h"
:show-image-pricing="isImageGenerationEnabled"
/>
<!-- 按次计费 -->
@@ -281,6 +300,7 @@ import {
SelectContent,
SelectItem,
Badge,
Checkbox,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
@@ -322,10 +342,24 @@ const selectedGlobalModel = computed(() => {
})
const selectedGlobalModelSupportsEmbedding = computed(() => modelSupportsEmbedding(selectedGlobalModel.value))
const selectedGlobalModelSupportsImageGeneration = computed(() => modelSupportsImageGeneration(selectedGlobalModel.value))
const editingModelSupportsEmbedding = computed(() => {
return props.editingModel?.effective_supports_embedding === true
|| modelSupportsEmbedding(props.editingModel)
})
const editingModelSupportsImageGeneration = computed(() => {
return props.editingModel?.effective_supports_image_generation === true
|| modelSupportsImageGeneration(props.editingModel)
})
const isImageGenerationEnabled = computed(() => {
if (form.value.supports_image_generation !== undefined) {
return form.value.supports_image_generation === true
}
return isEditing.value
? editingModelSupportsImageGeneration.value
: selectedGlobalModelSupportsImageGeneration.value
})
// 1h 缓存定价始终显示
const showCache1h = true
@@ -506,6 +540,26 @@ function syncManualProviderName(value: string | number) {
}
}
function modelSupportsImageGeneration(model: {
supported_capabilities?: string[] | null
supports_image_generation?: boolean | null
effective_supports_image_generation?: boolean | null
config?: Record<string, unknown> | null
} | null | undefined): boolean {
if (!model) return false
if (model.effective_supports_image_generation === true) return true
if (model.supports_image_generation === true) return true
const config = model.config || {}
return model.supported_capabilities?.includes('image_generation') === true
|| config.image_generation === true
|| config.model_type === 'image'
|| (Array.isArray(config.api_formats) && config.api_formats.some((format) => String(format).endsWith(':image')))
}
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
form.value.supports_image_generation = value === true
}
function getNested(obj: Record<string, unknown>, path: string): unknown {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)

View File

@@ -234,6 +234,9 @@
<template v-if="perRequestCost > 0">
+ 按次费用 <span class="font-medium">${{ perRequestCost.toFixed(6) }}</span>
</template>
<template v-if="imageOutputCostTotal > 0">
+ 图片输出费用 <span class="font-medium">${{ imageOutputCostTotal.toFixed(6) }}</span>
</template>
<template v-if="videoCostTotal > 0">
+ {{ detail.video_billing?.task_type === 'image' ? '图像' : detail.video_billing?.task_type === 'audio' ? '音频' : '视频' }}费用 <span class="font-medium">${{ videoCostTotal.toFixed(6) }}</span>
</template>
@@ -385,7 +388,56 @@
</div>
</div>
<!-- ========== 4. 视频/图像/音频计费独立隔离与Token计费风格一致 ========== -->
<!-- ========== 4. 图片输出计费 ========== -->
<div
v-if="hasImageBillingDetail"
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30 mb-3"
>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 text-xs">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-primary">图片输出</span>
<Badge
variant="outline"
class="text-[10px] px-1.5 py-0 h-4"
>
{{ imageOutputBillingLabel }}
</Badge>
<span
v-if="imageOutputMatrixEnabled && imagePriceKey"
class="text-muted-foreground font-mono"
>{{ imagePriceKey }}</span>
<span
v-else-if="imageOutputSize || imageOutputQuality"
class="text-muted-foreground font-mono"
>{{ [imageOutputSize, imageOutputQuality].filter(Boolean).join(' / ') }}</span>
</div>
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
<span
v-if="imageOutputPricePerImage !== null"
class="font-mono"
>{{ formatNumber(imageOutputCount) }} × ${{ imageOutputPricePerImage.toFixed(6) }}/ = ${{ imageOutputCostTotal.toFixed(6) }}</span>
</div>
</div>
<div class="flex items-center">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">数量</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ formatNumber(imageOutputCount) }}</span>
<span class="text-xs font-mono">${{ imageOutputCostTotal.toFixed(6) }}</span>
</div>
<Separator
orientation="vertical"
class="h-4 mx-4"
/>
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">格式</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ imageOutputFormat || '-' }}</span>
<span class="text-xs font-mono text-muted-foreground">{{ imageOutputBillingLabel }}</span>
</div>
</div>
</div>
<!-- ========== 5. 视频/图像/音频计费独立隔离与Token计费风格一致 ========== -->
<div
v-if="detail.video_billing"
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30"
@@ -901,6 +953,11 @@ function getNestedNumber(record: JsonRecord | null, ...path: string[]): number |
return toNumber(getNestedValue(record, ...path))
}
function getNestedString(record: JsonRecord | null, ...path: string[]): string | null {
const value = getNestedValue(record, ...path)
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function normalizeCacheTtlPricing(value: unknown): CacheTTLPriceEntry[] {
if (!Array.isArray(value)) return []
return value
@@ -1036,6 +1093,10 @@ const billingResolvedVariables = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.resolved_variables),
)
const billingResolvedDimensions = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.resolved_dimensions),
)
const billingCostBreakdown = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.cost_breakdown),
)
@@ -1377,6 +1438,74 @@ const effectiveRequestCost = computed(() => {
return 0
})
const effectiveImageOutputCost = computed(() =>
getNestedNumber(billingCostBreakdown.value, 'image_output_cost')
?? toNumber(detail.value?.image_output_cost)
?? 0,
)
const imageOutputCostTotal = computed(() => effectiveImageOutputCost.value)
const imageOutputPricePerImage = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'image_output_price_per_image'),
)
const imageOutputCount = computed(() =>
getNestedNumber(billingResolvedDimensions.value, 'image_count')
?? getNestedNumber(traceRequestMetadata.value, 'billing_dimensions', 'image_count')
?? getNestedNumber(traceRequestMetadata.value, 'dimensions', 'image_count')
?? 0,
)
const imageOutputSize = computed(() =>
getNestedString(billingResolvedDimensions.value, 'image_size')
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_size')
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_size'),
)
const imageOutputQuality = computed(() =>
getNestedString(billingResolvedDimensions.value, 'image_quality')
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_quality')
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_quality'),
)
const imageOutputFormat = computed(() =>
getNestedString(billingResolvedDimensions.value, 'image_output_format')
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_output_format')
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_output_format'),
)
const imagePriceKey = computed(() => {
const snapshotKey = getNestedString(billingResolvedDimensions.value, 'image_price_key')
if (snapshotKey) return snapshotKey
const fallbackKey = [imageOutputSize.value, imageOutputQuality.value].filter(Boolean).join(':')
return fallbackKey || null
})
const imageOutputPricingMode = computed(() =>
getNestedString(billingResolvedDimensions.value, 'image_output_pricing_mode'),
)
const imageOutputPricingEnabled = computed(() =>
getNestedValue(billingResolvedDimensions.value, 'image_output_pricing_enabled') === true
|| imageOutputPricingMode.value === 'matrix'
|| imageOutputPricingMode.value === 'per_image'
|| imageOutputCostTotal.value > 0,
)
const imageOutputMatrixEnabled = computed(() =>
getNestedValue(billingResolvedDimensions.value, 'image_output_matrix_enabled') === true
|| imageOutputPricingMode.value === 'matrix',
)
const imageOutputBillingLabel = computed(() =>
imageOutputMatrixEnabled.value ? '矩阵计费' : '默认计费',
)
const hasImageBillingDetail = computed(() =>
imageOutputPricingEnabled.value && (imageOutputCount.value > 0 || imageOutputCostTotal.value > 0),
)
const fallbackCacheTtlPricing = computed<CacheTTLPriceEntry[]>(() => {
const tierPricing = normalizeCacheTtlPricing(billingTierInfo.value?.cache_ttl_pricing)
if (tierPricing.length > 0) return tierPricing