mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 10:16:52 +00:00
fix(billing): 修复 OpenAI fast 档位计费并补齐展示
- 打通 service_tier 在 OpenAI HTTP、WS、passthrough 与 usage 记录中的传递 - 修正 priority/flex 计费逻辑,并将 fast 归一化为 priority - 在用户端和管理端补齐服务档位与计费明细展示 - 补齐前后端测试,并修复 WS 限流信号重复持久化导致的全量回归失败 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -228,6 +228,14 @@
|
||||
<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 && 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>
|
||||
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
|
||||
@@ -238,6 +246,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rate and Summary -->
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.serviceTier') }}</span>
|
||||
<span class="font-semibold text-cyan-300">{{ getUsageServiceTierLabel(tooltipData?.service_tier, t) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
|
||||
@@ -271,6 +283,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
|
||||
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
|
||||
import { resolveUsageRequestType } from '@/utils/usageRequestType'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
|
||||
111
frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts
Normal file
111
frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import UsageTable from '../UsageTable.vue'
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'usage.costDetails': 'Cost Breakdown',
|
||||
'admin.usage.inputCost': 'Input Cost',
|
||||
'admin.usage.outputCost': 'Output Cost',
|
||||
'admin.usage.cacheCreationCost': 'Cache Creation Cost',
|
||||
'admin.usage.cacheReadCost': 'Cache Read Cost',
|
||||
'usage.inputTokenPrice': 'Input price',
|
||||
'usage.outputTokenPrice': 'Output price',
|
||||
'usage.perMillionTokens': '/ 1M tokens',
|
||||
'usage.serviceTier': 'Service tier',
|
||||
'usage.serviceTierPriority': 'Fast',
|
||||
'usage.serviceTierFlex': 'Flex',
|
||||
'usage.serviceTierStandard': 'Standard',
|
||||
'usage.rate': 'Rate',
|
||||
'usage.accountMultiplier': 'Account rate',
|
||||
'usage.original': 'Original',
|
||||
'usage.userBilled': 'User billed',
|
||||
'usage.accountBilled': 'Account billed',
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => messages[key] ?? key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const DataTableStub = {
|
||||
props: ['data'],
|
||||
template: `
|
||||
<div>
|
||||
<div v-for="row in data" :key="row.request_id">
|
||||
<slot name="cell-cost" :row="row" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
describe('admin UsageTable tooltip', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 20,
|
||||
left: 20,
|
||||
right: 120,
|
||||
bottom: 40,
|
||||
width: 100,
|
||||
height: 20,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
|
||||
it('shows service tier and billing breakdown in cost tooltip', async () => {
|
||||
const row = {
|
||||
request_id: 'req-admin-1',
|
||||
actual_cost: 0.092883,
|
||||
total_cost: 0.092883,
|
||||
account_rate_multiplier: 1,
|
||||
rate_multiplier: 1,
|
||||
service_tier: 'priority',
|
||||
input_cost: 0.020285,
|
||||
output_cost: 0.00303,
|
||||
cache_creation_cost: 0,
|
||||
cache_read_cost: 0.069568,
|
||||
input_tokens: 4057,
|
||||
output_tokens: 101,
|
||||
}
|
||||
|
||||
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('Service tier')
|
||||
expect(text).toContain('Fast')
|
||||
expect(text).toContain('Rate')
|
||||
expect(text).toContain('1.00x')
|
||||
expect(text).toContain('Account rate')
|
||||
expect(text).toContain('User billed')
|
||||
expect(text).toContain('Account billed')
|
||||
expect(text).toContain('$0.092883')
|
||||
expect(text).toContain('$5.0000 / 1M tokens')
|
||||
expect(text).toContain('$30.0000 / 1M tokens')
|
||||
expect(text).toContain('$0.069568')
|
||||
})
|
||||
})
|
||||
20
frontend/src/i18n/__tests__/usageServiceTierLocales.spec.ts
Normal file
20
frontend/src/i18n/__tests__/usageServiceTierLocales.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import en from '../locales/en'
|
||||
import zh from '../locales/zh'
|
||||
|
||||
describe('usage service tier locale keys', () => {
|
||||
it('contains zh labels for service tier tooltip', () => {
|
||||
expect(zh.usage.serviceTier).toBe('服务档位')
|
||||
expect(zh.usage.serviceTierPriority).toBe('Fast')
|
||||
expect(zh.usage.serviceTierFlex).toBe('Flex')
|
||||
expect(zh.usage.serviceTierStandard).toBe('Standard')
|
||||
})
|
||||
|
||||
it('contains en labels for service tier tooltip', () => {
|
||||
expect(en.usage.serviceTier).toBe('Service tier')
|
||||
expect(en.usage.serviceTierPriority).toBe('Fast')
|
||||
expect(en.usage.serviceTierFlex).toBe('Flex')
|
||||
expect(en.usage.serviceTierStandard).toBe('Standard')
|
||||
})
|
||||
})
|
||||
@@ -722,8 +722,15 @@ export default {
|
||||
unknown: 'Unknown',
|
||||
in: 'In',
|
||||
out: 'Out',
|
||||
inputTokenPrice: 'Input price',
|
||||
outputTokenPrice: 'Output price',
|
||||
perMillionTokens: '/ 1M tokens',
|
||||
cacheRead: 'Read',
|
||||
cacheWrite: 'Write',
|
||||
serviceTier: 'Service tier',
|
||||
serviceTierPriority: 'Fast',
|
||||
serviceTierFlex: 'Flex',
|
||||
serviceTierStandard: 'Standard',
|
||||
rate: 'Rate',
|
||||
original: 'Original',
|
||||
billed: 'Billed',
|
||||
|
||||
@@ -727,8 +727,15 @@ export default {
|
||||
unknown: '未知',
|
||||
in: '输入',
|
||||
out: '输出',
|
||||
inputTokenPrice: '输入单价',
|
||||
outputTokenPrice: '输出单价',
|
||||
perMillionTokens: '/ 1M Token',
|
||||
cacheRead: '读取',
|
||||
cacheWrite: '写入',
|
||||
serviceTier: '服务档位',
|
||||
serviceTierPriority: 'Fast',
|
||||
serviceTierFlex: 'Flex',
|
||||
serviceTierStandard: 'Standard',
|
||||
rate: '倍率',
|
||||
original: '原始',
|
||||
billed: '计费',
|
||||
|
||||
@@ -927,6 +927,7 @@ export interface UsageLog {
|
||||
account_id: number | null
|
||||
request_id: string
|
||||
model: string
|
||||
service_tier?: string | null
|
||||
reasoning_effort?: string | null
|
||||
|
||||
group_id: number | null
|
||||
|
||||
39
frontend/src/utils/__tests__/usageServiceTier.spec.ts
Normal file
39
frontend/src/utils/__tests__/usageServiceTier.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatUsageServiceTier, getUsageServiceTierLabel, normalizeUsageServiceTier } from '@/utils/usageServiceTier'
|
||||
|
||||
describe('usageServiceTier utils', () => {
|
||||
it('normalizes fast/default aliases', () => {
|
||||
expect(normalizeUsageServiceTier('fast')).toBe('priority')
|
||||
expect(normalizeUsageServiceTier(' default ')).toBe('standard')
|
||||
expect(normalizeUsageServiceTier('STANDARD')).toBe('standard')
|
||||
})
|
||||
|
||||
it('preserves supported tiers', () => {
|
||||
expect(normalizeUsageServiceTier('priority')).toBe('priority')
|
||||
expect(normalizeUsageServiceTier('flex')).toBe('flex')
|
||||
})
|
||||
|
||||
it('formats empty values as standard', () => {
|
||||
expect(formatUsageServiceTier()).toBe('standard')
|
||||
expect(formatUsageServiceTier('')).toBe('standard')
|
||||
})
|
||||
|
||||
it('passes through unknown non-empty tiers for display fallback', () => {
|
||||
expect(normalizeUsageServiceTier('custom-tier')).toBe('custom-tier')
|
||||
expect(formatUsageServiceTier('custom-tier')).toBe('custom-tier')
|
||||
})
|
||||
|
||||
it('maps tiers to translated labels', () => {
|
||||
const translate = (key: string) => ({
|
||||
'usage.serviceTierPriority': 'Fast',
|
||||
'usage.serviceTierFlex': 'Flex',
|
||||
'usage.serviceTierStandard': 'Standard',
|
||||
})[key] ?? key
|
||||
|
||||
expect(getUsageServiceTierLabel('fast', translate)).toBe('Fast')
|
||||
expect(getUsageServiceTierLabel('flex', translate)).toBe('Flex')
|
||||
expect(getUsageServiceTierLabel(undefined, translate)).toBe('Standard')
|
||||
expect(getUsageServiceTierLabel('custom-tier', translate)).toBe('custom-tier')
|
||||
})
|
||||
})
|
||||
49
frontend/src/utils/usagePricing.ts
Normal file
49
frontend/src/utils/usagePricing.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const TOKENS_PER_MILLION = 1_000_000
|
||||
|
||||
interface TokenPriceFormatOptions {
|
||||
fractionDigits?: number
|
||||
withCurrencySymbol?: boolean
|
||||
emptyValue?: string
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
export function calculateTokenUnitPrice(
|
||||
cost: number | null | undefined,
|
||||
tokens: number | null | undefined
|
||||
): number | null {
|
||||
if (!isFiniteNumber(cost) || !isFiniteNumber(tokens) || tokens <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return cost / tokens
|
||||
}
|
||||
|
||||
export function calculateTokenPricePerMillion(
|
||||
cost: number | null | undefined,
|
||||
tokens: number | null | undefined
|
||||
): number | null {
|
||||
const unitPrice = calculateTokenUnitPrice(cost, tokens)
|
||||
if (unitPrice == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return unitPrice * TOKENS_PER_MILLION
|
||||
}
|
||||
|
||||
export function formatTokenPricePerMillion(
|
||||
cost: number | null | undefined,
|
||||
tokens: number | null | undefined,
|
||||
options: TokenPriceFormatOptions = {}
|
||||
): string {
|
||||
const pricePerMillion = calculateTokenPricePerMillion(cost, tokens)
|
||||
if (pricePerMillion == null) {
|
||||
return options.emptyValue ?? '-'
|
||||
}
|
||||
|
||||
const fractionDigits = options.fractionDigits ?? 4
|
||||
const formatted = pricePerMillion.toFixed(fractionDigits)
|
||||
return options.withCurrencySymbol == false ? formatted : `$${formatted}`
|
||||
}
|
||||
25
frontend/src/utils/usageServiceTier.ts
Normal file
25
frontend/src/utils/usageServiceTier.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function normalizeUsageServiceTier(serviceTier?: string | null): string | null {
|
||||
const value = serviceTier?.trim().toLowerCase()
|
||||
if (!value) return null
|
||||
if (value === 'fast') return 'priority'
|
||||
if (value === 'default' || value === 'standard') return 'standard'
|
||||
if (value === 'priority' || value === 'flex') return value
|
||||
return value
|
||||
}
|
||||
|
||||
export function formatUsageServiceTier(serviceTier?: string | null): string {
|
||||
const normalized = normalizeUsageServiceTier(serviceTier)
|
||||
if (!normalized) return 'standard'
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function getUsageServiceTierLabel(
|
||||
serviceTier: string | null | undefined,
|
||||
translate: (key: string) => string,
|
||||
): string {
|
||||
const tier = formatUsageServiceTier(serviceTier)
|
||||
if (tier === 'priority') return translate('usage.serviceTierPriority')
|
||||
if (tier === 'flex') return translate('usage.serviceTierFlex')
|
||||
if (tier === 'standard') return translate('usage.serviceTierStandard')
|
||||
return tier
|
||||
}
|
||||
@@ -426,6 +426,14 @@
|
||||
<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 && 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>
|
||||
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
|
||||
@@ -436,6 +444,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rate and Summary -->
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.serviceTier') }}</span>
|
||||
<span class="font-semibold text-cyan-300">{{ getUsageServiceTierLabel(tooltipData?.service_tier, t) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400"
|
||||
@@ -478,6 +490,8 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
|
||||
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
|
||||
import { resolveUsageRequestType } from '@/utils/usageRequestType'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
266
frontend/src/views/user/__tests__/UsageView.spec.ts
Normal file
266
frontend/src/views/user/__tests__/UsageView.spec.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import UsageView from '../UsageView.vue'
|
||||
|
||||
const { query, getStatsByDateRange, list, showError, showWarning, showSuccess, showInfo } = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
getStatsByDateRange: vi.fn(),
|
||||
list: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'usage.costDetails': 'Cost Breakdown',
|
||||
'admin.usage.inputCost': 'Input Cost',
|
||||
'admin.usage.outputCost': 'Output Cost',
|
||||
'admin.usage.cacheCreationCost': 'Cache Creation Cost',
|
||||
'admin.usage.cacheReadCost': 'Cache Read Cost',
|
||||
'usage.inputTokenPrice': 'Input price',
|
||||
'usage.outputTokenPrice': 'Output price',
|
||||
'usage.perMillionTokens': '/ 1M tokens',
|
||||
'usage.serviceTier': 'Service tier',
|
||||
'usage.serviceTierPriority': 'Fast',
|
||||
'usage.serviceTierFlex': 'Flex',
|
||||
'usage.serviceTierStandard': 'Standard',
|
||||
'usage.rate': 'Rate',
|
||||
'usage.original': 'Original',
|
||||
'usage.billed': 'Billed',
|
||||
'usage.allApiKeys': 'All API Keys',
|
||||
'usage.apiKeyFilter': 'API Key',
|
||||
'usage.model': 'Model',
|
||||
'usage.reasoningEffort': 'Reasoning Effort',
|
||||
'usage.type': 'Type',
|
||||
'usage.tokens': 'Tokens',
|
||||
'usage.cost': 'Cost',
|
||||
'usage.firstToken': 'First Token',
|
||||
'usage.duration': 'Duration',
|
||||
'usage.time': 'Time',
|
||||
'usage.userAgent': 'User Agent',
|
||||
}
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
usageAPI: {
|
||||
query,
|
||||
getStatsByDateRange,
|
||||
},
|
||||
keysAPI: {
|
||||
list,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ showError, showWarning, showSuccess, showInfo }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => messages[key] ?? key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||
const TablePageLayoutStub = {
|
||||
template: '<div><slot name="actions" /><slot name="filters" /><slot /></div>',
|
||||
}
|
||||
|
||||
describe('user UsageView tooltip', () => {
|
||||
beforeEach(() => {
|
||||
query.mockReset()
|
||||
getStatsByDateRange.mockReset()
|
||||
list.mockReset()
|
||||
showError.mockReset()
|
||||
showWarning.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showInfo.mockReset()
|
||||
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 20,
|
||||
left: 20,
|
||||
right: 120,
|
||||
bottom: 40,
|
||||
width: 100,
|
||||
height: 20,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
|
||||
;(globalThis as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
})
|
||||
|
||||
it('shows fast service tier and unit prices in user tooltip', async () => {
|
||||
query.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
request_id: 'req-user-1',
|
||||
actual_cost: 0.092883,
|
||||
total_cost: 0.092883,
|
||||
rate_multiplier: 1,
|
||||
service_tier: 'priority',
|
||||
input_cost: 0.020285,
|
||||
output_cost: 0.00303,
|
||||
cache_creation_cost: 0,
|
||||
cache_read_cost: 0.069568,
|
||||
input_tokens: 4057,
|
||||
output_tokens: 101,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 278272,
|
||||
cache_creation_5m_tokens: 0,
|
||||
cache_creation_1h_tokens: 0,
|
||||
image_count: 0,
|
||||
image_size: null,
|
||||
first_token_ms: null,
|
||||
duration_ms: 1,
|
||||
created_at: '2026-03-08T00:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
getStatsByDateRange.mockResolvedValue({
|
||||
total_requests: 1,
|
||||
total_tokens: 100,
|
||||
total_cost: 0.1,
|
||||
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,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
setupState.tooltipData = {
|
||||
request_id: 'req-user-1',
|
||||
actual_cost: 0.092883,
|
||||
total_cost: 0.092883,
|
||||
rate_multiplier: 1,
|
||||
service_tier: 'priority',
|
||||
input_cost: 0.020285,
|
||||
output_cost: 0.00303,
|
||||
cache_creation_cost: 0,
|
||||
cache_read_cost: 0.069568,
|
||||
input_tokens: 4057,
|
||||
output_tokens: 101,
|
||||
}
|
||||
setupState.tooltipVisible = true
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Service tier')
|
||||
expect(text).toContain('Fast')
|
||||
expect(text).toContain('Rate')
|
||||
expect(text).toContain('1.00x')
|
||||
expect(text).toContain('Billed')
|
||||
expect(text).toContain('$0.092883')
|
||||
expect(text).toContain('$5.0000 / 1M tokens')
|
||||
expect(text).toContain('$30.0000 / 1M tokens')
|
||||
})
|
||||
|
||||
it('exports csv with input and output unit price columns', async () => {
|
||||
const exportedLogs = [
|
||||
{
|
||||
request_id: 'req-user-export',
|
||||
actual_cost: 0.092883,
|
||||
total_cost: 0.092883,
|
||||
rate_multiplier: 1,
|
||||
service_tier: 'priority',
|
||||
input_cost: 0.020285,
|
||||
output_cost: 0.00303,
|
||||
cache_creation_cost: 0.000001,
|
||||
cache_read_cost: 0.069568,
|
||||
input_tokens: 4057,
|
||||
output_tokens: 101,
|
||||
cache_creation_tokens: 4,
|
||||
cache_read_tokens: 278272,
|
||||
cache_creation_5m_tokens: 0,
|
||||
cache_creation_1h_tokens: 0,
|
||||
image_count: 0,
|
||||
image_size: null,
|
||||
first_token_ms: 12,
|
||||
duration_ms: 345,
|
||||
created_at: '2026-03-08T00:00:00Z',
|
||||
model: 'gpt-5.4',
|
||||
reasoning_effort: null,
|
||||
api_key: { name: 'demo-key' },
|
||||
},
|
||||
]
|
||||
|
||||
query.mockResolvedValue({
|
||||
items: exportedLogs,
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
getStatsByDateRange.mockResolvedValue({
|
||||
total_requests: 1,
|
||||
total_tokens: 100,
|
||||
total_cost: 0.1,
|
||||
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,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
await setupState.exportToCSV()
|
||||
|
||||
expect(exportedBlob).not.toBeNull()
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(showSuccess).toHaveBeenCalled()
|
||||
|
||||
window.URL.createObjectURL = originalCreateObjectURL
|
||||
window.URL.revokeObjectURL = originalRevokeObjectURL
|
||||
clickSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user