fix(usage): unify OpenAI Live and WebSocket records

This commit is contained in:
ZheFox
2026-08-21 11:53:53 +08:00
parent 9996e75a34
commit acde38b8e7
32 changed files with 629 additions and 95 deletions
@@ -258,7 +258,7 @@
<div class="flex flex-wrap gap-2 text-muted-foreground/60">
<span>Provider: {{ replayResult.mapping.target_provider }}</span>
<span>Endpoint: {{ replayResult.mapping.target_endpoint_id }}</span>
<span>Format: {{ replayResult.mapping.target_api_format || '-' }}</span>
<span>Format: {{ formatApiFormat(replayResult.mapping.target_api_format) }}</span>
</div>
<div
v-if="replayResult.mapping.mapping_source && replayResult.mapping.mapping_source !== 'none'"
@@ -326,6 +326,7 @@ import { dashboardApi, type ReplayResponse, type RequestDetail } from '@/api/das
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getProviderKeys } from '@/api/endpoints/keys'
import type { EndpointAPIKey } from '@/api/endpoints/types'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { useClipboard } from '@/composables/useClipboard'
import { useEscapeKey } from '@/composables/useEscapeKey'
import Card from '@/components/ui/card.vue'
@@ -42,12 +42,14 @@
<Badge
v-if="detail?.status_code === 200"
variant="success"
data-request-lifecycle-status
>
{{ detail.status_code }}
</Badge>
<Badge
v-else-if="detail"
variant="destructive"
data-request-lifecycle-status
>
{{ detail.status_code }}
</Badge>
@@ -58,27 +60,29 @@
:title="formatUsageWebSocketTransportTitle(detail)"
class="border-sky-500/50 text-xs text-sky-600 dark:text-sky-400"
>
{{ formatUsageWebSocketTransportLabel(detail) }}
WS
</Badge>
<Badge
v-else-if="detail && resolveUsageStreamLabelSegments(detail).hasConversion"
data-usage-transport="http"
:variant="streamBadgeVariant(resolveUsageStreamLabelSegments(detail).client === '流式')"
:class="streamBadgeVariant(resolveUsageStreamLabelSegments(detail).client === '流式') === 'secondary'
? 'text-xs inline-flex items-center gap-1'
: 'text-xs inline-flex items-center gap-1 border-border/60 text-muted-foreground'"
>
<span>{{ resolveUsageStreamLabelSegments(detail).client }}</span>
<span>HTTP {{ resolveUsageStreamLabelSegments(detail).client }}</span>
<span class="opacity-60">→</span>
<span>{{ resolveUsageStreamLabelSegments(detail).upstream }}</span>
<span>HTTP {{ resolveUsageStreamLabelSegments(detail).upstream }}</span>
</Badge>
<Badge
v-else-if="detail"
data-usage-transport="http"
:variant="streamBadgeVariant(isUsageUpstreamStream(detail))"
:class="streamBadgeVariant(isUsageUpstreamStream(detail)) === 'secondary'
? 'text-xs'
: 'text-xs border-border/60 text-muted-foreground'"
>
{{ formatUsageStreamLabel(detail) }}
HTTP {{ formatUsageStreamLabel(detail) }}
</Badge>
</div>
<div class="flex items-center gap-1 shrink-0">
@@ -905,10 +909,7 @@ import {
resolveDisplayRequestStatus,
resolveUsageStreamLabelSegments,
} from '../utils/status'
import {
formatUsageWebSocketTransportLabel,
formatUsageWebSocketTransportTitle,
} from '../utils/websocketTransport'
import { formatUsageWebSocketTransportTitle } from '../utils/websocketTransport'
import { isCyberPolicyError } from '../utils/cyberError'
import {
mergeUsageRecordErrorMessage,
@@ -150,13 +150,13 @@
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部状态
全部类型
</SelectItem>
<SelectItem value="stream">
流式
HTTP 流式
</SelectItem>
<SelectItem value="standard">
标准
HTTP 标准
</SelectItem>
<SelectItem value="websocket">
WebSocket (WS)
@@ -287,7 +287,7 @@
:title="getWebSocketTransportTitle(record)"
class="whitespace-nowrap border-sky-500/50 text-sky-600 dark:text-sky-400 text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
{{ getWebSocketTransportLabel(record) }}
WS
</Badge>
<Badge
v-else-if="getStreamModeSegments(record).hasConversion"
@@ -878,7 +878,7 @@
:title="getWebSocketTransportTitle(record)"
class="whitespace-nowrap border-sky-500/50 text-sky-600 dark:text-sky-400"
>
{{ getWebSocketTransportLabel(record) }}
WS
</Badge>
<Badge
v-else-if="getStreamModeSegments(record).hasConversion"
@@ -1116,10 +1116,7 @@ import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-for
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
import { formatServiceTierFact } from '../utils/service-tier'
import { isCyberPolicyError } from '../utils/cyberError'
import {
formatUsageWebSocketTransportLabel as getWebSocketTransportLabel,
formatUsageWebSocketTransportTitle as getWebSocketTransportTitle,
} from '../utils/websocketTransport'
import { formatUsageWebSocketTransportTitle as getWebSocketTransportTitle } from '../utils/websocketTransport'
import type { DateRangeParams, UsageRecord } from '../types'
import { MultiSelect, TimeRangePicker } from '@/components/common'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
@@ -1364,9 +1361,9 @@ const apiFormatFilterOptions = computed<FilterOption[]>(() => [
])
const statusFilterOptions: FilterOption[] = [
{ value: '__all__', label: '全部状态' },
{ value: 'stream', label: '流式' },
{ value: 'standard', label: '标准' },
{ value: '__all__', label: '全部类型' },
{ value: 'stream', label: 'HTTP 流式' },
{ value: 'standard', label: 'HTTP 标准' },
{ value: 'websocket', label: 'WebSocket (WS)' },
{ value: 'active', label: '活跃' },
{ value: 'failed', label: '失败' },
@@ -1620,7 +1617,7 @@ function getApiFormatTooltip(record: UsageRecord): string {
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n${conversionType}`
}
return record.api_format
return displayFormat
}
// 获取实际使用的模型(优先 target_model,其次列表接口下发的 model_version)
@@ -113,7 +113,7 @@ function buildFastTierDetail(): RequestDetail {
}
describe('RequestDetailDrawer settlement pricing', () => {
it('labels an unmetered Codex Live WebSocket detail without rendering zero usage as billing', async () => {
it('labels an unmetered OpenAI Live WebSocket detail without rendering zero usage as billing', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-live-ws-1',
@@ -152,8 +152,11 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
const transport = document.body.querySelector<HTMLElement>('[data-usage-transport="websocket"]')
expect(transport?.textContent?.trim()).toBe('Live WS')
expect(transport?.title).toBe('Codex Live Sideband WebSocket')
expect(transport?.textContent?.trim()).toBe('WS')
expect(transport?.title).toBe('OpenAI Live Sideband WebSocket')
expect(document.body.querySelector('[data-usage-transport="http"]')).toBeNull()
expect(document.body.querySelector('[data-request-lifecycle-status]')?.textContent?.trim())
.toBe('200')
expect(document.body.querySelector('[data-request-detail-usage-unavailable]')).not.toBeNull()
})
@@ -198,6 +201,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await nextTick()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-usage-transport="http"]')?.textContent?.trim())
.toBe('HTTP 流式')
expect(document.body.textContent).toContain('10.12s / 10.63s')
expect(document.body.textContent).toContain('95.1tps')
expect(document.body.textContent).not.toContain('98.8tps')
@@ -228,6 +233,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await nextTick()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-usage-transport="http"]')?.textContent?.trim())
.toBe('HTTP 标准')
expect(document.body.textContent).toContain('输入 $0.1/M')
expect(document.body.textContent).toContain('输出 -')
})
@@ -350,15 +350,30 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('gpt-5')
})
it('shows WS in the type column for completed WebSocket usage records', () => {
it('keeps the type label as WS after Responses detail enriches transport metadata', () => {
const root = mountUsageRecordsTable([buildRecord({
is_websocket: true,
websocket_transport: 'responses',
status: 'completed',
})])
const badges = [...root.querySelectorAll<HTMLElement>('[data-usage-transport="websocket"]')]
expect(badges.length).toBeGreaterThan(0)
expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true)
expect(badges.every(badge => badge.title === 'OpenAI Responses WebSocket')).toBe(true)
})
it('falls back to WS when transport metadata is unavailable', () => {
const root = mountUsageRecordsTable([buildRecord({
is_websocket: true,
websocket_transport: null,
status: 'completed',
})])
const badges = [...root.querySelectorAll<HTMLElement>('[data-usage-transport="websocket"]')]
expect(badges.length).toBeGreaterThan(0)
expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true)
expect(badges.every(badge => badge.title === 'WebSocket')).toBe(true)
})
it('offers a WebSocket type filter in both filter menus', () => {
@@ -367,9 +382,11 @@ describe('UsageRecordsTable', () => {
const labels = [...root.querySelectorAll<HTMLElement>('div')]
.filter(element => element.textContent?.trim() === 'WebSocket (WS)')
expect(labels.length).toBeGreaterThanOrEqual(2)
expect(root.textContent).toContain('HTTP 流式')
expect(root.textContent).toContain('HTTP 标准')
})
it('labels Codex Live transport rows without inventing token or cost usage', () => {
it('keeps OpenAI Live transport rows under the single WS type without inventing usage', () => {
const root = mountUsageRecordsTable([buildRecord({
is_websocket: true,
websocket_transport: 'codex_live_direct',
@@ -382,8 +399,8 @@ describe('UsageRecordsTable', () => {
const badges = [...root.querySelectorAll<HTMLElement>('[data-usage-transport="websocket"]')]
expect(badges.length).toBeGreaterThan(0)
expect(badges.every(badge => badge.textContent?.trim() === 'Live WS')).toBe(true)
expect(badges.every(badge => badge.title === 'Codex Live 直连 WebSocket')).toBe(true)
expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true)
expect(badges.every(badge => badge.title === 'OpenAI Live 直连 WebSocket')).toBe(true)
const unavailableTokens = [...root.querySelectorAll<HTMLElement>('[data-usage-unavailable="tokens"]')]
const unavailableCosts = [...root.querySelectorAll<HTMLElement>('[data-usage-unavailable="cost"]')]
@@ -393,7 +410,7 @@ describe('UsageRecordsTable', () => {
expect(unavailableCosts.every(element => element.textContent?.trim() === '不可用')).toBe(true)
})
it('labels OpenAI Realtime WebSocket rows consistently in both layouts', () => {
it('keeps OpenAI Realtime rows under the single WS type in both layouts', () => {
const root = mountUsageRecordsTable([buildRecord({
is_websocket: true,
websocket_transport: 'openai_realtime',
@@ -401,7 +418,7 @@ describe('UsageRecordsTable', () => {
const badges = [...root.querySelectorAll<HTMLElement>('[data-usage-transport="websocket"]')]
expect(badges.length).toBeGreaterThan(0)
expect(badges.every(badge => badge.textContent?.trim() === 'Realtime WS')).toBe(true)
expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true)
expect(badges.every(badge => badge.title === 'OpenAI Realtime WebSocket')).toBe(true)
})
@@ -603,6 +620,18 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('Gemini Embedding')
expect(root.textContent).toContain('Jina Embedding')
expect(root.textContent).toContain('Doubao Embedding')
const liveLabels = [...root.querySelectorAll<HTMLElement>('div')]
.filter(element => element.textContent?.trim() === 'OpenAI Live')
expect(liveLabels.length).toBeGreaterThanOrEqual(2)
expect(root.querySelector('[data-filter-value="codex:live"]')).not.toBeNull()
})
it('does not expose the canonical Codex Live id in row labels or tooltips', () => {
const root = mountUsageRecordsTable([buildRecord({ api_format: 'codex:live' })])
expect(root.textContent).toContain('OpenAI Live')
expect(root.textContent).not.toContain('codex:live')
expect(root.querySelector('[title="OpenAI Live"]')).not.toBeNull()
})
it('emits hide unknown toggle changes', () => {
@@ -143,11 +143,12 @@ describe('useUsageData', () => {
await loadRecords(
{ page: 2, pageSize: 20 },
{ status: 'websocket' },
{ api_format: 'codex:live', status: 'websocket' },
{ preset: 'today', tz_offset_minutes: 0 },
)
expect(meGetUsageMock).toHaveBeenCalledWith(expect.objectContaining({
api_format: 'codex:live',
status: 'websocket',
limit: 20,
offset: 20,
@@ -393,6 +393,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.status) {
params.status = filters.status
}
if (filters?.api_format) {
params.api_format = filters.api_format
}
if (isAdminPage.value) {
// 管理员页面:使用管理员 API
@@ -405,9 +408,6 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.provider) {
params.provider = filters.provider
}
if (filters?.api_format) {
params.api_format = filters.api_format
}
if (filters?.client_family) {
params.client_family = filters.client_family
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import {
isUserLocalOnlyRecordStatus,
shouldUseServerUserRecordFilters,
} from '../recordFilterPolicy'
describe('normal-user usage record filter policy', () => {
it.each(['has_retry', 'has_fallback'] as const)(
'keeps %s local even when combined with server-supported filters',
(status) => {
expect(isUserLocalOnlyRecordStatus(status)).toBe(true)
expect(shouldUseServerUserRecordFilters({
search: 'production',
apiFormat: 'codex:live',
status,
})).toBe(false)
},
)
it.each([
{ search: '', apiFormat: '__all__', status: 'websocket' as const },
{ search: '', apiFormat: 'codex:live', status: '__all__' as const },
{ search: 'production', apiFormat: '__all__', status: '__all__' as const },
{ search: 'production', apiFormat: 'codex:live', status: 'websocket' as const },
])('uses server pagination for supported filter combination %#', (filters) => {
expect(shouldUseServerUserRecordFilters(filters)).toBe(true)
})
it('keeps the unfiltered normal-user list on its existing local pagination path', () => {
expect(shouldUseServerUserRecordFilters({
search: ' ',
apiFormat: '__all__',
status: '__all__',
})).toBe(false)
})
})
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import type { UsageRecord } from '../../types'
import { matchesUsageRecordSearch } from '../recordSearch'
function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
return {
id: 'usage-1',
model: 'gpt-5.6-sol',
provider: 'OpenAI',
api_key: {
id: 'key-1',
name: 'Production Key',
display: 'Production Key',
},
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
cost: 0,
is_stream: false,
created_at: '2026-08-21T00:00:00Z',
...overrides,
}
}
describe('matchesUsageRecordSearch', () => {
it('matches normal-user key and model search terms case-insensitively', () => {
const usage = record()
expect(matchesUsageRecordSearch(usage, 'production')).toBe(true)
expect(matchesUsageRecordSearch(usage, 'GPT-5.6')).toBe(true)
expect(matchesUsageRecordSearch(usage, 'production gpt-5.6')).toBe(true)
})
it('requires every whitespace-delimited search term to match the record', () => {
const usage = record()
expect(matchesUsageRecordSearch(usage, 'production missing')).toBe(false)
expect(matchesUsageRecordSearch(usage, ' ')).toBe(true)
})
})
@@ -0,0 +1,17 @@
import type { FilterStatusValue } from '../types'
export function isUserLocalOnlyRecordStatus(status: FilterStatusValue): boolean {
return status === 'has_retry' || status === 'has_fallback'
}
export function shouldUseServerUserRecordFilters(input: {
search: string
apiFormat: string
status: FilterStatusValue
}): boolean {
if (isUserLocalOnlyRecordStatus(input.status)) return false
return input.search.trim().length > 0
|| input.apiFormat !== '__all__'
|| input.status !== '__all__'
}
@@ -0,0 +1,41 @@
import type { UsageRecord } from '../types'
type SearchableUsageRecord = Pick<
UsageRecord,
| 'model'
| 'target_model'
| 'provider'
| 'api_key'
| 'api_key_name'
| 'provider_key_name'
>
function normalizedSearchValues(record: SearchableUsageRecord): string[] {
return [
record.model,
record.target_model,
record.provider,
record.api_key?.id,
record.api_key?.name,
record.api_key?.display,
record.api_key_name,
record.provider_key_name,
]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map(value => value.toLocaleLowerCase())
}
export function matchesUsageRecordSearch(
record: SearchableUsageRecord,
search: string,
): boolean {
const keywords = search
.trim()
.toLocaleLowerCase()
.split(/\s+/)
.filter(Boolean)
if (keywords.length === 0) return true
const values = normalizedSearchValues(record)
return keywords.every(keyword => values.some(value => value.includes(keyword)))
}
@@ -8,23 +8,6 @@ function normalizeWebSocketTransport(
return record.websocket_transport?.trim().toLowerCase() ?? ''
}
export function formatUsageWebSocketTransportLabel(
record: UsageWebSocketTransportLike,
): string {
switch (normalizeWebSocketTransport(record)) {
case 'responses':
return 'Responses WS'
case 'codex_live_direct':
case 'codex_live_sideband':
return 'Live WS'
case 'openai_realtime':
case 'realtime':
return 'Realtime WS'
default:
return 'WS'
}
}
export function formatUsageWebSocketTransportTitle(
record: UsageWebSocketTransportLike,
): string {
@@ -33,9 +16,9 @@ export function formatUsageWebSocketTransportTitle(
case 'responses':
return 'OpenAI Responses WebSocket'
case 'codex_live_direct':
return 'Codex Live 直连 WebSocket'
return 'OpenAI Live 直连 WebSocket'
case 'codex_live_sideband':
return 'Codex Live Sideband WebSocket'
return 'OpenAI Live Sideband WebSocket'
case 'openai_realtime':
case 'realtime':
return 'OpenAI Realtime WebSocket'