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:
yangjianbo
2026-03-08 23:22:28 +08:00
parent bcb6444f89
commit 87f4ed591e
29 changed files with 1417 additions and 47 deletions

View File

@@ -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()

View 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()
})
})