fix: 修正时间戳精度、TTL 定价匹配逻辑及账单快照展示

- 将 created_at_unix_ms 从 current_unix_secs 改为 current_unix_ms,修正候选尝试和跳过记录的时间戳精度
- formula_engine: TTL 定价从「<=上限」模糊匹配改为精确匹配,null 值改为回退到基础价格而非透传
- 新增 ttl_pricing_requires_exact_match 和 ttl_pricing_null_value_falls_back_to_base_tier_value 测试用例
- service: 新增 5min/1h cache TTL 的端到端计费验证测试
- RequestDetailDrawer: 优先从 billing_snapshot 读取已解析的价格和费用,正确展示当前 TTL 对应的缓存创建/读取价格,修正输入/输出/缓存费用列的数据来源
This commit is contained in:
fawney19
2026-04-10 18:50:59 +08:00
parent 010ab127e2
commit d5b8583d6b
11 changed files with 542 additions and 76 deletions

View File

@@ -237,19 +237,12 @@
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
<template v-if="hasTierCacheCreationSplitPricing(tier)">
<span v-if="getTierCachePriceForTTL(tier, 5, 'cache_creation_price_per_1m') !== null">
缓存创建(5min) ${{ formatPrice(getTierCachePriceForTTL(tier, 5, 'cache_creation_price_per_1m') || 0) }}/M
</span>
<span v-if="getTierCachePriceForTTL(tier, 60, 'cache_creation_price_per_1m') !== null">
缓存创建(1h) ${{ formatPrice(getTierCachePriceForTTL(tier, 60, 'cache_creation_price_per_1m') || 0) }}/M
</span>
</template>
<span v-else-if="tier.cache_creation_price_per_1m">
缓存创建 ${{ formatPrice(tier.cache_creation_price_per_1m) }}/M
<span v-if="getTierActiveCacheCreationDisplay(tier)">
{{ getTierActiveCacheCreationDisplay(tier)?.label }}
${{ formatPrice(getTierActiveCacheCreationDisplay(tier)?.price || 0) }}/M
</span>
<span v-if="tier.cache_read_price_per_1m">
缓存读取 ${{ formatPrice(tier.cache_read_price_per_1m) }}/M
<span v-if="shouldShowCacheReadPrice(tier)">
缓存读取 ${{ formatPrice(getTierActiveCacheReadPrice(tier) ?? 0) }}/M
</span>
</div>
</div>
@@ -261,7 +254,7 @@
<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">{{ displayInputTokens }}</span>
<span class="text-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).toFixed(6) }}</span>
<span class="text-xs font-mono">${{ effectiveInputCost.toFixed(6) }}</span>
</div>
<Separator
orientation="vertical"
@@ -270,15 +263,15 @@
<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">{{ detail.tokens?.output || detail.output_tokens || 0 }}</span>
<span class="text-xs font-mono">${{ (detail.cost?.output || detail.output_cost || 0).toFixed(6) }}</span>
<span class="text-xs font-mono">${{ effectiveOutputCost.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]">{{ cacheCreationSplitRows.length > 0 ? '创建合计' : '缓存创建' }}</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_creation_input_tokens || 0 }}</span>
<span class="text-xs font-mono">${{ (detail.cache_creation_cost || 0).toFixed(6) }}</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ totalCacheCreationTokens }}</span>
<span class="text-xs font-mono">${{ effectiveCacheCreationCost.toFixed(6) }}</span>
</div>
<Separator
orientation="vertical"
@@ -287,7 +280,7 @@
<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">{{ detail.cache_read_input_tokens || 0 }}</span>
<span class="text-xs font-mono">${{ (detail.cache_read_cost || 0).toFixed(6) }}</span>
<span class="text-xs font-mono">${{ effectiveCacheReadCost.toFixed(6) }}</span>
</div>
</div>
<!-- 缓存创建 5m/1h 细分 -->
@@ -323,10 +316,10 @@
</div>
<div class="rounded-lg p-3 bg-primary/5 border border-primary/30 space-y-2">
<div
v-if="detail.price_per_request"
v-if="effectivePricePerRequest > 0"
class="flex items-center justify-end text-xs"
>
<span class="text-muted-foreground">${{ detail.price_per_request.toFixed(6) }}/</span>
<span class="text-muted-foreground">${{ effectivePricePerRequest.toFixed(6) }}/</span>
</div>
<div class="flex items-center">
<div class="flex items-center flex-1">
@@ -753,10 +746,55 @@ type CacheTTLPriceEntry = {
}
type PricingTierLike = {
up_to?: number | null
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
cache_ttl_pricing?: CacheTTLPriceEntry[] | null
}
type JsonRecord = Record<string, unknown>
function asRecord(value: unknown): JsonRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as JsonRecord
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function getNestedValue(record: JsonRecord | null, ...path: string[]): unknown {
let current: unknown = record
for (const key of path) {
const object = asRecord(current)
if (!object) return null
current = object[key]
}
return current
}
function getNestedNumber(record: JsonRecord | null, ...path: string[]): number | null {
return toNumber(getNestedValue(record, ...path))
}
function normalizeCacheTtlPricing(value: unknown): CacheTTLPriceEntry[] {
if (!Array.isArray(value)) return []
return value
.map((entry) => asRecord(entry))
.filter((entry): entry is JsonRecord => entry !== null)
.map((entry) => ({
ttl_minutes: toNumber(entry.ttl_minutes),
cache_creation_price_per_1m: toNumber(entry.cache_creation_price_per_1m),
cache_read_price_per_1m: toNumber(entry.cache_read_price_per_1m),
}))
}
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
const autoRefreshing = ref(false)
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
@@ -813,6 +851,22 @@ const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
return meta as Record<string, unknown>
})
const billingSnapshot = computed<JsonRecord | null>(() =>
asRecord(traceRequestMetadata.value?.billing_snapshot),
)
const billingResolvedVariables = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.resolved_variables),
)
const billingCostBreakdown = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.cost_breakdown),
)
const billingTierInfo = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.tier_info),
)
function hasBodyContent(flag: boolean | undefined, data: unknown): boolean {
return Boolean(flag) || hasContent(data)
}
@@ -1022,6 +1076,136 @@ const priceSourceLabel = computed(() => {
return '历史定价'
})
const cacheCreationInputTokens5m = computed(() => {
if (!detail.value) return 0
return toNumber(detail.value.cache_creation_input_tokens_5m)
?? getNestedNumber(detail.value as unknown as JsonRecord, 'cache_creation_ephemeral_5m_input_tokens')
?? 0
})
const cacheCreationInputTokens1h = computed(() => {
if (!detail.value) return 0
return toNumber(detail.value.cache_creation_input_tokens_1h)
?? getNestedNumber(detail.value as unknown as JsonRecord, 'cache_creation_ephemeral_1h_input_tokens')
?? 0
})
const totalCacheCreationTokens = computed(() => {
if (!detail.value) return 0
const classified = cacheCreationInputTokens5m.value + cacheCreationInputTokens1h.value
const total = detail.value.cache_creation_input_tokens || 0
if (total === 0 && classified > 0) return classified
return total
})
const effectiveInputPricePer1M = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'input_price_per_1m')
?? toNumber(detail.value?.input_price_per_1m)
)
const effectiveOutputPricePer1M = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'output_price_per_1m')
?? toNumber(detail.value?.output_price_per_1m)
)
const effectiveCacheCreationPricePer1M = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'cache_creation_price_per_1m')
?? toNumber(detail.value?.cache_creation_price_per_1m)
)
const effectiveCacheReadPricePer1M = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'cache_read_price_per_1m')
?? toNumber(detail.value?.cache_read_price_per_1m)
)
const effectivePricePerRequest = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'price_per_request')
?? toNumber(detail.value?.price_per_request)
?? 0,
)
const effectiveInputCost = computed(() =>
getNestedNumber(billingCostBreakdown.value, 'input_cost')
?? toNumber(detail.value?.input_cost)
?? 0,
)
const effectiveOutputCost = computed(() =>
getNestedNumber(billingCostBreakdown.value, 'output_cost')
?? toNumber(detail.value?.output_cost)
?? 0,
)
const effectiveCacheCreationCost = computed(() => {
const snapshotCost = [
getNestedNumber(billingCostBreakdown.value, 'cache_creation_uncategorized_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_5m_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_1h_cost'),
].reduce((sum, value) => sum + (value ?? 0), 0)
if (snapshotCost > 0) return snapshotCost
return toNumber(detail.value?.cache_creation_cost) ?? 0
})
const effectiveCacheReadCost = computed(() =>
getNestedNumber(billingCostBreakdown.value, 'cache_read_cost')
?? toNumber(detail.value?.cache_read_cost)
?? 0,
)
const effectiveRequestCost = computed(() => {
const snapshotCost = getNestedNumber(billingCostBreakdown.value, 'request_cost')
if (snapshotCost !== null) return snapshotCost
if (effectivePricePerRequest.value > 0) {
return toNumber(detail.value?.request_cost) ?? 0
}
return 0
})
const fallbackCacheTtlPricing = computed<CacheTTLPriceEntry[]>(() => {
const tierPricing = normalizeCacheTtlPricing(billingTierInfo.value?.cache_ttl_pricing)
if (tierPricing.length > 0) return tierPricing
const rows: CacheTTLPriceEntry[] = []
const cache5mCreationPrice = getNestedNumber(
billingResolvedVariables.value,
'cache_creation_ephemeral_5m_price_per_1m',
)
const cache1hCreationPrice = getNestedNumber(
billingResolvedVariables.value,
'cache_creation_ephemeral_1h_price_per_1m',
)
if (cache5mCreationPrice !== null) {
rows.push({
ttl_minutes: 5,
cache_creation_price_per_1m: cache5mCreationPrice,
cache_read_price_per_1m: effectiveCacheReadPricePer1M.value,
})
}
if (cache1hCreationPrice !== null) {
rows.push({
ttl_minutes: 60,
cache_creation_price_per_1m: cache1hCreationPrice,
cache_read_price_per_1m: effectiveCacheReadPricePer1M.value,
})
}
return rows
})
const activeCacheTtlMinutes = computed(() => {
const snapshotTtl = getNestedNumber(billingSnapshot.value, 'resolved_dimensions', 'cache_ttl_minutes')
if (snapshotTtl !== null && snapshotTtl > 0) {
return Math.trunc(snapshotTtl)
}
if (cacheCreationInputTokens1h.value > 0 && cacheCreationInputTokens5m.value === 0) {
return 60
}
if (cacheCreationInputTokens5m.value > 0 && cacheCreationInputTokens1h.value === 0) {
return 5
}
return null
})
// 统一的阶梯显示数据
// 如果有 tiered_pricing使用它否则用历史价格构建单阶梯
const displayTiers = computed(() => {
@@ -1035,10 +1219,11 @@ const displayTiers = computed(() => {
// 否则用历史价格构建单阶梯(无上限)
return [{
up_to: null,
input_price_per_1m: detail.value.input_price_per_1m || 0,
output_price_per_1m: detail.value.output_price_per_1m || 0,
cache_creation_price_per_1m: detail.value.cache_creation_price_per_1m,
cache_read_price_per_1m: detail.value.cache_read_price_per_1m
input_price_per_1m: effectiveInputPricePer1M.value ?? 0,
output_price_per_1m: effectiveOutputPricePer1M.value ?? 0,
cache_creation_price_per_1m: effectiveCacheCreationPricePer1M.value,
cache_read_price_per_1m: effectiveCacheReadPricePer1M.value,
cache_ttl_pricing: fallbackCacheTtlPricing.value,
}]
})
@@ -1051,6 +1236,11 @@ const currentTierIndex = computed(() => {
return detail.value.tiered_pricing.tier_index
}
const snapshotTierIndex = getNestedNumber(billingSnapshot.value, 'tier_index')
if (snapshotTierIndex !== null) {
return Math.max(0, Math.trunc(snapshotTierIndex))
}
// 单阶梯时默认是第0阶
return 0
})
@@ -1064,9 +1254,9 @@ const currentTier = computed<PricingTierLike | null>(() => {
const cacheCreationSummaryText = computed(() => {
if (!detail.value) return '0'
const total = detail.value.cache_creation_input_tokens || 0
const cache5m = detail.value.cache_creation_input_tokens_5m || 0
const cache1h = detail.value.cache_creation_input_tokens_1h || 0
const total = totalCacheCreationTokens.value
const cache5m = cacheCreationInputTokens5m.value
const cache1h = cacheCreationInputTokens1h.value
if (cache5m <= 0 && cache1h <= 0) {
return formatNumber(total)
@@ -1091,8 +1281,8 @@ const cacheCreationSplitRows = computed(() => {
cost: number | null
}> = []
const cache5m = detail.value.cache_creation_input_tokens_5m || 0
const cache1h = detail.value.cache_creation_input_tokens_1h || 0
const cache5m = cacheCreationInputTokens5m.value
const cache1h = cacheCreationInputTokens1h.value
if (cache5m > 0) {
const pricePer1M = getActiveCachePriceForTTL(5, 'cache_creation_price_per_1m')
@@ -1130,7 +1320,7 @@ const _totalInputContext = computed(() => {
// 否则手动计算
const input = detail.value.tokens?.input || detail.value.input_tokens || 0
const cacheCreation = detail.value.cache_creation_input_tokens || 0
const cacheCreation = totalCacheCreationTokens.value
const cacheRead = detail.value.cache_read_input_tokens || 0
return input + cacheCreation + cacheRead
})
@@ -1138,11 +1328,10 @@ const _totalInputContext = computed(() => {
// Token 费用总计
const tokenCostTotal = computed(() => {
if (!detail.value) return 0
const inputCost = detail.value.cost?.input || detail.value.input_cost || 0
const outputCost = detail.value.cost?.output || detail.value.output_cost || 0
const cacheCreationCost = detail.value.cache_creation_cost || 0
const cacheReadCost = detail.value.cache_read_cost || 0
return inputCost + outputCost + cacheCreationCost + cacheReadCost
return effectiveInputCost.value
+ effectiveOutputCost.value
+ effectiveCacheCreationCost.value
+ effectiveCacheReadCost.value
})
// 按次计费费用(非视频任务时)
@@ -1150,7 +1339,7 @@ const perRequestCost = computed(() => {
if (!detail.value) return 0
// 视频任务的 request_cost 实际上是视频费用,不算按次
if (detail.value.video_billing) return 0
return detail.value.request_cost || 0
return effectiveRequestCost.value
})
// 视频/图像/音频费用
@@ -1167,7 +1356,7 @@ const hasTokenCost = computed(() => {
if (!detail.value) return false
const inputTokens = detail.value.tokens?.input || detail.value.input_tokens || 0
const outputTokens = detail.value.tokens?.output || detail.value.output_tokens || 0
const cacheCreation = detail.value.cache_creation_input_tokens || 0
const cacheCreation = totalCacheCreationTokens.value
const cacheRead = detail.value.cache_read_input_tokens || 0
return (inputTokens + outputTokens + cacheCreation + cacheRead) > 0 || tokenCostTotal.value > 0
})
@@ -1208,12 +1397,25 @@ function getTierCachePriceForTTL(
if (ttlPricing.length === 0) return fallback
const matched = ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) >= ttlMinutes)
|| ttlPricing[ttlPricing.length - 1]
const matched = ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) === ttlMinutes)
const price = toFiniteNumber(matched?.[priceKey])
return price ?? fallback
}
function getTierMatchedCachePricingEntry(
tier: PricingTierLike | null | undefined,
ttlMinutes: number,
): CacheTTLPriceEntry | null {
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing)
? tier.cache_ttl_pricing
.filter((entry): entry is CacheTTLPriceEntry => !!entry && typeof entry === 'object')
.sort((a, b) => Number(a.ttl_minutes || 0) - Number(b.ttl_minutes || 0))
: []
if (ttlPricing.length === 0) return null
return ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) === ttlMinutes) ?? null
}
function hasTierCacheCreationSplitPricing(tier: PricingTierLike | null | undefined): boolean {
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing) ? tier.cache_ttl_pricing : []
return ttlPricing.some((entry) =>
@@ -1222,6 +1424,45 @@ function hasTierCacheCreationSplitPricing(tier: PricingTierLike | null | undefin
)
}
function formatCacheTtlLabel(ttlMinutes: number | null | undefined): string {
if (!ttlMinutes || ttlMinutes <= 0) return '缓存创建'
if (ttlMinutes >= 60) return '缓存创建(1h)'
if (ttlMinutes <= 5) return '缓存创建(5min)'
return `缓存创建(${ttlMinutes}min)`
}
function getTierActiveCacheCreationDisplay(
tier: PricingTierLike | null | undefined,
): { label: string, price: number } | null {
if (hasTierCacheCreationSplitPricing(tier)) {
const activeTtl = activeCacheTtlMinutes.value
if (activeTtl !== null) {
const matchedEntry = getTierMatchedCachePricingEntry(tier, activeTtl)
const matchedPrice = getTierCachePriceForTTL(tier, activeTtl, 'cache_creation_price_per_1m')
if (matchedEntry && matchedPrice !== null) {
return {
label: formatCacheTtlLabel(matchedEntry.ttl_minutes),
price: matchedPrice,
}
}
const fallbackPrice = toFiniteNumber(tier?.cache_creation_price_per_1m)
if (fallbackPrice !== null) {
return {
label: formatCacheTtlLabel(activeTtl),
price: fallbackPrice,
}
}
}
}
const fallbackPrice = toFiniteNumber(tier?.cache_creation_price_per_1m)
if (fallbackPrice === null) return null
return {
label: '缓存创建',
price: fallbackPrice,
}
}
function getActiveCachePriceForTTL(
ttlMinutes: number,
priceKey: 'cache_creation_price_per_1m' | 'cache_read_price_per_1m',
@@ -1235,6 +1476,22 @@ function getActiveCachePriceForTTL(
return toFiniteNumber(detail.value?.cache_read_price_per_1m)
}
function getTierActiveCacheReadPrice(tier: PricingTierLike | null | undefined): number | null {
const activeTtl = activeCacheTtlMinutes.value
if (activeTtl !== null) {
const matchedPrice = getTierCachePriceForTTL(tier, activeTtl, 'cache_read_price_per_1m')
if (matchedPrice !== null) return matchedPrice
}
return toFiniteNumber(tier?.cache_read_price_per_1m)
?? effectiveCacheReadPricePer1M.value
?? null
}
function shouldShowCacheReadPrice(tier: PricingTierLike | null | undefined): boolean {
return getTierActiveCacheReadPrice(tier) !== null
}
function getDefaultDataSourceForTab(tab: string): 'client' | 'provider' {
if (!detail.value) {
if (['request-headers', 'request-body'].includes(tab)) return 'provider'