mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge remote-tracking branch 'origin/pr-483' into merge-pr-483
# Conflicts: # apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/payload.rs # apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs # apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/payload.rs # apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs # apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs
This commit is contained in:
@@ -196,6 +196,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
|
||||
|
||||
@@ -18,9 +18,20 @@ export interface PricingTier {
|
||||
cache_ttl_pricing?: CacheTTLPricing[]
|
||||
}
|
||||
|
||||
export type ImageOutputQuality = 'low' | 'medium' | 'high'
|
||||
|
||||
export interface ImageOutputPriceRange {
|
||||
up_to_pixels: number | null
|
||||
prices: Partial<Record<ImageOutputQuality, number>>
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
/** 阶梯计费配置 */
|
||||
export interface TieredPricingConfig {
|
||||
tiers: PricingTier[]
|
||||
image_output_prices?: Record<string, Record<string, number>> | null
|
||||
image_output_price_default?: number | null
|
||||
image_output_price_ranges?: ImageOutputPriceRange[] | null
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
:class="checkboxClass"
|
||||
:checked="isChecked"
|
||||
v-bind="$attrs"
|
||||
:checked="isChecked"
|
||||
@change="handleChange"
|
||||
>
|
||||
</template>
|
||||
|
||||
@@ -230,6 +230,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 border-t border-border/60 pt-3">
|
||||
<Checkbox
|
||||
:checked="isImageGenerationEnabled"
|
||||
class="mt-0.5"
|
||||
@update:checked="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>
|
||||
|
||||
@@ -242,6 +257,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>
|
||||
@@ -575,6 +591,7 @@ const defaultForm = (): FormData => ({
|
||||
})
|
||||
|
||||
const form = ref<FormData>(defaultForm())
|
||||
const imageGenerationExplicitOverride = ref<boolean | null>(null)
|
||||
|
||||
const isEmbeddingEnabled = computed(() => {
|
||||
return form.value.supported_capabilities?.includes('embedding') === true
|
||||
@@ -582,6 +599,18 @@ const isEmbeddingEnabled = computed(() => {
|
||||
|| form.value.config?.model_type === 'embedding'
|
||||
})
|
||||
|
||||
const isImageGenerationEnabled = computed(() => {
|
||||
if (imageGenerationExplicitOverride.value !== null) {
|
||||
return imageGenerationExplicitOverride.value
|
||||
}
|
||||
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')))
|
||||
|| tieredPricingHasImageOutputPricing(tieredPricing.value)
|
||||
})
|
||||
|
||||
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
|
||||
|
||||
// 设置 config 字段
|
||||
@@ -624,6 +653,21 @@ function setEmbeddingEnabled(enabled: boolean) {
|
||||
form.value.supported_capabilities = [...caps]
|
||||
}
|
||||
|
||||
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
|
||||
const enabled = value === true
|
||||
imageGenerationExplicitOverride.value = enabled
|
||||
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)
|
||||
@@ -781,6 +825,7 @@ watch(() => props.open, (isOpen) => {
|
||||
|
||||
// 选择模型并填充表单
|
||||
function selectModel(model: ModelsDevModelItem) {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
manualModelMode.value = false
|
||||
selectedModel.value = model
|
||||
expandedProvider.value = model.providerId
|
||||
@@ -806,7 +851,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)
|
||||
}
|
||||
@@ -827,6 +875,7 @@ function selectModel(model: ModelsDevModelItem) {
|
||||
|
||||
// 清除选择(手动填写)
|
||||
function clearSelection() {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
manualModelMode.value = false
|
||||
selectedModel.value = null
|
||||
form.value = defaultForm()
|
||||
@@ -841,6 +890,7 @@ function handleLogoError(event: Event) {
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
form.value = defaultForm()
|
||||
tieredPricing.value = null
|
||||
videoResolutionPrices.value = []
|
||||
@@ -854,23 +904,30 @@ function resetForm() {
|
||||
// 加载模型数据(编辑模式)
|
||||
function loadModelData() {
|
||||
if (!props.model) return
|
||||
imageGenerationExplicitOverride.value = null
|
||||
// 先重置创建模式的残留状态
|
||||
selectedModel.value = null
|
||||
searchQuery.value = ''
|
||||
expandedProvider.value = null
|
||||
|
||||
const modelTieredPricing = props.model.default_tiered_pricing
|
||||
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
|
||||
: null
|
||||
const supportedCapabilities = new Set(props.model.supported_capabilities || [])
|
||||
if (tieredPricingHasImageOutputPricing(modelTieredPricing)) {
|
||||
supportedCapabilities.add('image_generation')
|
||||
}
|
||||
|
||||
form.value = {
|
||||
name: props.model.name,
|
||||
display_name: props.model.display_name,
|
||||
default_price_per_request: props.model.default_price_per_request,
|
||||
supported_capabilities: [...(props.model.supported_capabilities || [])],
|
||||
supported_capabilities: [...supportedCapabilities],
|
||||
config: props.model.config ? { ...props.model.config } : { streaming: true },
|
||||
is_active: props.model.is_active,
|
||||
}
|
||||
// 确保 tieredPricing 也被正确设置或重置
|
||||
tieredPricing.value = props.model.default_tiered_pricing
|
||||
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
|
||||
: null
|
||||
tieredPricing.value = modelTieredPricing
|
||||
loadVideoPricingFromConfig()
|
||||
}
|
||||
|
||||
@@ -898,8 +955,7 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
|
||||
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
|
||||
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
|
||||
|
||||
if (!finalTieredPricing?.tiers?.length) {
|
||||
showError('请配置至少一个价格阶梯')
|
||||
@@ -920,6 +976,9 @@ async function handleSubmit() {
|
||||
} else {
|
||||
caps.delete('cache_1h')
|
||||
}
|
||||
if (tieredPricingHasImageOutputPricing(finalTieredPricing)) {
|
||||
caps.add('image_generation')
|
||||
}
|
||||
form.value.supported_capabilities = caps.size > 0 ? [...caps] : []
|
||||
|
||||
// 清理空的 config
|
||||
@@ -949,4 +1008,29 @@ async function handleSubmit() {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
|
||||
if (!pricing) return false
|
||||
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
|
||||
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
|
||||
if (!prices || typeof prices !== 'object') return false
|
||||
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
||||
})) return true
|
||||
return (pricing.image_output_price_ranges || []).some((range) => {
|
||||
if (!range || typeof range !== 'object') return false
|
||||
const prices = range.prices && typeof range.prices === 'object'
|
||||
? range.prices
|
||||
: range as Record<string, unknown>
|
||||
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
||||
})
|
||||
}
|
||||
|
||||
function toFinitePrice(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 默认定价 -->
|
||||
@@ -145,6 +146,114 @@
|
||||
默认定价
|
||||
</h4>
|
||||
|
||||
<!-- 图片输出计费 -->
|
||||
<div
|
||||
v-if="hasImagePricing"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>图片输出计费</span>
|
||||
<Badge
|
||||
v-if="imagePricingEntries.length > 0"
|
||||
variant="outline"
|
||||
class="text-[10px] h-5 px-1.5"
|
||||
>
|
||||
矩阵
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="imagePriceRangeEntries.length > 0"
|
||||
variant="outline"
|
||||
class="text-[10px] h-5 px-1.5"
|
||||
>
|
||||
区间
|
||||
</Badge>
|
||||
</div>
|
||||
<span
|
||||
v-if="imageOutputDefaultPrice !== null"
|
||||
class="text-xs font-mono"
|
||||
>默认 ${{ imageOutputDefaultPrice.toFixed(6) }}/张</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="imagePricingEntries.length > 0"
|
||||
class="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-muted/30">
|
||||
<TableHead class="text-xs h-9">
|
||||
分辨率
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||
:key="quality"
|
||||
class="text-xs h-9 text-right"
|
||||
>
|
||||
{{ quality }}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="entry in imagePricingEntries"
|
||||
:key="entry.size"
|
||||
class="text-xs"
|
||||
>
|
||||
<TableCell class="py-2 font-mono">
|
||||
{{ formatImageSize(entry.size) }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||
:key="`${entry.size}-${quality}`"
|
||||
class="py-2 text-right font-mono"
|
||||
>
|
||||
{{ formatImagePrice(entry.prices[quality]) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div
|
||||
v-if="imagePriceRangeEntries.length > 0"
|
||||
class="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-muted/30">
|
||||
<TableHead class="text-xs h-9">
|
||||
上限像素
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||
:key="quality"
|
||||
class="text-xs h-9 text-right"
|
||||
>
|
||||
{{ quality }}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="entry in imagePriceRangeEntries"
|
||||
:key="entry.key"
|
||||
class="text-xs"
|
||||
>
|
||||
<TableCell class="py-2 font-mono">
|
||||
{{ formatPixelLimit(entry.upToPixels) }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||
:key="`${entry.key}-${quality}`"
|
||||
class="py-2 text-right font-mono"
|
||||
>
|
||||
{{ formatImagePrice(entry.prices[quality]) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单阶梯(固定价格)展示 -->
|
||||
<div
|
||||
v-if="getTierCount(model.default_tiered_pricing) <= 1"
|
||||
@@ -561,6 +670,79 @@ const videoPricingEntries = computed(() => {
|
||||
return sortResolutionEntries(Object.entries(priceByResolution))
|
||||
})
|
||||
|
||||
const IMAGE_OUTPUT_QUALITIES = ['low', 'medium', 'high'] as const
|
||||
|
||||
const imageOutputDefaultPrice = computed(() => {
|
||||
const value = props.model?.default_tiered_pricing?.image_output_price_default
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
})
|
||||
|
||||
const imagePricingEntries = computed(() => {
|
||||
const prices = props.model?.default_tiered_pricing?.image_output_prices
|
||||
if (!prices || typeof prices !== 'object') return []
|
||||
return sortResolutionEntries(Object.entries(prices)).map(([size, qualityPrices]) => ({
|
||||
size,
|
||||
prices: normalizeImageQualityPrices(qualityPrices),
|
||||
})).filter(entry => Object.values(entry.prices).some(price => price !== null))
|
||||
})
|
||||
|
||||
const imagePriceRangeEntries = computed(() => {
|
||||
const ranges = props.model?.default_tiered_pricing?.image_output_price_ranges
|
||||
if (!Array.isArray(ranges)) return []
|
||||
return ranges.map((range, index) => {
|
||||
const object = range && typeof range === 'object' ? range as Record<string, unknown> : {}
|
||||
const rawPrices = object.prices && typeof object.prices === 'object'
|
||||
? object.prices
|
||||
: object
|
||||
return {
|
||||
key: `${object.up_to_pixels ?? 'unbounded'}-${index}`,
|
||||
upToPixels: toFiniteNumber(object.up_to_pixels),
|
||||
prices: normalizeImageQualityPrices(rawPrices),
|
||||
}
|
||||
}).filter(entry => Object.values(entry.prices).some(price => price !== null))
|
||||
})
|
||||
|
||||
const hasImagePricing = computed(() =>
|
||||
imageOutputDefaultPrice.value !== null
|
||||
|| imagePricingEntries.value.length > 0
|
||||
|| imagePriceRangeEntries.value.length > 0,
|
||||
)
|
||||
|
||||
function normalizeImageQualityPrices(value: unknown): Record<typeof IMAGE_OUTPUT_QUALITIES[number], number | null> {
|
||||
const object = value && typeof value === 'object' ? value as Record<string, unknown> : {}
|
||||
return {
|
||||
low: toFiniteNumber(object.low),
|
||||
medium: toFiniteNumber(object.medium),
|
||||
high: toFiniteNumber(object.high),
|
||||
}
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function formatImagePrice(value: number | null): string {
|
||||
return value === null ? '-' : `$${value.toFixed(6)}`
|
||||
}
|
||||
|
||||
function formatImageSize(value: string): string {
|
||||
return value.replace(/\s*[xX×]\s*/g, ' x ')
|
||||
}
|
||||
|
||||
function formatPixelLimit(value: number | null): string {
|
||||
return value === null ? '无上限' : `<= ${formatPixels(value)}`
|
||||
}
|
||||
|
||||
function formatPixels(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 2)}M px`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(0)}K px`
|
||||
}
|
||||
return `${value} px`
|
||||
}
|
||||
|
||||
const detailTab = ref('basic')
|
||||
|
||||
// 处理背景点击
|
||||
|
||||
@@ -784,7 +784,9 @@ function targetFormatsForEndpoint(
|
||||
provider: RoutingProviderInfo,
|
||||
endpoint: RoutingEndpointInfo
|
||||
): string[] {
|
||||
return STANDARD_ROUTING_API_FORMATS.filter(format =>
|
||||
const endpointFormat = normalizeLegacyOpenAIFormatAlias(endpoint.api_format)
|
||||
const candidateFormats = Array.from(new Set([...STANDARD_ROUTING_API_FORMATS, endpointFormat]))
|
||||
return candidateFormats.filter(format =>
|
||||
endpointSupportsClientFormat(provider, endpoint, format, endpoint.api_format)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,141 @@
|
||||
添加价格阶梯
|
||||
</Button>
|
||||
|
||||
<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">
|
||||
<Label class="text-xs text-muted-foreground">默认价</Label>
|
||||
<Input
|
||||
:model-value="imageOutputPriceDefault"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="h-8 w-24"
|
||||
placeholder="0"
|
||||
@update:model-value="updateImageOutputPriceDefault"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs text-muted-foreground">精确分辨率覆盖</Label>
|
||||
<span class="text-[11px] text-muted-foreground">优先匹配 size + quality</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 text-xs text-muted-foreground">
|
||||
<span>分辨率</span>
|
||||
<span>low</span>
|
||||
<span>medium</span>
|
||||
<span>high</span>
|
||||
<span />
|
||||
</div>
|
||||
<div
|
||||
v-for="row in imageOutputPriceRows"
|
||||
:key="row.id"
|
||||
class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 items-center"
|
||||
>
|
||||
<Input
|
||||
:model-value="row.size"
|
||||
class="h-8 font-mono text-xs"
|
||||
placeholder="1024x1024"
|
||||
@update:model-value="(v) => updateImageOutputSize(row.id, v)"
|
||||
/>
|
||||
<Input
|
||||
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||
:key="`${row.id}-${quality}`"
|
||||
:model-value="getImageOutputPrice(row, quality)"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateImageOutputPrice(row.id, quality, v)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
@click="removeImageOutputSizeRow(row.id)"
|
||||
>
|
||||
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="addImageOutputSizeRow"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
添加分辨率
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 border-t pt-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs text-muted-foreground">像素区间</Label>
|
||||
<span class="text-[11px] text-muted-foreground">矩阵未命中时按宽×高落档</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 text-xs text-muted-foreground">
|
||||
<span>上限像素</span>
|
||||
<span>low</span>
|
||||
<span>medium</span>
|
||||
<span>high</span>
|
||||
<span />
|
||||
</div>
|
||||
<div
|
||||
v-for="row in imageOutputPriceRangeRows"
|
||||
:key="row.id"
|
||||
class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 items-center"
|
||||
>
|
||||
<Input
|
||||
:model-value="row.upToPixels"
|
||||
type="number"
|
||||
min="1"
|
||||
class="h-8 font-mono text-xs"
|
||||
placeholder="空=无上限"
|
||||
@update:model-value="(v) => updateImageOutputRangeLimit(row.id, v)"
|
||||
/>
|
||||
<Input
|
||||
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||
:key="`${row.id}-${quality}`"
|
||||
:model-value="getImageOutputRangePrice(row, quality)"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateImageOutputRangePrice(row.id, quality, v)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
@click="removeImageOutputRangeRow(row.id)"
|
||||
>
|
||||
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="addImageOutputRangeRow"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
添加像素区间
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证提示 -->
|
||||
<p
|
||||
v-if="validationError"
|
||||
@@ -151,11 +286,28 @@
|
||||
import { ref, computed, watch, reactive } from 'vue'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { Button, Input, Label } from '@/components/ui'
|
||||
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
|
||||
import type { TieredPricingConfig, PricingTier, ImageOutputPriceRange } from '@/api/endpoints/types'
|
||||
|
||||
type ImageOutputQuality = 'low' | 'medium' | 'high'
|
||||
type ImageOutputPriceRow = {
|
||||
id: string
|
||||
size: string
|
||||
prices: Partial<Record<ImageOutputQuality, number>>
|
||||
}
|
||||
type ImageOutputPriceRangeRow = {
|
||||
id: string
|
||||
upToPixels: string
|
||||
prices: Partial<Record<ImageOutputQuality, number>>
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_OUTPUT_SIZES = ['1024x1024', '1536x1024', '1024x1536']
|
||||
const DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS = [1_048_576, 1_572_864, 2_097_152]
|
||||
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: TieredPricingConfig | null
|
||||
showCache1h?: boolean
|
||||
showImagePricing?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -164,6 +316,12 @@ const emit = defineEmits<{
|
||||
|
||||
// 本地状态
|
||||
const localTiers = ref<PricingTier[]>([])
|
||||
const imageOutputPriceRows = ref<ImageOutputPriceRow[]>([])
|
||||
const imageOutputPriceRangeRows = ref<ImageOutputPriceRangeRow[]>([])
|
||||
const imageOutputPriceDefault = ref<string>('')
|
||||
const lastEmittedPricingJson = ref<string>('')
|
||||
let imageOutputPriceRowId = 0
|
||||
let imageOutputPriceRangeRowId = 0
|
||||
|
||||
// 跟踪每个阶梯的缓存价格是否被手动设置
|
||||
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
|
||||
@@ -186,8 +344,16 @@ const customInputValue = reactive<Record<number, string>>({})
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (lastEmittedPricingJson.value && JSON.stringify(newValue ?? null) === lastEmittedPricingJson.value) {
|
||||
return
|
||||
}
|
||||
if (newValue?.tiers) {
|
||||
localTiers.value = newValue.tiers.map(t => ({ ...t }))
|
||||
imageOutputPriceRows.value = createImageOutputPriceRows(newValue.image_output_prices)
|
||||
imageOutputPriceRangeRows.value = createImageOutputPriceRangeRows(newValue.image_output_price_ranges)
|
||||
imageOutputPriceDefault.value = newValue.image_output_price_default != null
|
||||
? String(newValue.image_output_price_default)
|
||||
: ''
|
||||
// 如果已有缓存价格,标记为手动设置
|
||||
newValue.tiers.forEach((t, i) => {
|
||||
const has1hCache = t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60) ?? false
|
||||
@@ -203,6 +369,9 @@ watch(
|
||||
input_price_per_1m: 0,
|
||||
output_price_per_1m: 0,
|
||||
}]
|
||||
imageOutputPriceRows.value = createImageOutputPriceRows(null)
|
||||
imageOutputPriceRangeRows.value = createImageOutputPriceRangeRows(null)
|
||||
imageOutputPriceDefault.value = ''
|
||||
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
|
||||
}
|
||||
},
|
||||
@@ -367,7 +536,9 @@ function syncToParent() {
|
||||
return tier
|
||||
})
|
||||
|
||||
emit('update:modelValue', { tiers })
|
||||
const value = buildPricingConfig(tiers)
|
||||
lastEmittedPricingJson.value = JSON.stringify(value ?? null)
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// 获取最终提交的数据(包含自动计算的缓存价格)
|
||||
@@ -406,11 +577,239 @@ function getFinalTiers(): PricingTier[] {
|
||||
})
|
||||
}
|
||||
|
||||
function getFinalPricing(): TieredPricingConfig {
|
||||
return buildPricingConfig(getFinalTiers())
|
||||
}
|
||||
|
||||
// 暴露给父组件调用
|
||||
defineExpose({
|
||||
getFinalTiers,
|
||||
getFinalPricing,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
const ranges = normalizedImageOutputPriceRanges()
|
||||
if (ranges.length > 0) {
|
||||
config.image_output_price_ranges = ranges
|
||||
}
|
||||
const defaultPrice = parseOptionalFloat(imageOutputPriceDefault.value)
|
||||
if (defaultPrice != null) {
|
||||
config.image_output_price_default = defaultPrice
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function createImageOutputPriceRows(value: TieredPricingConfig['image_output_prices']): ImageOutputPriceRow[] {
|
||||
const rows: ImageOutputPriceRow[] = []
|
||||
if (!value || typeof value !== 'object') {
|
||||
return DEFAULT_IMAGE_OUTPUT_SIZES.map(size => createImageOutputPriceRow(size))
|
||||
}
|
||||
for (const [size, prices] of Object.entries(value)) {
|
||||
if (!prices || typeof prices !== 'object') continue
|
||||
const rowPrices: Partial<Record<ImageOutputQuality, number>> = {}
|
||||
for (const quality of IMAGE_OUTPUT_QUALITIES) {
|
||||
const price = (prices as Record<string, unknown>)[quality]
|
||||
if (typeof price === 'number' && Number.isFinite(price)) {
|
||||
rowPrices[quality] = price
|
||||
}
|
||||
}
|
||||
rows.push(createImageOutputPriceRow(size, rowPrices))
|
||||
}
|
||||
if (rows.length > 0) return rows
|
||||
return DEFAULT_IMAGE_OUTPUT_SIZES.map(size => createImageOutputPriceRow(size))
|
||||
}
|
||||
|
||||
function createImageOutputPriceRangeRows(value: TieredPricingConfig['image_output_price_ranges']): ImageOutputPriceRangeRow[] {
|
||||
const rows: ImageOutputPriceRangeRow[] = []
|
||||
if (!Array.isArray(value)) {
|
||||
return rows
|
||||
}
|
||||
for (const range of value) {
|
||||
if (!range || typeof range !== 'object') continue
|
||||
const rowPrices: Partial<Record<ImageOutputQuality, number>> = {}
|
||||
const rawPrices = 'prices' in range && range.prices && typeof range.prices === 'object'
|
||||
? range.prices as Record<string, unknown>
|
||||
: range as Record<string, unknown>
|
||||
for (const quality of IMAGE_OUTPUT_QUALITIES) {
|
||||
const price = rawPrices[quality]
|
||||
if (typeof price === 'number' && Number.isFinite(price)) {
|
||||
rowPrices[quality] = price
|
||||
}
|
||||
}
|
||||
const upToPixels = 'up_to_pixels' in range && range.up_to_pixels != null
|
||||
? String(range.up_to_pixels)
|
||||
: ''
|
||||
rows.push(createImageOutputPriceRangeRow(upToPixels, rowPrices))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function createImageOutputPriceRow(
|
||||
size = '',
|
||||
prices: Partial<Record<ImageOutputQuality, number>> = {},
|
||||
): ImageOutputPriceRow {
|
||||
imageOutputPriceRowId += 1
|
||||
return {
|
||||
id: `image-output-size-${imageOutputPriceRowId}`,
|
||||
size,
|
||||
prices: { ...prices },
|
||||
}
|
||||
}
|
||||
|
||||
function createImageOutputPriceRangeRow(
|
||||
upToPixels = '',
|
||||
prices: Partial<Record<ImageOutputQuality, number>> = {},
|
||||
): ImageOutputPriceRangeRow {
|
||||
imageOutputPriceRangeRowId += 1
|
||||
return {
|
||||
id: `image-output-range-${imageOutputPriceRangeRowId}`,
|
||||
upToPixels,
|
||||
prices: { ...prices },
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedImageOutputPrices(): Record<string, Record<string, number>> {
|
||||
const out: Record<string, Record<string, number>> = {}
|
||||
for (const row of imageOutputPriceRows.value) {
|
||||
const size = normalizeImageOutputSize(row.size)
|
||||
if (!size) continue
|
||||
for (const quality of IMAGE_OUTPUT_QUALITIES) {
|
||||
const price = row.prices[quality]
|
||||
if (price != null && Number.isFinite(price)) {
|
||||
out[size] = { ...(out[size] || {}), [quality]: price }
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizedImageOutputPriceRanges(): ImageOutputPriceRange[] {
|
||||
const ranges: ImageOutputPriceRange[] = []
|
||||
for (const row of imageOutputPriceRangeRows.value) {
|
||||
const prices: Partial<Record<ImageOutputQuality, number>> = {}
|
||||
for (const quality of IMAGE_OUTPUT_QUALITIES) {
|
||||
const price = row.prices[quality]
|
||||
if (price != null && Number.isFinite(price)) {
|
||||
prices[quality] = price
|
||||
}
|
||||
}
|
||||
if (Object.keys(prices).length === 0) continue
|
||||
ranges.push({
|
||||
up_to_pixels: parseOptionalInteger(row.upToPixels),
|
||||
prices,
|
||||
})
|
||||
}
|
||||
return ranges.sort((a, b) => {
|
||||
if (a.up_to_pixels == null && b.up_to_pixels == null) return 0
|
||||
if (a.up_to_pixels == null) return 1
|
||||
if (b.up_to_pixels == null) return -1
|
||||
return a.up_to_pixels - b.up_to_pixels
|
||||
})
|
||||
}
|
||||
|
||||
function parseOptionalFloat(value: string | number): number | null {
|
||||
if (value === '' || value === null || value === undefined) return null
|
||||
const number = typeof value === 'string' ? parseFloat(value) : value
|
||||
return Number.isFinite(number) ? number : null
|
||||
}
|
||||
|
||||
function parseOptionalInteger(value: string | number): number | null {
|
||||
if (value === '' || value === null || value === undefined) return null
|
||||
const number = typeof value === 'string' ? parseInt(value, 10) : value
|
||||
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : null
|
||||
}
|
||||
|
||||
function normalizeImageOutputSize(size: string): string {
|
||||
return String(size || '').trim().replace(/\s*[xX×]\s*/g, 'x')
|
||||
}
|
||||
|
||||
function getImageOutputPrice(row: ImageOutputPriceRow, quality: ImageOutputQuality): string | number {
|
||||
return row.prices[quality] ?? ''
|
||||
}
|
||||
|
||||
function getImageOutputRangePrice(row: ImageOutputPriceRangeRow, quality: ImageOutputQuality): string | number {
|
||||
return row.prices[quality] ?? ''
|
||||
}
|
||||
|
||||
function updateImageOutputSize(rowId: string, value: string | number) {
|
||||
const row = imageOutputPriceRows.value.find(item => item.id === rowId)
|
||||
if (!row) return
|
||||
row.size = normalizeImageOutputSize(String(value ?? ''))
|
||||
imageOutputPriceRows.value = [...imageOutputPriceRows.value]
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateImageOutputPrice(rowId: string, quality: ImageOutputQuality, value: string | number) {
|
||||
const row = imageOutputPriceRows.value.find(item => item.id === rowId)
|
||||
if (!row) return
|
||||
const price = parseOptionalFloat(value)
|
||||
if (price == null) {
|
||||
delete row.prices[quality]
|
||||
} else {
|
||||
row.prices[quality] = price
|
||||
}
|
||||
imageOutputPriceRows.value = [...imageOutputPriceRows.value]
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function addImageOutputSizeRow() {
|
||||
const usedSizes = new Set(imageOutputPriceRows.value.map(row => normalizeImageOutputSize(row.size)).filter(Boolean))
|
||||
const suggestedSize = DEFAULT_IMAGE_OUTPUT_SIZES.find(size => !usedSizes.has(size)) || ''
|
||||
imageOutputPriceRows.value = [...imageOutputPriceRows.value, createImageOutputPriceRow(suggestedSize)]
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function removeImageOutputSizeRow(rowId: string) {
|
||||
imageOutputPriceRows.value = imageOutputPriceRows.value.filter(row => row.id !== rowId)
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateImageOutputRangeLimit(rowId: string, value: string | number) {
|
||||
const row = imageOutputPriceRangeRows.value.find(item => item.id === rowId)
|
||||
if (!row) return
|
||||
row.upToPixels = String(value ?? '')
|
||||
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value]
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateImageOutputRangePrice(rowId: string, quality: ImageOutputQuality, value: string | number) {
|
||||
const row = imageOutputPriceRangeRows.value.find(item => item.id === rowId)
|
||||
if (!row) return
|
||||
const price = parseOptionalFloat(value)
|
||||
if (price == null) {
|
||||
delete row.prices[quality]
|
||||
} else {
|
||||
row.prices[quality] = price
|
||||
}
|
||||
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value]
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function addImageOutputRangeRow() {
|
||||
const usedLimits = new Set(imageOutputPriceRangeRows.value.map(row => parseOptionalInteger(row.upToPixels)).filter((value): value is number => value !== null))
|
||||
const suggestedLimit = DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS.find(limit => !usedLimits.has(limit))
|
||||
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value, createImageOutputPriceRangeRow(suggestedLimit ? String(suggestedLimit) : '')]
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function removeImageOutputRangeRow(rowId: string) {
|
||||
imageOutputPriceRangeRows.value = imageOutputPriceRangeRows.value.filter(row => row.id !== rowId)
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateImageOutputPriceDefault(value: string | number) {
|
||||
imageOutputPriceDefault.value = String(value ?? '')
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function parseFloatInput(value: string | number): number {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value
|
||||
return isNaN(num) ? 0 : num
|
||||
|
||||
@@ -94,6 +94,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
|
||||
:checked="isImageGenerationEnabled"
|
||||
class="mt-0.5"
|
||||
@update:checked="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">
|
||||
@@ -103,6 +121,7 @@
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
:show-cache1h="showCache1h"
|
||||
:show-image-pricing="isImageGenerationEnabled"
|
||||
/>
|
||||
|
||||
<!-- 按次计费 -->
|
||||
@@ -249,6 +268,7 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Badge,
|
||||
Checkbox,
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||
@@ -290,10 +310,28 @@ 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 (imageGenerationExplicitOverride.value !== null) {
|
||||
return imageGenerationExplicitOverride.value
|
||||
}
|
||||
if (form.value.supports_image_generation !== undefined) {
|
||||
return form.value.supports_image_generation === true
|
||||
}
|
||||
const supportsImageGeneration = isEditing.value
|
||||
? editingModelSupportsImageGeneration.value
|
||||
: selectedGlobalModelSupportsImageGeneration.value
|
||||
return supportsImageGeneration || tieredPricingHasImageOutputPricing(tieredPricing.value)
|
||||
})
|
||||
|
||||
// 1h 缓存定价始终显示
|
||||
const showCache1h = true
|
||||
@@ -349,6 +387,7 @@ const form = ref({
|
||||
supports_image_generation: undefined as boolean | undefined,
|
||||
is_active: true
|
||||
})
|
||||
const imageGenerationExplicitOverride = ref<boolean | null>(null)
|
||||
|
||||
const canSubmitCreate = computed(() => {
|
||||
if (isEditing.value) return true
|
||||
@@ -364,6 +403,7 @@ 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 || '',
|
||||
@@ -374,7 +414,7 @@ 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: props.editingModel.supports_image_generation ?? undefined,
|
||||
supports_image_generation: supportsImageGeneration ? true : props.editingModel.supports_image_generation ?? undefined,
|
||||
is_active: props.editingModel.is_active
|
||||
}
|
||||
// 从有效配置中加载视频费用
|
||||
@@ -425,6 +465,7 @@ watch(tieredPricing, (newValue) => {
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
form.value = {
|
||||
global_model_id: '',
|
||||
provider_model_name: '',
|
||||
@@ -446,11 +487,66 @@ function resetForm() {
|
||||
}
|
||||
|
||||
function handleGlobalModelSelect(value: string) {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
form.value.supports_image_generation = undefined
|
||||
form.value.global_model_id = value
|
||||
const selectedModel = availableGlobalModels.value.find(model => model.id === value)
|
||||
form.value.provider_model_name = selectedModel?.name || form.value.provider_model_name
|
||||
}
|
||||
|
||||
function modelSupportsImageGeneration(model: {
|
||||
supported_capabilities?: string[] | null
|
||||
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
|
||||
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')))
|
||||
|| tieredPricingHasImageOutputPricing(model.default_tiered_pricing)
|
||||
|| tieredPricingHasImageOutputPricing(model.tiered_pricing)
|
||||
|| tieredPricingHasImageOutputPricing(model.effective_tiered_pricing)
|
||||
}
|
||||
|
||||
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
|
||||
if (!pricing) return false
|
||||
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
|
||||
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
|
||||
if (!prices || typeof prices !== 'object') return false
|
||||
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
||||
})) return true
|
||||
return (pricing.image_output_price_ranges || []).some((range) => {
|
||||
if (!range || typeof range !== 'object') return false
|
||||
const prices = range.prices && typeof range.prices === 'object'
|
||||
? range.prices
|
||||
: range as Record<string, unknown>
|
||||
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
||||
})
|
||||
}
|
||||
|
||||
function toFinitePrice(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
|
||||
const enabled = value === true
|
||||
imageGenerationExplicitOverride.value = enabled
|
||||
form.value.supports_image_generation = enabled
|
||||
}
|
||||
|
||||
function getNested(obj: Record<string, unknown>, path: string): unknown {
|
||||
if (!obj || typeof obj !== 'object') return undefined
|
||||
const parts = path.split('.').filter(Boolean)
|
||||
@@ -631,8 +727,9 @@ async function handleSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
// 获取包含自动计算缓存价格的最终数据
|
||||
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
|
||||
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
|
||||
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
|
||||
const supportsImageGeneration = isImageGenerationEnabled.value
|
||||
|| tieredPricingHasImageOutputPricing(finalTieredPricing)
|
||||
|
||||
// Apply billing (video) pricing into config.
|
||||
applyVideoPricingToConfig(form.value.config)
|
||||
@@ -651,7 +748,7 @@ async function handleSubmit() {
|
||||
supportsFunctionCalling: form.value.supports_function_calling,
|
||||
supportsStreaming: form.value.supports_streaming,
|
||||
supportsExtendedThinking: form.value.supports_extended_thinking,
|
||||
supportsImageGeneration: form.value.supports_image_generation,
|
||||
supportsImageGeneration,
|
||||
isActive: form.value.is_active
|
||||
}))
|
||||
showSuccess('模型配置已更新')
|
||||
@@ -674,7 +771,7 @@ async function handleSubmit() {
|
||||
supportsFunctionCalling: form.value.supports_function_calling,
|
||||
supportsStreaming: form.value.supports_streaming,
|
||||
supportsExtendedThinking: form.value.supports_extended_thinking,
|
||||
supportsImageGeneration: form.value.supports_image_generation,
|
||||
supportsImageGeneration,
|
||||
isActive: form.value.is_active
|
||||
}))
|
||||
showSuccess('模型已添加')
|
||||
|
||||
@@ -443,9 +443,10 @@ const activeEndpoints = computed(() => (props.endpoints ?? [])
|
||||
if (typeof endpoint.active_keys === 'number') {
|
||||
return endpoint.is_active !== false
|
||||
&& isModelTestableApiFormat(endpoint.api_format)
|
||||
&& endpoint.active_keys > 0
|
||||
&& (endpoint.active_keys > 0
|
||||
|| isModelTestableEndpoint(endpoint, providerKeysState.value, props.provider.provider_type))
|
||||
}
|
||||
return isModelTestableEndpoint(endpoint, providerKeysState.value)
|
||||
return isModelTestableEndpoint(endpoint, providerKeysState.value, props.provider.provider_type)
|
||||
}))
|
||||
const selectableTestEndpoints = computed(() => mappingTestEndpoints.value ?? activeEndpoints.value)
|
||||
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
|
||||
|
||||
@@ -313,9 +313,10 @@ const activeEndpoints = computed(() => (props.endpoints ?? [])
|
||||
if (typeof endpoint.active_keys === 'number') {
|
||||
return endpoint.is_active !== false
|
||||
&& isModelTestableApiFormat(endpoint.api_format)
|
||||
&& endpoint.active_keys > 0
|
||||
&& (endpoint.active_keys > 0
|
||||
|| isModelTestableEndpoint(endpoint, props.providerKeys ?? [], props.provider.provider_type))
|
||||
}
|
||||
return isModelTestableEndpoint(endpoint, props.providerKeys ?? [])
|
||||
return isModelTestableEndpoint(endpoint, props.providerKeys ?? [], props.provider.provider_type)
|
||||
}))
|
||||
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
|
||||
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('buildDefaultModelTestRequestBody', () => {
|
||||
expect(body.input).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses prompt payloads for openai image api formats', () => {
|
||||
it('uses image prompt payloads for OpenAI image test requests', () => {
|
||||
const body = JSON.parse(buildDefaultModelTestRequestBody('gpt-image-2', 'openai:image'))
|
||||
|
||||
expect(body).toEqual({
|
||||
@@ -240,6 +240,8 @@ describe('isModelTestableApiFormat', () => {
|
||||
it.each([
|
||||
'openai:chat',
|
||||
'openai:responses',
|
||||
'openai:responses:compact',
|
||||
'openai:image',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
'openai:image',
|
||||
@@ -350,6 +352,23 @@ describe('isModelTestableEndpoint', () => {
|
||||
is_active: true,
|
||||
}, keys)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets fixed provider OAuth keys inherit testable endpoint formats', () => {
|
||||
const keys = [{
|
||||
api_formats: ['legacy:mismatch'],
|
||||
auth_type: 'oauth',
|
||||
is_active: true,
|
||||
}]
|
||||
|
||||
expect(isModelTestableEndpoint({
|
||||
api_format: 'openai:image',
|
||||
is_active: true,
|
||||
}, keys, 'chatgpt_web')).toBe(true)
|
||||
expect(isModelTestableEndpoint({
|
||||
api_format: 'openai:image',
|
||||
is_active: true,
|
||||
}, keys, 'custom')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatModelTestDiagnostic', () => {
|
||||
|
||||
@@ -15,6 +15,9 @@ export type ModelTestImageSource = {
|
||||
export type ModelTestKeySource = {
|
||||
api_formats?: string[] | null
|
||||
is_active?: boolean | null
|
||||
auth_type?: string | null
|
||||
credential_kind?: string | null
|
||||
oauth_managed?: boolean | null
|
||||
}
|
||||
|
||||
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
|
||||
@@ -23,6 +26,20 @@ const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
|
||||
'gemini:files',
|
||||
])
|
||||
|
||||
const MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS = new Set([
|
||||
'claude_code',
|
||||
'codex',
|
||||
'chatgpt_web',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro',
|
||||
])
|
||||
|
||||
const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
|
||||
'chatgpt_web',
|
||||
])
|
||||
|
||||
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
|
||||
pool_account_blocked: '账号已失效,需重新授权',
|
||||
}
|
||||
@@ -41,12 +58,15 @@ export function isModelTestableApiFormat(apiFormat: string | null | undefined):
|
||||
export function modelTestKeySupportsEndpoint(
|
||||
key: ModelTestKeySource,
|
||||
endpoint: ModelTestEndpointSource,
|
||||
providerType?: string | null,
|
||||
): boolean {
|
||||
if (key.is_active === false) return false
|
||||
|
||||
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
|
||||
if (!isModelTestableApiFormat(endpointFormat)) return false
|
||||
|
||||
if (modelTestKeyInheritsProviderFormats(key, providerType)) return true
|
||||
|
||||
const keyFormats = normalizeModelTestStringList(key.api_formats)
|
||||
if (keyFormats.length === 0) return true
|
||||
|
||||
@@ -56,10 +76,32 @@ export function modelTestKeySupportsEndpoint(
|
||||
export function isModelTestableEndpoint(
|
||||
endpoint: ModelTestEndpointSource,
|
||||
keys: ModelTestKeySource[],
|
||||
providerType?: string | null,
|
||||
): boolean {
|
||||
return endpoint.is_active !== false
|
||||
&& isModelTestableApiFormat(endpoint.api_format)
|
||||
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
|
||||
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint, providerType))
|
||||
}
|
||||
|
||||
function modelTestKeyInheritsProviderFormats(
|
||||
key: ModelTestKeySource,
|
||||
providerType: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedProviderType = providerType?.trim().toLowerCase()
|
||||
if (!normalizedProviderType) return false
|
||||
|
||||
const authType = key.auth_type?.trim().toLowerCase()
|
||||
const credentialKind = key.credential_kind?.trim().toLowerCase()
|
||||
const oauthManaged = key.oauth_managed === true
|
||||
|| credentialKind === 'oauth_session'
|
||||
|| authType === 'oauth'
|
||||
|
||||
if (oauthManaged && MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return authType === 'bearer'
|
||||
&& MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)
|
||||
}
|
||||
|
||||
export function selectPreferredModelTestEndpoint<T extends ModelTestEndpointSource>(
|
||||
|
||||
@@ -149,14 +149,16 @@ export function buildDefaultModelTestRequestBody(
|
||||
apiFormat?: string | null,
|
||||
model?: ModelTestImageSource | null,
|
||||
): string {
|
||||
if (apiFormat?.trim().toLowerCase().endsWith(':embedding')) {
|
||||
const normalizedApiFormat = normalizeApiFormatAlias(apiFormat ?? '')
|
||||
|
||||
if (normalizedApiFormat.endsWith(':embedding')) {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
input: 'This is a test embedding input.',
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (apiFormat?.trim().toLowerCase().endsWith(':rerank')) {
|
||||
if (normalizedApiFormat.endsWith(':rerank')) {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
query: 'Apple',
|
||||
@@ -171,7 +173,7 @@ export function buildDefaultModelTestRequestBody(
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:image') {
|
||||
if (normalizedApiFormat === 'openai:image') {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
prompt: DEFAULT_MODEL_TEST_MESSAGE,
|
||||
@@ -181,7 +183,7 @@ export function buildDefaultModelTestRequestBody(
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:responses' && modelSupportsImageGeneration(model)) {
|
||||
if (normalizedApiFormat === 'openai:responses' && modelSupportsImageGeneration(model)) {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
input: DEFAULT_MODEL_TEST_MESSAGE,
|
||||
@@ -271,4 +273,4 @@ export function parseModelTestRequestHeadersDraft(
|
||||
emptyError: null,
|
||||
invalidTypeError: '测试请求头必须是 JSON 对象',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,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>
|
||||
@@ -426,7 +429,52 @@
|
||||
</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="imageOutputPricingDescriptor"
|
||||
class="text-muted-foreground font-mono"
|
||||
>{{ imageOutputPricingDescriptor }}</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"
|
||||
@@ -943,6 +991,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
|
||||
@@ -1080,6 +1133,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),
|
||||
)
|
||||
@@ -1421,6 +1478,108 @@ 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 imageOutputPriceBucket = computed(() =>
|
||||
getNestedString(billingResolvedDimensions.value, 'image_output_price_bucket'),
|
||||
)
|
||||
|
||||
const imageOutputPixels = computed(() =>
|
||||
getNestedNumber(billingResolvedDimensions.value, 'image_pixels')
|
||||
?? parseImageSizePixels(imageOutputSize.value),
|
||||
)
|
||||
|
||||
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 === 'pixel_tiers'
|
||||
|| imageOutputPricingMode.value === 'per_image'
|
||||
|| imageOutputCostTotal.value > 0,
|
||||
)
|
||||
|
||||
const imageOutputMatrixEnabled = computed(() => {
|
||||
if (imageOutputPricingMode.value) return imageOutputPricingMode.value === 'matrix'
|
||||
return getNestedValue(billingResolvedDimensions.value, 'image_output_matrix_enabled') === true
|
||||
})
|
||||
|
||||
const imageOutputRangeEnabled = computed(() => {
|
||||
if (imageOutputPricingMode.value) return imageOutputPricingMode.value === 'pixel_tiers'
|
||||
return getNestedValue(billingResolvedDimensions.value, 'image_output_range_enabled') === true
|
||||
})
|
||||
|
||||
const imageOutputBillingLabel = computed(() => {
|
||||
if (imageOutputMatrixEnabled.value) return '矩阵计费'
|
||||
if (imageOutputRangeEnabled.value) return '像素区间'
|
||||
return '默认计费'
|
||||
})
|
||||
|
||||
const imageOutputPricingDescriptor = computed(() => {
|
||||
if (imageOutputMatrixEnabled.value && imagePriceKey.value) return imagePriceKey.value
|
||||
|
||||
const parts: string[] = []
|
||||
if (imageOutputPriceBucket.value && imageOutputPriceBucket.value !== 'default') {
|
||||
parts.push(formatImagePriceBucket(imageOutputPriceBucket.value))
|
||||
}
|
||||
const sizeQuality = [imageOutputSize.value, imageOutputQuality.value].filter(Boolean).join(' / ')
|
||||
if (sizeQuality) parts.push(sizeQuality)
|
||||
if (imageOutputRangeEnabled.value && imageOutputPixels.value !== null) {
|
||||
parts.push(formatPixels(imageOutputPixels.value))
|
||||
}
|
||||
if (parts.length > 0) return parts.join(' · ')
|
||||
if (imageOutputPriceBucket.value === 'default') return '默认价'
|
||||
return null
|
||||
})
|
||||
|
||||
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
|
||||
@@ -2123,6 +2282,34 @@ function formatNumber(num: number): string {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
function parseImageSizePixels(size: string | null): number | null {
|
||||
if (!size) return null
|
||||
const normalized = size.trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
|
||||
const [widthText, heightText] = normalized.split('x')
|
||||
const width = Number(widthText)
|
||||
const height = Number(heightText)
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null
|
||||
return Math.trunc(width * height)
|
||||
}
|
||||
|
||||
function formatImagePriceBucket(bucket: string): string {
|
||||
if (bucket === 'default') return '默认价'
|
||||
if (bucket === 'unbounded') return '无上限'
|
||||
const match = bucket.match(/^<=([0-9]+)px$/)
|
||||
if (match) return `<= ${formatPixels(Number(match[1]))}`
|
||||
return bucket
|
||||
}
|
||||
|
||||
function formatPixels(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 2)}M px`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(0)}K px`
|
||||
}
|
||||
return `${value} px`
|
||||
}
|
||||
|
||||
// 格式化响应时间,自动选择合适的单位
|
||||
function formatResponseTime(ms: number): { value: string; unit: string } {
|
||||
if (ms >= 1_000) {
|
||||
|
||||
Reference in New Issue
Block a user