Fix image billing size normalization

This commit is contained in:
2ue
2026-05-12 15:21:31 +08:00
parent 62ccd0ff39
commit bb4c1abe28
45 changed files with 3270 additions and 313 deletions
@@ -86,19 +86,19 @@
</template>
<template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getBillingModeBadgeClass(row.billing_mode)">
{{ getBillingModeLabel(row.billing_mode, t) }}
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getBillingModeBadgeClass(getDisplayBillingMode(row))">
{{ getBillingModeLabel(getDisplayBillingMode(row), t) }}
</span>
</template>
<template #cell-tokens="{ row }">
<!-- 图片生成请求(仅按次计费时显示图片格式) -->
<div v-if="row.image_count > 0 && row.billing_mode === BILLING_MODE_IMAGE" class="flex items-center gap-1.5">
<div v-if="isImageUsage(row)" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-indigo-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-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
<span class="text-gray-400">({{ formatImageBillingSize(row, t) }})</span>
</div>
<!-- Token 请求 -->
<div v-else class="flex items-center gap-1.5">
@@ -280,21 +280,30 @@
<span class="font-medium text-white">${{ tooltipData.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_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>
<!-- Per-request / image billing: show unit price -->
<template v-else-if="tooltipData?.billing_mode === BILLING_MODE_IMAGE">
<template v-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') }} ({{ tooltipData.image_size || '2K' }})</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageBillingSize') }}</span>
<span class="font-medium text-white">{{ formatImageBillingSize(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageSizeSource') }}</span>
<span class="font-medium text-white">{{ formatImageSizeSource(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageInputSize') }}</span>
<span class="font-medium text-white">{{ formatImageInputSize(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputSize') }}</span>
<span class="font-medium text-white">{{ formatImageOutputSize(tooltipData, t) }}</span>
</div>
<div v-if="formatImageSizeBreakdown(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageSizeBreakdown') }}</span>
<span class="font-medium text-white">{{ formatImageSizeBreakdown(tooltipData) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageUnitPrice') }}</span>
@@ -305,6 +314,16 @@
<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>
@@ -366,6 +385,13 @@ 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 {
formatImageBillingSize,
formatImageInputSize,
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
} from '@/utils/imageUsage'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
@@ -381,6 +407,17 @@ function imageUnitPrice(row: AdminUsageLog | null): number {
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'
import Icon from '@/components/icons/Icon.vue'
@@ -22,6 +22,26 @@ const messages: Record<string, string> = {
'usage.original': 'Original',
'usage.userBilled': 'User billed',
'usage.accountBilled': 'Account billed',
'usage.imageUnit': ' images',
'usage.imageCount': 'Image count',
'usage.imageBillingSize': 'Billing size',
'usage.imageInputSize': 'Input size',
'usage.imageOutputSize': 'Output size',
'usage.imageSizeSource': 'Size source',
'usage.imageSizeBreakdown': 'Size breakdown',
'usage.imageSizeSourceOutput': 'Upstream output',
'usage.imageSizeSourceInput': 'Request input',
'usage.imageSizeSourceDefault': 'Default billing tier',
'usage.imageSizeSourceLegacy': 'Legacy record',
'usage.imageSizeSourceMissing': 'Not recorded',
'usage.imageSizeNotRecorded': 'not recorded',
'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized',
'usage.imageSizeUnknown': 'unknown',
'usage.imageUnitPrice': 'Per-image price',
'usage.imageTotalPrice': 'Image total price',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
}
vi.mock('vue-i18n', async () => {
@@ -40,12 +60,42 @@ const DataTableStub = {
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-model" :row="row" :value="row.model" />
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
</div>
</div>
`,
}
const baseImageRow = {
request_id: 'req-admin-image',
model: 'gpt-image-2',
actual_cost: 0.4,
total_cost: 0.4,
account_rate_multiplier: 1,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
cache_ttl_overridden: false,
billing_mode: 'image',
image_count: 2,
image_size: '2K',
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
}
describe('admin UsageTable tooltip', () => {
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
@@ -93,7 +143,8 @@ describe('admin UsageTable tooltip', () => {
},
})
await wrapper.find('.group.relative').trigger('mouseenter')
const tooltipTriggers = wrapper.findAll('.group.relative')
await tooltipTriggers[tooltipTriggers.length - 1].trigger('mouseenter')
await nextTick()
const text = wrapper.text()
@@ -147,4 +198,126 @@ describe('admin UsageTable tooltip', () => {
expect(text).toContain('claude-sonnet-4')
expect(text).toContain('claude-sonnet-4-20250514')
})
it.each([
{
name: 'defaulted row',
row: {
...baseImageRow,
request_id: 'req-admin-default-image',
image_size: '2K',
image_input_size: 'auto',
image_output_size: null,
image_size_source: 'default',
},
expected: ['2K', 'Default billing tier', 'auto', 'unknown'],
},
{
name: 'output-sourced row',
row: {
...baseImageRow,
request_id: 'req-admin-output-image',
image_size: '4K',
image_input_size: '1024x1024',
image_output_size: '3840x2160',
image_size_source: 'output',
image_size_breakdown: { '4K': 1 },
},
expected: ['4K', 'Upstream output', '1024x1024', '3840x2160', '4K x 1'],
},
{
name: 'input-sourced row',
row: {
...baseImageRow,
request_id: 'req-admin-input-image',
image_size: '1K',
image_input_size: '1024x1024',
image_output_size: null,
image_size_source: 'input',
},
expected: ['1K', 'Request input', '1024x1024', 'unknown'],
},
{
name: 'legacy unstandardized row',
row: {
...baseImageRow,
request_id: 'req-admin-legacy-unstandardized-image',
image_size: '512x512',
image_input_size: null,
image_output_size: null,
image_size_source: null,
},
expected: ['legacy unstandardized: 512x512', 'Legacy record', 'unknown'],
},
])('shows image usage metadata for $name', async ({ row, expected }) => {
const wrapper = mount(UsageTable, {
props: {
data: [row],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
await wrapper.find('.group.relative').trigger('mouseenter')
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image count')
expect(text).toContain('Billing size')
expect(text).toContain('Size source')
expect(text).toContain('Input size')
expect(text).toContain('Output size')
expect(text).toContain('Per-image price')
expect(text).toContain('Image total price')
for (const value of expected) {
expect(text).toContain(value)
}
})
it('displays historical image rows with missing billing_mode as image usage without a 2K fallback', async () => {
const wrapper = mount(UsageTable, {
props: {
data: [
{
...baseImageRow,
request_id: 'req-admin-legacy-missing-image',
billing_mode: null,
image_size: null,
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
},
],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
await wrapper.find('.group.relative').trigger('mouseenter')
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image')
expect(text).toContain('Image count')
expect(text).toContain('Per-image price')
expect(text).toContain('not recorded')
expect(text).not.toContain('(2K)')
})
})
+13
View File
@@ -855,6 +855,19 @@ export default {
imageUnitPrice: 'Per-image price',
imageTotalPrice: 'Image total price',
imageCount: 'Image count',
imageBillingSize: 'Billing size',
imageInputSize: 'Input size',
imageOutputSize: 'Output size',
imageSizeSource: 'Size source',
imageSizeBreakdown: 'Size breakdown',
imageSizeSourceOutput: 'Upstream output',
imageSizeSourceInput: 'Request input',
imageSizeSourceDefault: 'Default billing tier',
imageSizeSourceLegacy: 'Legacy record',
imageSizeSourceMissing: 'Not recorded',
imageSizeNotRecorded: 'not recorded',
imageSizeLegacyUnstandardized: 'legacy unstandardized',
imageSizeUnknown: 'unknown',
cacheRead: 'Read',
cacheWrite: 'Write',
serviceTier: 'Service tier',
+13
View File
@@ -859,6 +859,19 @@ export default {
imageUnitPrice: '单张价格',
imageTotalPrice: '图片总价',
imageCount: '图片张数',
imageBillingSize: '计费尺寸',
imageInputSize: '输入尺寸',
imageOutputSize: '输出尺寸',
imageSizeSource: '尺寸来源',
imageSizeBreakdown: '尺寸明细',
imageSizeSourceOutput: '上游输出',
imageSizeSourceInput: '请求输入',
imageSizeSourceDefault: '默认计费档位',
imageSizeSourceLegacy: '历史记录',
imageSizeSourceMissing: '未记录',
imageSizeNotRecorded: '未记录',
imageSizeLegacyUnstandardized: '历史非标准',
imageSizeUnknown: '未知',
cacheRead: '读取',
cacheWrite: '写入',
serviceTier: '服务档位',
+6
View File
@@ -1154,6 +1154,8 @@ export interface CodexSessionImportResult {
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
export type UsageRequestType = 'unknown' | 'sync' | 'stream' | 'ws_v2'
export type ImageSizeSource = 'output' | 'input' | 'default' | 'legacy'
export type ImageSizeBreakdown = Record<string, number>
export interface UsageLog {
id: number
@@ -1195,6 +1197,10 @@ export interface UsageLog {
// 图片生成字段
image_count: number
image_size: string | null
image_input_size: string | null
image_output_size: string | null
image_size_source: ImageSizeSource | null
image_size_breakdown: ImageSizeBreakdown | null
// User-Agent
user_agent: string | null
+56
View File
@@ -0,0 +1,56 @@
import type { UsageLog } from '@/types'
type Translate = (key: string) => string
const knownImageSizeSources = new Set(['output', 'input', 'default', 'legacy'])
const knownImageBillingSizes = new Set(['1K', '2K', '4K', 'mixed'])
type ImageUsageRow = Pick<
UsageLog,
'image_size' | 'image_input_size' | 'image_output_size' | 'image_size_source' | 'image_size_breakdown'
>
const trimmed = (value: string | null | undefined): string => value?.trim() ?? ''
export const formatImageBillingSize = (row: ImageUsageRow | null | undefined, t: Translate): string => {
const size = trimmed(row?.image_size)
if (!size) {
return t('usage.imageSizeNotRecorded')
}
if (knownImageBillingSizes.has(size)) {
return size
}
return `${t('usage.imageSizeLegacyUnstandardized')}: ${size}`
}
export const formatImageInputSize = (row: ImageUsageRow | null | undefined, t: Translate): string => {
const size = trimmed(row?.image_input_size)
return size || t('usage.imageSizeUnknown')
}
export const formatImageOutputSize = (row: ImageUsageRow | null | undefined, t: Translate): string => {
const size = trimmed(row?.image_output_size)
return size || t('usage.imageSizeUnknown')
}
export const formatImageSizeSource = (row: ImageUsageRow | null | undefined, t: Translate): string => {
const source = trimmed(row?.image_size_source).toLowerCase()
if (knownImageSizeSources.has(source)) {
return t(`usage.imageSizeSource${source.charAt(0).toUpperCase()}${source.slice(1)}`)
}
if (trimmed(row?.image_size)) {
return t('usage.imageSizeSourceLegacy')
}
return t('usage.imageSizeSourceMissing')
}
export const formatImageSizeBreakdown = (row: ImageUsageRow | null | undefined): string => {
const breakdown = row?.image_size_breakdown
if (!breakdown || Object.keys(breakdown).length === 0) {
return ''
}
return ['1K', '2K', '4K']
.filter((tier) => (breakdown[tier] ?? 0) > 0)
.map((tier) => `${tier} x ${breakdown[tier]}`)
.join(', ')
}
+65 -22
View File
@@ -191,14 +191,14 @@
<template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium"
:class="getBillingModeBadgeClass(row.billing_mode)">
{{ getBillingModeLabel(row.billing_mode, t) }}
:class="getBillingModeBadgeClass(getDisplayBillingMode(row))">
{{ getBillingModeLabel(getDisplayBillingMode(row), t) }}
</span>
</template>
<template #cell-tokens="{ row }">
<!-- 图片生成请求(仅按次计费时显示图片格式) -->
<div v-if="row.image_count > 0 && row.billing_mode === 'image'" class="flex items-center gap-1.5">
<!-- 图片生成请求 -->
<div v-if="isImageUsage(row)" class="flex items-center gap-1.5">
<svg
class="h-4 w-4 text-indigo-500"
fill="none"
@@ -212,8 +212,8 @@
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-gray-900 dark:text-white">{{ row.image_count }}{{ $t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ formatImageBillingSize(row, t) }})</span>
</div>
<!-- Token 请求 -->
<div v-else class="flex items-center gap-1.5">
@@ -447,22 +447,31 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="!tooltipData?.billing_mode || 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>
<!-- Per-request / image billing: show unit price -->
<template v-else-if="tooltipData?.billing_mode === 'image'">
<!-- Per-image billing: show image metadata and unit price -->
<template v-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') }} ({{ tooltipData.image_size || '2K' }})</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageBillingSize') }}</span>
<span class="font-medium text-white">{{ formatImageBillingSize(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageSizeSource') }}</span>
<span class="font-medium text-white">{{ formatImageSizeSource(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageInputSize') }}</span>
<span class="font-medium text-white">{{ formatImageInputSize(tooltipData, t) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputSize') }}</span>
<span class="font-medium text-white">{{ formatImageOutputSize(tooltipData, t) }}</span>
</div>
<div v-if="formatImageSizeBreakdown(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageSizeBreakdown') }}</span>
<span class="font-medium text-white">{{ formatImageSizeBreakdown(tooltipData) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageUnitPrice') }}</span>
@@ -473,6 +482,17 @@
<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>
@@ -538,7 +558,19 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass } from '@/utils/billingMode'
import {
BILLING_MODE_IMAGE,
BILLING_MODE_TOKEN,
getBillingModeBadgeClass,
getBillingModeLabel,
} from '@/utils/billingMode'
import {
formatImageBillingSize,
formatImageInputSize,
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
} from '@/utils/imageUsage'
const { t } = useI18n()
const appStore = useAppStore()
@@ -646,6 +678,17 @@ const imageUnitPrice = (row: UsageLog | null): number => {
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
}
@@ -877,7 +920,7 @@ const exportToCSV = async () => {
formatReasoningEffort(log.reasoning_effort),
log.inbound_endpoint || '',
getRequestTypeExportText(log),
getBillingModeLabel(log.billing_mode, t),
getBillingModeLabel(getDisplayBillingMode(log), t),
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
@@ -41,6 +41,26 @@ const messages: Record<string, string> = {
'usage.duration': 'Duration',
'usage.time': 'Time',
'usage.userAgent': 'User Agent',
'usage.imageUnit': ' images',
'usage.imageCount': 'Image count',
'usage.imageBillingSize': 'Billing size',
'usage.imageInputSize': 'Input size',
'usage.imageOutputSize': 'Output size',
'usage.imageSizeSource': 'Size source',
'usage.imageSizeBreakdown': 'Size breakdown',
'usage.imageSizeSourceOutput': 'Upstream output',
'usage.imageSizeSourceInput': 'Request input',
'usage.imageSizeSourceDefault': 'Default billing tier',
'usage.imageSizeSourceLegacy': 'Legacy record',
'usage.imageSizeSourceMissing': 'Not recorded',
'usage.imageSizeNotRecorded': 'not recorded',
'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized',
'usage.imageSizeUnknown': 'unknown',
'usage.imageUnitPrice': 'Per-image price',
'usage.imageTotalPrice': 'Image total price',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
}
vi.mock('@/api', () => ({
@@ -69,7 +89,19 @@ vi.mock('vue-i18n', async () => {
const AppLayoutStub = { template: '<div><slot /></div>' }
const TablePageLayoutStub = {
template: '<div><slot name="actions" /><slot name="filters" /><slot /></div>',
template: '<div><slot name="actions" /><slot name="filters" /><slot name="table" /><slot /></div>',
}
const DataTableStub = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
</div>
</div>
`,
}
describe('user UsageView tooltip', () => {
@@ -146,6 +178,7 @@ describe('user UsageView tooltip', () => {
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
@@ -244,6 +277,7 @@ describe('user UsageView tooltip', () => {
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
@@ -274,4 +308,233 @@ describe('user UsageView tooltip', () => {
window.URL.revokeObjectURL = originalRevokeObjectURL
clickSpy.mockRestore()
})
it('exports historical image rows with image billing mode derived from image_count', async () => {
const exportedLogs = [
{
request_id: 'req-user-export-legacy-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
reasoning_effort: null,
api_key: { name: 'demo-key' },
},
]
query.mockResolvedValue({
items: exportedLogs,
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
let exportedBlob: Blob | null = null
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
exportedBlob = blob as Blob
return 'blob:usage-export'
}) as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
await setupState.exportToCSV()
expect(exportedBlob).not.toBeNull()
const csv = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsText(exportedBlob as Blob)
})
expect(csv).toContain('Billing Mode')
expect(csv).toContain('Image')
expect(csv).not.toContain(',Token,0,0,0,0,')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
clickSpy.mockRestore()
})
it('does not display a 2K fallback for historical image rows with missing size', async () => {
query.mockResolvedValue({
items: [
{
request_id: 'req-user-legacy-missing-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 1,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
},
],
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image')
expect(text).toContain('not recorded')
expect(text).not.toContain('(2K)')
})
it('shows image billing metadata in the user cost tooltip', async () => {
query.mockResolvedValue({
items: [],
total: 0,
pages: 0,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 0,
total_tokens: 0,
total_cost: 0,
avg_duration_ms: 0,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
setupState.tooltipData = {
request_id: 'req-user-output-image',
actual_cost: 0.8,
total_cost: 0.8,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
billing_mode: null,
image_count: 2,
image_size: '4K',
image_input_size: '1024x1024',
image_output_size: '3840x2160',
image_size_source: 'output',
image_size_breakdown: { '4K': 2 },
}
setupState.tooltipVisible = true
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image count')
expect(text).toContain('Billing size')
expect(text).toContain('4K')
expect(text).toContain('Size source')
expect(text).toContain('Upstream output')
expect(text).toContain('Input size')
expect(text).toContain('1024x1024')
expect(text).toContain('Output size')
expect(text).toContain('3840x2160')
expect(text).toContain('4K x 2')
})
})