feat(gateway): add Codex Live and OpenAI Realtime

Implement preflighted Live/Realtime WebSocket transports, protocol-aware authentication, usage auditing, UI filtering, and legacy Codex permission migration.
This commit is contained in:
ZheFox
2026-08-21 04:27:34 +08:00
parent fe38dcd294
commit 2c89202001
105 changed files with 7553 additions and 947 deletions
@@ -52,7 +52,16 @@
{{ detail.status_code }}
</Badge>
<Badge
v-if="detail && resolveUsageStreamLabelSegments(detail).hasConversion"
v-if="detail && isUsageWebSocket(detail)"
variant="outline"
data-usage-transport="websocket"
:title="formatUsageWebSocketTransportTitle(detail)"
class="border-sky-500/50 text-xs text-sky-600 dark:text-sky-400"
>
{{ formatUsageWebSocketTransportLabel(detail) }}
</Badge>
<Badge
v-else-if="detail && resolveUsageStreamLabelSegments(detail).hasConversion"
:variant="streamBadgeVariant(resolveUsageStreamLabelSegments(detail).client === '流式')"
:class="streamBadgeVariant(resolveUsageStreamLabelSegments(detail).client === '流式') === 'secondary'
? 'text-xs inline-flex items-center gap-1'
@@ -162,13 +171,17 @@
<div class="p-3 sm:p-4">
<div class="mb-4 text-sm">
<div class="sm:hidden">
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground/70">{{ priceSourceLabel }}</span>
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground/70">{{ detailPricingLabel }}</span>
</div>
<div class="mt-2 grid grid-cols-[max-content_max-content_max-content_max-content_max-content] items-center gap-x-2 overflow-x-auto whitespace-nowrap text-xs sm:hidden">
<span>
<span class="text-muted-foreground">总费用</span>
<span class="ml-1 font-bold text-green-600 dark:text-green-400">
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
<span
class="ml-1 font-bold"
:class="detailPricingAvailable ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'"
data-request-detail-total-cost
>
{{ detailPricingAvailable ? `$${detailTotalCostForDisplay.toFixed(6)}` : (detailUsageAvailable ? '未计价' : '不可用') }}
</span>
</span>
<span class="text-muted-foreground">|</span>
@@ -185,12 +198,16 @@
</span>
</div>
<div class="hidden flex-wrap items-center gap-x-2 gap-y-1 sm:flex">
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground/70">{{ priceSourceLabel }}</span>
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground/70">{{ detailPricingLabel }}</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">总费用</span>
<span class="ml-1 font-bold text-green-600 dark:text-green-400">
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
<span
class="ml-1 font-bold"
:class="detailPricingAvailable ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'"
data-request-detail-total-cost
>
{{ detailPricingAvailable ? `$${detailTotalCostForDisplay.toFixed(6)}` : (detailUsageAvailable ? '未计价' : '不可用') }}
</span>
</span>
<span class="text-muted-foreground">|</span>
@@ -217,9 +234,27 @@
<!-- 分隔线 -->
<Separator class="mb-4" />
<div
v-if="!detailUsageAvailable"
data-request-detail-usage-unavailable
class="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 p-3 text-xs text-muted-foreground"
>
上游未提供可验证的 token/费用用量,本会话不显示伪造的 0 token 或 0 费用。
</div>
<div
v-else-if="!detailPricingAvailable"
data-request-detail-usage-unpriced
class="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 p-3 text-xs text-muted-foreground"
>
上游提供了权威 token 用量,但包含 Aether 当前计价规则无法安全拆分的音频 token;保留 token 统计并将本会话标记为未计价。
<span v-if="(detail.input_audio_tokens || 0) > 0 || (detail.output_audio_tokens || 0) > 0">
音频输入/输出:{{ detail.input_audio_tokens || 0 }} / {{ detail.output_audio_tokens || 0 }}。
</span>
</div>
<!-- ========== 1. Token分阶段成本 ========== -->
<div
v-if="hasTokenCost"
v-if="detailPricingAvailable && hasTokenCost"
class="space-y-2 mb-3"
>
<!-- 阶梯标题 -->
@@ -424,7 +459,7 @@
<!-- ========== 3. 按次计费 ========== -->
<div
v-if="perRequestCost > 0 && !detail.video_billing"
v-if="detailPricingAvailable && perRequestCost > 0 && !detail.video_billing"
class="space-y-2 mb-3"
>
<div class="flex items-center gap-2 text-xs">
@@ -450,7 +485,7 @@
<!-- ========== 4. 图片输出计费 ========== -->
<div
v-if="hasImageBillingDetail"
v-if="detailPricingAvailable && hasImageBillingDetail"
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30 mb-3"
>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 text-xs">
@@ -495,7 +530,7 @@
<!-- ========== 5. 视频/图像/音频计费(独立隔离,与Token计费风格一致) ========== -->
<div
v-if="detail.video_billing"
v-if="detailPricingAvailable && detail.video_billing"
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30"
>
<!-- 标题行(与阶梯标题行风格一致) -->
@@ -866,9 +901,14 @@ import {
import {
formatUsageStreamLabel,
isUsageUpstreamStream,
isUsageWebSocket,
resolveDisplayRequestStatus,
resolveUsageStreamLabelSegments,
} from '../utils/status'
import {
formatUsageWebSocketTransportLabel,
formatUsageWebSocketTransportTitle,
} from '../utils/websocketTransport'
import { isCyberPolicyError } from '../utils/cyberError'
import {
mergeUsageRecordErrorMessage,
@@ -933,6 +973,12 @@ const emit = defineEmits<{
responseTimeMs?: number | null
firstByteTimeMs?: number | null
isStream?: boolean | null
isWebSocket?: boolean | null
websocketTransport?: string | null
usageAvailable?: boolean | null
usagePricingAvailable?: boolean | null
inputAudioTokens?: number | null
outputAudioTokens?: number | null
upstreamIsStream?: boolean | null
clientRequestedStream?: boolean | null
clientIsStream?: boolean | null
@@ -961,6 +1007,13 @@ const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
const loading = ref(false)
const error = ref<string | null>(null)
const detail = ref<RequestDetail | null>(null)
const detailUsageAvailable = computed(() => detail.value?.usage_available !== false)
const detailPricingAvailable = computed(() => (
detailUsageAvailable.value && detail.value?.usage_pricing_available !== false
))
const detailTotalCostForDisplay = computed(() => (
detail.value ? (detailTotalCost(detail.value) ?? 0) : 0
))
const timelineRef = ref<InstanceType<typeof HorizontalRequestTimeline> | null>(null)
const timelineLoaded = ref(false)
const timelineHasTrace = ref(false)
@@ -1237,6 +1290,12 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
responseTimeMs: nextDetail.response_time_ms ?? undefined,
firstByteTimeMs: nextDetail.first_byte_time_ms ?? null,
isStream: nextDetail.is_stream ?? null,
isWebSocket: nextDetail.is_websocket ?? null,
websocketTransport: nextDetail.websocket_transport ?? null,
usageAvailable: nextDetail.usage_available ?? null,
usagePricingAvailable: nextDetail.usage_pricing_available ?? null,
inputAudioTokens: nextDetail.input_audio_tokens ?? null,
outputAudioTokens: nextDetail.output_audio_tokens ?? null,
upstreamIsStream: nextDetail.upstream_is_stream ?? null,
clientRequestedStream: nextDetail.client_requested_stream ?? null,
clientIsStream: nextDetail.client_is_stream ?? null,
@@ -1858,6 +1917,11 @@ const priceSourceLabel = computed(() => {
if (!detail.value) return '历史定价'
return resolveSettlementPricingSourceLabel(detail.value) ?? '历史定价'
})
const detailPricingLabel = computed(() => {
if (!detailUsageAvailable.value) return '用量不可用'
if (!detailPricingAvailable.value) return '音频用量未计价'
return priceSourceLabel.value
})
const cacheCreationInputTokens5m = computed(() => {
if (!detail.value) return 0
@@ -158,6 +158,9 @@
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="websocket">
WebSocket (WS)
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
@@ -281,9 +284,10 @@
v-else-if="isUsageWebSocket(record)"
variant="outline"
data-usage-transport="websocket"
: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"
>
WS
{{ getWebSocketTransportLabel(record) }}
</Badge>
<Badge
v-else-if="getStreamModeSegments(record).hasConversion"
@@ -308,9 +312,24 @@
</div>
</div>
<div class="flex flex-col items-end flex-shrink-0">
<span class="text-sm text-primary font-semibold leading-5">{{ formatCurrency(record.cost || 0) }}</span>
<span
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
v-if="record.usage_available !== false && record.usage_pricing_available !== false"
class="text-sm text-primary font-semibold leading-5"
>{{ formatCurrency(record.cost || 0) }}</span>
<span
v-else-if="record.usage_available === false"
data-usage-unavailable="cost"
class="text-sm text-muted-foreground font-medium leading-5"
title="上游未提供可验证的 token/费用用量"
>不可用</span>
<span
v-else
data-usage-unpriced="cost"
class="text-sm text-muted-foreground font-medium leading-5"
title="token 用量可验证,但当前计价规则不支持该音频用量分项"
>未计价</span>
<span
v-if="record.usage_available !== false && record.usage_pricing_available !== false && showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-[10px] text-muted-foreground"
>{{ formatCurrency(record.actual_cost) }}</span>
</div>
@@ -380,11 +399,24 @@
:title="hasRecordCacheTokens(record) ? getRecordCacheTokensTitle(record) : undefined"
>
<span class="text-muted-foreground">Tokens</span>
<span class="ml-1">{{ formatTokens(getRecordEffectiveInputTokens(record)) }} / {{ formatTokens(record.output_tokens || 0) }}</span>
<template v-if="hasRecordCacheTokens(record)">
<span
v-if="record.usage_available !== false"
class="ml-1"
>{{ formatTokens(getRecordEffectiveInputTokens(record)) }} / {{ formatTokens(record.output_tokens || 0) }}</span>
<span
v-else
data-usage-unavailable="tokens"
class="ml-1 text-muted-foreground"
title="上游未提供可验证的 token/费用用量"
>不可用</span>
<template v-if="record.usage_available !== false && hasRecordCacheTokens(record)">
<span class="text-muted-foreground"> | </span>
<span>{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }} / {{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}</span>
</template>
<template v-if="record.usage_available !== false && ((record.input_audio_tokens || 0) > 0 || (record.output_audio_tokens || 0) > 0)">
<span class="text-muted-foreground"> | 音频 </span>
<span>{{ formatOptionalTokens(record.input_audio_tokens) }} / {{ formatOptionalTokens(record.output_audio_tokens) }}</span>
</template>
</span>
</div>
</div>
@@ -843,9 +875,10 @@
v-else-if="isUsageWebSocket(record)"
variant="outline"
data-usage-transport="websocket"
:title="getWebSocketTransportTitle(record)"
class="whitespace-nowrap border-sky-500/50 text-sky-600 dark:text-sky-400"
>
WS
{{ getWebSocketTransportLabel(record) }}
</Badge>
<Badge
v-else-if="getStreamModeSegments(record).hasConversion"
@@ -872,44 +905,63 @@
v-if="isColumnVisible('tokens')"
class="py-4 w-[10%]"
>
<div class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums">
<span class="justify-self-end whitespace-nowrap text-right">
{{ formatTokens(getRecordEffectiveInputTokens(record)) }}
</span>
<span class="justify-self-center text-muted-foreground">
/
</span>
<span class="justify-self-start whitespace-nowrap text-left">
{{ formatTokens(record.output_tokens || 0) }}
</span>
</div>
<div class="mt-0.5 grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums text-muted-foreground">
<span
class="justify-self-end whitespace-nowrap text-right"
:class="[
hasPositiveTokens(getRecordCacheReadTokens(record)) ? 'text-foreground/70' : ''
]"
<template v-if="record.usage_available !== false">
<div class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums">
<span class="justify-self-end whitespace-nowrap text-right">
{{ formatTokens(getRecordEffectiveInputTokens(record)) }}
</span>
<span class="justify-self-center text-muted-foreground">
/
</span>
<span class="justify-self-start whitespace-nowrap text-left">
{{ formatTokens(record.output_tokens || 0) }}
</span>
</div>
<div class="mt-0.5 grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums text-muted-foreground">
<span
class="justify-self-end whitespace-nowrap text-right"
:class="[
hasPositiveTokens(getRecordCacheReadTokens(record)) ? 'text-foreground/70' : ''
]"
>
{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }}
</span>
<span class="justify-self-center">
/
</span>
<span
class="justify-self-start whitespace-nowrap text-left"
:class="[
hasPositiveTokens(getRecordCacheCreationTokens(record)) ? 'text-foreground/70' : ''
]"
>
{{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}
</span>
</div>
<div
v-if="(record.input_audio_tokens || 0) > 0 || (record.output_audio_tokens || 0) > 0"
class="mt-0.5 text-right text-[10px] leading-tight tabular-nums text-muted-foreground"
>
{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }}
</span>
<span class="justify-self-center">
/
</span>
<span
class="justify-self-start whitespace-nowrap text-left"
:class="[
hasPositiveTokens(getRecordCacheCreationTokens(record)) ? 'text-foreground/70' : ''
]"
>
{{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}
</span>
音频 {{ formatOptionalTokens(record.input_audio_tokens) }} / {{ formatOptionalTokens(record.output_audio_tokens) }}
</div>
</template>
<div
v-else
data-usage-unavailable="tokens"
class="text-right text-xs text-muted-foreground"
title="上游未提供可验证的 token/费用用量"
>
不可用
</div>
</TableCell>
<TableCell
v-if="isColumnVisible('cost')"
class="text-right py-4 w-[6%]"
>
<div class="flex flex-col items-end text-xs gap-0.5">
<div
v-if="record.usage_available !== false && record.usage_pricing_available !== false"
class="flex flex-col items-end text-xs gap-0.5"
>
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
@@ -918,6 +970,22 @@
{{ formatCurrency(record.actual_cost) }}
</span>
</div>
<div
v-else-if="record.usage_available === false"
data-usage-unavailable="cost"
class="text-xs text-muted-foreground"
title="上游未提供可验证的 token/费用用量"
>
不可用
</div>
<div
v-else
data-usage-unpriced="cost"
class="text-xs text-muted-foreground"
title="token 用量可验证,但当前计价规则不支持该音频用量分项"
>
未计价
</div>
</TableCell>
<TableCell
v-if="isColumnVisible('performance')"
@@ -1048,6 +1116,10 @@ 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 type { DateRangeParams, UsageRecord } from '../types'
import { MultiSelect, TimeRangePicker } from '@/components/common'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
@@ -1295,6 +1367,7 @@ const statusFilterOptions: FilterOption[] = [
{ value: '__all__', label: '全部状态' },
{ value: 'stream', label: '流式' },
{ value: 'standard', label: '标准' },
{ value: 'websocket', label: 'WebSocket (WS)' },
{ value: 'active', label: '活跃' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
@@ -113,6 +113,55 @@ function buildFastTierDetail(): RequestDetail {
}
describe('RequestDetailDrawer settlement pricing', () => {
it('labels an unmetered Codex Live WebSocket detail without rendering zero usage as billing', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-live-ws-1',
request_id: 'req-live-ws-1',
tokens: { input: 0, output: 0, total: 0 },
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
cost: { input: 0, output: 0, total: 0 },
total_cost: 0,
is_stream: true,
is_websocket: true,
websocket_transport: 'codex_live_sideband',
usage_available: false,
} satisfies RequestDetail)
let isOpen!: Ref<boolean>
const Host = defineComponent({
setup() {
isOpen = ref(false)
return () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-live-ws-1',
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
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(document.body.querySelector('[data-request-detail-usage-unavailable]')).not.toBeNull()
})
const totals = [...document.body.querySelectorAll<HTMLElement>('[data-request-detail-total-cost]')]
expect(totals.length).toBe(2)
expect(totals.every(total => total.textContent?.trim() === '不可用')).toBe(true)
})
it('shows end-to-end latency while keeping output TPS scoped to candidate timing', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
@@ -47,7 +47,15 @@ vi.mock('@/components/ui', async () => {
TableCell: passthrough('TableCellStub', 'td'),
Pagination: passthrough('PaginationStub'),
SortableTableHead: passthrough('SortableTableHeadStub', 'th'),
TableFilterMenu: passthrough('TableFilterMenuStub'),
TableFilterMenu: defineComponent({
name: 'TableFilterMenuStub',
props: { options: Array },
setup(props) {
return () => h('div', { 'data-table-filter-menu': '' },
(props.options as Array<{ value: string, label: string }> | undefined)
?.map(option => h('div', { 'data-filter-value': option.value }, option.label)))
},
}),
}
})
@@ -353,6 +361,50 @@ describe('UsageRecordsTable', () => {
expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true)
})
it('offers a WebSocket type filter in both filter menus', () => {
const root = mountUsageRecordsTable([buildRecord()])
const labels = [...root.querySelectorAll<HTMLElement>('div')]
.filter(element => element.textContent?.trim() === 'WebSocket (WS)')
expect(labels.length).toBeGreaterThanOrEqual(2)
})
it('labels Codex Live transport rows without inventing token or cost usage', () => {
const root = mountUsageRecordsTable([buildRecord({
is_websocket: true,
websocket_transport: 'codex_live_direct',
usage_available: false,
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
cost: 0,
})])
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)
const unavailableTokens = [...root.querySelectorAll<HTMLElement>('[data-usage-unavailable="tokens"]')]
const unavailableCosts = [...root.querySelectorAll<HTMLElement>('[data-usage-unavailable="cost"]')]
expect(unavailableTokens.length).toBeGreaterThanOrEqual(2)
expect(unavailableCosts.length).toBeGreaterThanOrEqual(2)
expect(unavailableTokens.every(element => element.textContent?.trim() === '不可用')).toBe(true)
expect(unavailableCosts.every(element => element.textContent?.trim() === '不可用')).toBe(true)
})
it('labels OpenAI Realtime WebSocket rows consistently in both layouts', () => {
const root = mountUsageRecordsTable([buildRecord({
is_websocket: true,
websocket_transport: 'openai_realtime',
})])
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.title === 'OpenAI Realtime WebSocket')).toBe(true)
})
it('shows reasoning effort next to the model name', () => {
const root = mountUsageRecordsTable([buildRecord({
requested_reasoning_effort: 'xhigh',
@@ -121,6 +121,39 @@ describe('useUsageData', () => {
expect(totalRecords.value).toBe(1)
})
it('sends the WebSocket type filter to the admin records endpoint', async () => {
const isAdminPage = ref(true)
const { loadRecords } = useUsageData({ isAdminPage })
await loadRecords(
{ page: 1, pageSize: 20 },
{ status: 'websocket' },
{ preset: 'today', tz_offset_minutes: 0 },
)
expect(getAllUsageRecordsMock).toHaveBeenCalledWith(expect.objectContaining({
status: 'websocket',
include_total: false,
}))
})
it('sends the WebSocket type filter to the user records endpoint before pagination', async () => {
const isAdminPage = ref(false)
const { loadRecords } = useUsageData({ isAdminPage })
await loadRecords(
{ page: 2, pageSize: 20 },
{ status: 'websocket' },
{ preset: 'today', tz_offset_minutes: 0 },
)
expect(meGetUsageMock).toHaveBeenCalledWith(expect.objectContaining({
status: 'websocket',
limit: 20,
offset: 20,
}))
})
it('keeps locally resolved failure fields when a stale active record refreshes', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { UsageRecord } from '../../types'
import { useUsageFilters } from '../useUsageFilters'
function buildRecord(
id: string,
isWebSocket: boolean,
isStream = true,
): UsageRecord {
return {
id,
model: 'gpt-5',
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
cost: 0.01,
is_stream: isStream,
is_websocket: isWebSocket,
created_at: '2026-08-21T00:00:00Z',
}
}
describe('useUsageFilters', () => {
it('filters WebSocket records independently from streaming HTTP records', () => {
const records = ref([
buildRecord('responses-websocket', true),
buildRecord('streaming-http', false),
])
const filters = useUsageFilters({ allRecords: records })
filters.handleFilterStatusChange('websocket')
expect(filters.filteredRecords.value.map(record => record.id)).toEqual([
'responses-websocket',
])
})
it('keeps WebSocket records out of standard and streaming HTTP filters', () => {
const records = ref([
buildRecord('responses-websocket', true, true),
buildRecord('realtime-websocket', true, false),
buildRecord('streaming-http', false, true),
buildRecord('standard-http', false, false),
])
const filters = useUsageFilters({ allRecords: records })
filters.handleFilterStatusChange('stream')
expect(filters.filteredRecords.value.map(record => record.id)).toEqual([
'streaming-http',
])
filters.handleFilterStatusChange('standard')
expect(filters.filteredRecords.value.map(record => record.id)).toEqual([
'standard-http',
])
})
})
@@ -390,6 +390,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.search?.trim()) {
params.search = filters.search.trim()
}
if (filters?.status) {
params.status = filters.status
}
if (isAdminPage.value) {
// 管理员页面:使用管理员 API
@@ -405,9 +408,6 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.api_format) {
params.api_format = filters.api_format
}
if (filters?.status) {
params.status = filters.status
}
if (filters?.client_family) {
params.client_family = filters.client_family
}
@@ -648,6 +648,22 @@ export function useUsageData(options: UseUsageDataOptions) {
: existing.image_progress,
is_stream: upstreamIsStream,
is_websocket: mergeBooleanTrueWins(existing.is_websocket, record.is_websocket),
websocket_transport: record.websocket_transport || existing.websocket_transport,
usage_available: existing.usage_available === false || record.usage_available === false
? false
: (record.usage_available ?? existing.usage_available),
usage_pricing_available: existing.usage_pricing_available === false
|| record.usage_pricing_available === false
? false
: (record.usage_pricing_available ?? existing.usage_pricing_available),
input_audio_tokens: mergeSparseRecordMetric(
existing.input_audio_tokens,
record.input_audio_tokens,
) ?? record.input_audio_tokens,
output_audio_tokens: mergeSparseRecordMetric(
existing.output_audio_tokens,
record.output_audio_tokens,
) ?? record.output_audio_tokens,
upstream_is_stream: upstreamIsStream,
client_requested_stream: clientRequestedStream,
client_is_stream: clientIsStream,
@@ -5,6 +5,7 @@ import {
hasUsageRetry,
isUsageRecordFailed,
isUsageUpstreamStream,
isUsageWebSocket,
resolveDisplayRequestStatus,
} from '../utils/status'
@@ -69,13 +70,19 @@ export function useUsageFilters(options: UseUsageFiltersOptions) {
}
if (filterStatus.value !== '__all__') {
if (filterStatus.value === 'stream') {
if (filterStatus.value === 'websocket') {
records = records.filter(record => isUsageWebSocket(record))
} else if (filterStatus.value === 'stream') {
records = records.filter(record =>
isUsageUpstreamStream(record) && !isUsageRecordFailed(record)
isUsageUpstreamStream(record)
&& !isUsageWebSocket(record)
&& !isUsageRecordFailed(record)
)
} else if (filterStatus.value === 'standard') {
records = records.filter(record =>
!isUsageUpstreamStream(record) && !isUsageRecordFailed(record)
!isUsageUpstreamStream(record)
&& !isUsageWebSocket(record)
&& !isUsageRecordFailed(record)
)
} else if (filterStatus.value === 'active') {
records = records.filter(record =>
+6
View File
@@ -121,6 +121,11 @@ export interface UsageRecord {
end_to_end_first_byte_time_ms?: number | null // 客户端从请求进入网关到首字节的耗时
is_stream: boolean
is_websocket?: boolean
websocket_transport?: string | null
usage_available?: boolean
usage_pricing_available?: boolean
input_audio_tokens?: number | null
output_audio_tokens?: number | null
upstream_is_stream?: boolean
client_requested_stream?: boolean
client_is_stream?: boolean
@@ -158,6 +163,7 @@ export type FilterStatusValue =
'__all__' |
'stream' |
'standard' |
'websocket' |
'active' |
'failed' |
'cancelled' |
@@ -0,0 +1,45 @@
export interface UsageWebSocketTransportLike {
websocket_transport?: string | null
}
function normalizeWebSocketTransport(
record: UsageWebSocketTransportLike,
): string {
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 {
const transport = normalizeWebSocketTransport(record)
switch (transport) {
case 'responses':
return 'OpenAI Responses WebSocket'
case 'codex_live_direct':
return 'Codex Live 直连 WebSocket'
case 'codex_live_sideband':
return 'Codex Live Sideband WebSocket'
case 'openai_realtime':
case 'realtime':
return 'OpenAI Realtime WebSocket'
default:
return transport ? `WebSocket transport: ${transport}` : 'WebSocket'
}
}