mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 16:09:34 +00:00
Fix image billing size normalization
This commit is contained in:
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user