fix(frontend): display image_output_tokens breakdown in usage pages

When image generation models are billed by token (channel pricing mode=token),
the usage pages previously showed only image count format instead of detailed
token breakdown. This fixes the display to properly show image output tokens
separately from text output tokens.
This commit is contained in:
erio
2026-05-31 22:53:22 +08:00
parent f18451e56f
commit ef5ad0fb10
9 changed files with 175 additions and 64 deletions
+2
View File
@@ -609,6 +609,8 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ImageSize: l.ImageSize,
ImageInputSize: l.ImageInputSize,
ImageOutputSize: l.ImageOutputSize,
ImageOutputTokens: l.ImageOutputTokens,
ImageOutputCost: l.ImageOutputCost,
ImageSizeSource: l.ImageSizeSource,
ImageSizeBreakdown: l.ImageSizeBreakdown,
MediaType: l.MediaType,
+2
View File
@@ -464,6 +464,8 @@ type UsageLog struct {
ImageSize *string `json:"image_size"`
ImageInputSize *string `json:"image_input_size"`
ImageOutputSize *string `json:"image_output_size"`
ImageOutputTokens int `json:"image_output_tokens"`
ImageOutputCost float64 `json:"image_output_cost"`
ImageSizeSource *string `json:"image_size_source"`
ImageSizeBreakdown map[string]int `json:"image_size_breakdown"`
MediaType *string `json:"media_type"`
@@ -125,6 +125,12 @@
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
</div>
</div>
<div v-if="hasImageOutputTokens(row)" class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span class="font-medium text-pink-600 dark:text-pink-400">{{ row.image_output_tokens.toLocaleString() }}</span>
</div>
</div>
</div>
<!-- Token Detail Tooltip -->
<div
@@ -206,10 +212,18 @@
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0 && !hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageOutputTokens(tokenTooltipData) && textOutputTokens(tokenTooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ textOutputTokens(tokenTooltipData).toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputTokens') }}</span>
<span class="font-medium text-pink-300">{{ tokenTooltipData.image_output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
<!-- 有 5m/1h 明细时,展开显示 -->
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
@@ -279,8 +293,26 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && hasImageOutputCost(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputCost') }}</span>
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(6) }}</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="tooltipData && isImageUsage(tooltipData)">
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0 && textOutputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, textOutputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && hasImageOutputTokens(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputTokenPrice') }}</span>
<span class="font-medium text-pink-300">{{ formatTokenPricePerMillion(tooltipData.image_output_cost ?? 0, tooltipData.image_output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<template v-else-if="isImageUsage(tooltipData)">
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
@@ -314,16 +346,6 @@
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
</template>
<template v-else-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
@@ -384,13 +406,23 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILLING_MODE_IMAGE } from '@/utils/billingMode'
import {
BILLING_MODE_TOKEN,
getBillingModeLabel,
getBillingModeBadgeClass,
isImageUsage,
getDisplayBillingMode,
imageUnitPrice,
} from '@/utils/billingMode'
import {
formatImageBillingSize,
formatImageInputSize,
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
hasImageOutputTokens,
textOutputTokens,
hasImageOutputCost,
} from '@/utils/imageUsage'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
@@ -400,23 +432,6 @@ function accountBilled(row: { total_cost?: number | null; account_stats_cost?: n
return Number.isNaN(result) ? 0 : result
}
function imageUnitPrice(row: AdminUsageLog | null): number {
if (!row || row.image_count <= 0) return 0
const total = row.total_cost ?? 0
const price = total / row.image_count
return Number.isFinite(price) ? price : 0
}
function isImageUsage(row: Pick<AdminUsageLog, 'image_count'> | null | undefined): boolean {
return (row?.image_count ?? 0) > 0
}
function getDisplayBillingMode(row: Pick<AdminUsageLog, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
if (isImageUsage(row)) {
return BILLING_MODE_IMAGE
}
return row?.billing_mode
}
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
+3
View File
@@ -906,6 +906,9 @@ export default {
imageBillingSize: 'Billing size',
imageInputSize: 'Input size',
imageOutputSize: 'Output size',
imageOutputTokens: 'Image Output Tokens',
imageOutputTokenPrice: 'Image Output Price',
imageOutputCost: 'Image Output Cost',
imageSizeSource: 'Size source',
imageSizeBreakdown: 'Size breakdown',
imageSizeSourceOutput: 'Upstream output',
+3
View File
@@ -910,6 +910,9 @@ export default {
imageBillingSize: '计费尺寸',
imageInputSize: '输入尺寸',
imageOutputSize: '输出尺寸',
imageOutputTokens: '图片输出 Token',
imageOutputTokenPrice: '图片输出单价',
imageOutputCost: '图片输出成本',
imageSizeSource: '尺寸来源',
imageSizeBreakdown: '尺寸明细',
imageSizeSourceOutput: '上游输出',
+5
View File
@@ -232,6 +232,7 @@ export interface PublicSettings {
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
service_quota_enabled: boolean
affiliate_enabled: boolean
}
@@ -1234,6 +1235,8 @@ export interface UsageLog {
image_output_size: string | null
image_size_source: ImageSizeSource | null
image_size_breakdown: ImageSizeBreakdown | null
image_output_tokens: number
image_output_cost: number
// User-Agent
user_agent: string | null
@@ -1408,6 +1411,8 @@ export interface UsageStatsResponse {
total_input_tokens: number
total_output_tokens: number
total_cache_tokens: number
total_cache_read_tokens: number
total_cache_creation_tokens: number
total_tokens: number
total_cost: number // 标准计费
total_actual_cost: number // 实际扣除
+21
View File
@@ -17,3 +17,24 @@ export function getBillingModeBadgeClass(mode: string | null | undefined): strin
default: return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}
}
interface ImageBillingRow {
image_count: number
billing_mode?: string | null
total_cost: number
}
export function isImageUsage(row: Pick<ImageBillingRow, 'image_count' | 'billing_mode'> | null | undefined): boolean {
return (row?.image_count ?? 0) > 0 && row?.billing_mode !== BILLING_MODE_TOKEN
}
export function getDisplayBillingMode(row: Pick<ImageBillingRow, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
return row?.billing_mode
}
export function imageUnitPrice(row: Pick<ImageBillingRow, 'image_count' | 'total_cost'> | null): number {
if (!row || row.image_count <= 0) return 0
const total = row.total_cost ?? 0
const price = total / row.image_count
return Number.isFinite(price) ? price : 0
}
+22
View File
@@ -2,6 +2,28 @@ import type { UsageLog } from '@/types'
type Translate = (key: string) => string
// --- Image output token / cost helpers ---
type ImageOutputTokenRow = Pick<UsageLog, 'output_tokens' | 'image_output_tokens'>
type ImageOutputCostRow = Pick<UsageLog, 'image_output_cost'>
/** Whether the row contains any image-output tokens. */
export const hasImageOutputTokens = (row: ImageOutputTokenRow | null | undefined): boolean =>
(row?.image_output_tokens ?? 0) > 0
/**
* Text-only output tokens (total output minus image-output).
* Returns 0 when no text tokens exist.
*/
export const textOutputTokens = (row: ImageOutputTokenRow | null | undefined): number =>
Math.max(0, (row?.output_tokens ?? 0) - (row?.image_output_tokens ?? 0))
/** Whether the row has a non-zero image-output cost. */
export const hasImageOutputCost = (row: ImageOutputCostRow | null | undefined): boolean =>
(row?.image_output_cost ?? 0) > 0
// --- Image size / billing helpers ---
const knownImageSizeSources = new Set(['output', 'input', 'default', 'legacy'])
const knownImageBillingSizes = new Set(['1K', '2K', '4K', 'mixed'])
+72 -34
View File
@@ -29,7 +29,7 @@
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
<Icon name="cube" size="md" class="text-amber-600 dark:text-amber-400" />
</div>
<div>
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('usage.totalTokens') }}
</p>
@@ -37,8 +37,23 @@
{{ formatTokens(usageStats?.total_tokens || 0) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} /
{{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }}
<span>{{ t('usage.in') }} {{ formatTokens(usageStats?.total_input_tokens || 0) }}</span>
<span> · </span>
<span>{{ t('usage.out') }} {{ formatTokens(usageStats?.total_output_tokens || 0) }}</span>
<span> · </span>
<span class="text-sky-600 dark:text-sky-400">{{ t('usage.cacheHit') }} {{ formatTokens(usageStats?.total_cache_read_tokens || 0) }}</span>
<span> · </span>
<span class="text-amber-600 dark:text-amber-400">{{ t('usage.cacheCreate') }} {{ formatTokens(usageStats?.total_cache_creation_tokens || 0) }}</span>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
{{ t('usage.cacheHitRate') }}:
<template v-if="cacheStats.totalInput > 0">
<span class="text-sky-600 dark:text-sky-400">{{ formatTokens(cacheStats.cacheRead) }}</span>
<span class="text-gray-400">/</span>
<span class="text-gray-600 dark:text-gray-300">{{ formatTokens(cacheStats.totalInput) }}</span>
<span class="ml-1">{{ cacheStats.ratePercent }}</span>
</template>
<template v-else>-</template>
</p>
</div>
</div>
@@ -257,6 +272,12 @@
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
</div>
</div>
<div v-if="hasImageOutputTokens(row)" class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span class="font-medium text-pink-600 dark:text-pink-400">{{ row.image_output_tokens.toLocaleString() }}</span>
</div>
</div>
</div>
<!-- Token Detail Tooltip -->
<div
@@ -368,10 +389,18 @@
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0 && !hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageOutputTokens(tokenTooltipData) && textOutputTokens(tokenTooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ textOutputTokens(tokenTooltipData).toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputTokens') }}</span>
<span class="font-medium text-pink-300">{{ tokenTooltipData.image_output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
<!-- 有 5m/1h 明细时,展开显示 -->
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
@@ -447,8 +476,27 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && hasImageOutputCost(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputCost') }}</span>
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(6) }}</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0 && textOutputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, textOutputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && hasImageOutputTokens(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputTokenPrice') }}</span>
<span class="font-medium text-pink-300">{{ formatTokenPricePerMillion(tooltipData.image_output_cost ?? 0, tooltipData.image_output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<!-- Per-image billing: show image metadata and unit price -->
<template v-if="tooltipData && isImageUsage(tooltipData)">
<template v-else-if="tooltipData && isImageUsage(tooltipData)">
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
@@ -482,17 +530,6 @@
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
</template>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-else-if="!getDisplayBillingMode(tooltipData) || getDisplayBillingMode(tooltipData) === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
@@ -559,10 +596,12 @@ import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import {
BILLING_MODE_IMAGE,
BILLING_MODE_TOKEN,
getBillingModeBadgeClass,
getBillingModeLabel,
isImageUsage,
getDisplayBillingMode,
imageUnitPrice,
} from '@/utils/billingMode'
import {
formatImageBillingSize,
@@ -570,6 +609,9 @@ import {
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
hasImageOutputTokens,
textOutputTokens,
hasImageOutputCost,
} from '@/utils/imageUsage'
const { t } = useI18n()
@@ -590,6 +632,19 @@ const tokenTooltipData = ref<UsageLog | null>(null)
// Usage stats from API
const usageStats = ref<UsageStatsResponse | null>(null)
// 缓存命中率 = cache_read / (input + cache_read)
// 分母为 0(无任何输入)时显示 '-'
const cacheStats = computed(() => {
// 总输入 token = 普通输入 + 缓存写入 + 缓存读取(命中)
// 缓存命中率 = 缓存读取 / 总输入;总输入为 0 时返回零值,模板按 '-' 渲染。
const cacheRead = usageStats.value?.total_cache_read_tokens || 0
const cacheCreate = usageStats.value?.total_cache_creation_tokens || 0
const input = usageStats.value?.total_input_tokens || 0
const totalInput = input + cacheCreate + cacheRead
const ratePercent = totalInput > 0 ? `${((cacheRead / totalInput) * 100).toFixed(1)}%` : '-'
return { cacheRead, totalInput, ratePercent }
})
const columns = computed<Column[]>(() => [
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true },
@@ -671,23 +726,6 @@ const formatDuration = (ms: number): string => {
return `${(ms / 1000).toFixed(2)}s`
}
const imageUnitPrice = (row: UsageLog | null): number => {
if (!row || row.image_count <= 0) return 0
const total = row.total_cost ?? 0
const price = total / row.image_count
return Number.isFinite(price) ? price : 0
}
const isImageUsage = (row: Pick<UsageLog, 'image_count'> | null | undefined): boolean => {
return (row?.image_count ?? 0) > 0
}
const getDisplayBillingMode = (row: Pick<UsageLog, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined => {
if (isImageUsage(row)) {
return BILLING_MODE_IMAGE
}
return row?.billing_mode
}
const formatUserAgent = (ua: string): string => {
return ua