+
+
{{ t('usage.imageCount') }}
- {{ tooltipData.image_count }}{{ t('usage.imageUnit') }} ({{ tooltipData.image_size || '2K' }})
+ {{ tooltipData.image_count }}{{ t('usage.imageUnit') }}
+
+
+ {{ t('usage.imageBillingSize') }}
+ {{ formatImageBillingSize(tooltipData, t) }}
+
+
+ {{ t('usage.imageSizeSource') }}
+ {{ formatImageSizeSource(tooltipData, t) }}
+
+
+ {{ t('usage.imageInputSize') }}
+ {{ formatImageInputSize(tooltipData, t) }}
+
+
+ {{ t('usage.imageOutputSize') }}
+ {{ formatImageOutputSize(tooltipData, t) }}
+
+
+ {{ t('usage.imageSizeBreakdown') }}
+ {{ formatImageSizeBreakdown(tooltipData) }}
{{ t('usage.imageUnitPrice') }}
@@ -473,6 +482,17 @@
${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
+
+
+
+ {{ t('usage.inputTokenPrice') }}
+ {{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}
+
+
+ {{ t('usage.outputTokenPrice') }}
+ {{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}
+
+
{{ t('usage.unitPrice') }}
${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}
@@ -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
| null | undefined): boolean => {
+ return (row?.image_count ?? 0) > 0
+}
+
+const getDisplayBillingMode = (row: Pick | 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,
diff --git a/frontend/src/views/user/__tests__/UsageView.spec.ts b/frontend/src/views/user/__tests__/UsageView.spec.ts
index 5f87619c..011b96c8 100644
--- a/frontend/src/views/user/__tests__/UsageView.spec.ts
+++ b/frontend/src/views/user/__tests__/UsageView.spec.ts
@@ -41,6 +41,26 @@ const messages: Record = {
'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: '
' }
const TablePageLayoutStub = {
- template: '
',
+ template: '
',
+}
+const DataTableStub = {
+ props: ['data'],
+ template: `
+
+ `,
}
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((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')
+ })
})