mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
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:
@@ -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 =>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user