mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
merge(main): 解决 usage 展示与生命周期同步冲突
This commit is contained in:
@@ -22,6 +22,9 @@ const props = withDefaults(defineProps<{
|
||||
const now = ref(Date.now())
|
||||
const precision = computed(() => Math.max(0, props.precision))
|
||||
const isActive = computed(() => props.status === 'pending' || props.status === 'streaming')
|
||||
// Usage timestamps have second precision while durations have millisecond precision.
|
||||
// Switching anchors can therefore introduce a sub-second phase shift at first byte.
|
||||
const ACTIVE_CLOCK_TIMESTAMP_PRECISION_MS = 1000
|
||||
|
||||
let rafId: number | null = null
|
||||
|
||||
@@ -72,19 +75,29 @@ const displayText = computed(() => {
|
||||
return `${(responseTimeMs / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
|
||||
const createdAtMs = parseCreatedAtMs(props.createdAt)
|
||||
const createdAtElapsedMs = Number.isNaN(createdAtMs)
|
||||
? null
|
||||
: Math.max(0, now.value - createdAtMs)
|
||||
|
||||
const responseTimeMs = finiteNonNegativeMs(props.responseTimeMs)
|
||||
const updatedAtMs = parseCreatedAtMs(props.responseTimeUpdatedAt)
|
||||
if (responseTimeMs != null && !Number.isNaN(updatedAtMs)) {
|
||||
const elapsedSinceUpdateMs = Math.max(0, now.value - updatedAtMs)
|
||||
return `${((responseTimeMs + elapsedSinceUpdateMs) / 1000).toFixed(precision.value)}s`
|
||||
const responseElapsedMs = responseTimeMs + elapsedSinceUpdateMs
|
||||
|
||||
// When both clocks differ only by timestamp truncation, keep the original
|
||||
// created-at clock so the first-byte snapshot cannot make total time pause
|
||||
// or move backwards. A larger difference is a real calibration signal
|
||||
// (for example an audit row created before execution) and remains authoritative.
|
||||
if (createdAtElapsedMs != null &&
|
||||
Math.abs(responseElapsedMs - createdAtElapsedMs) <= ACTIVE_CLOCK_TIMESTAMP_PRECISION_MS) {
|
||||
return `${(createdAtElapsedMs / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
return `${(responseElapsedMs / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
|
||||
if (!props.createdAt) return '-'
|
||||
|
||||
const createdAtMs = parseCreatedAtMs(props.createdAt)
|
||||
if (Number.isNaN(createdAtMs)) return '-'
|
||||
|
||||
const elapsedMs = Math.max(0, now.value - createdAtMs)
|
||||
return `${(elapsedMs / 1000).toFixed(precision.value)}s`
|
||||
if (createdAtElapsedMs == null) return '-'
|
||||
return `${(createdAtElapsedMs / 1000).toFixed(precision.value)}s`
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -462,7 +462,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息:真实上游响应合并在此处展示 -->
|
||||
<!-- 错误信息:将实际上游响应头和响应体作为同一个对象展示 -->
|
||||
<div
|
||||
v-if="currentAttempt.status === 'failed' && currentAttemptRequestError"
|
||||
class="error-block"
|
||||
@@ -485,12 +485,24 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptRequestError.upstreamResponse"
|
||||
class="error-json"
|
||||
class="error-json error-upstream-response-json"
|
||||
>
|
||||
<JsonContentPanel
|
||||
:data="currentAttemptRequestError.upstreamResponse"
|
||||
:is-dark="isDark"
|
||||
empty-message="无上游响应信息"
|
||||
title="上游响应"
|
||||
empty-message="无上游响应"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptRequestError.diagnostic"
|
||||
class="error-json error-diagnostic-json"
|
||||
>
|
||||
<JsonContentPanel
|
||||
:data="currentAttemptRequestError.diagnostic"
|
||||
:is-dark="isDark"
|
||||
title="失败诊断"
|
||||
empty-message="无失败诊断信息"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1227,7 +1239,7 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknow
|
||||
const raw = extractObject(value)
|
||||
if (!raw) return null
|
||||
const statusCode = readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode')
|
||||
const headers = raw.headers
|
||||
const headers = raw.headers ?? raw.header
|
||||
const body = raw.body
|
||||
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
|
||||
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
|
||||
@@ -1627,6 +1639,7 @@ const currentAttemptRequestError = computed<{
|
||||
message: string
|
||||
statusCode?: number
|
||||
upstreamResponse: Record<string, unknown> | null
|
||||
diagnostic: Record<string, unknown> | null
|
||||
} | null>(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt || attempt.status !== 'failed') return null
|
||||
@@ -1667,10 +1680,20 @@ const currentAttemptRequestError = computed<{
|
||||
rawMessage,
|
||||
)
|
||||
: null
|
||||
const upstreamResponseWithDiagnostic = diagnostic
|
||||
? { ...(upstreamResponseDisplay ?? {}), diagnostic }
|
||||
: upstreamResponseDisplay
|
||||
if (!message && statusCode == null && !upstreamResponseWithDiagnostic) return null
|
||||
const upstreamResponseData: Record<string, unknown> = {}
|
||||
const responseHeader = upstreamResponseDisplay?.headers
|
||||
const responseBody = upstreamResponseDisplay?.body
|
||||
if (hasRenderableValue(responseHeader)) upstreamResponseData.header = responseHeader
|
||||
if (hasRenderableValue(responseBody)) upstreamResponseData.body = responseBody
|
||||
const response = Object.keys(upstreamResponseData).length > 0
|
||||
? upstreamResponseData
|
||||
: null
|
||||
if (
|
||||
!message
|
||||
&& statusCode == null
|
||||
&& !response
|
||||
&& !diagnostic
|
||||
) return null
|
||||
const showMessage = shouldShowAttemptMessageWithUpstreamResponse(
|
||||
rawMessage || fallbackType,
|
||||
upstreamResponseDisplay,
|
||||
@@ -1679,7 +1702,8 @@ const currentAttemptRequestError = computed<{
|
||||
return {
|
||||
message: showMessage ? (message || '未知错误') : '',
|
||||
statusCode,
|
||||
upstreamResponse: upstreamResponseWithDiagnostic,
|
||||
upstreamResponse: response,
|
||||
diagnostic,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -23,23 +23,21 @@
|
||||
<h3 class="text-lg font-semibold">
|
||||
请求详情
|
||||
</h3>
|
||||
<div class="flex min-w-0 max-w-[10rem] items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded sm:max-w-none">
|
||||
<span class="truncate">{{ detail?.model || '-' }}</span>
|
||||
<template v-if="detail?.target_model && detail.target_model !== detail.model">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">{{ detail.target_model }}</span>
|
||||
</template>
|
||||
<UsageModelDisplay
|
||||
v-if="headerModelRecord"
|
||||
:record="headerModelRecord"
|
||||
:cyber="detailCyberPolicyError"
|
||||
context="detail"
|
||||
data-request-detail-model-display
|
||||
class="min-w-0 max-w-[18rem] text-sm font-mono text-muted-foreground sm:max-w-none"
|
||||
model-row-class="rounded bg-muted px-2 py-0.5"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
data-request-detail-model-display
|
||||
class="rounded bg-muted px-2 py-0.5 text-sm font-mono text-muted-foreground"
|
||||
>
|
||||
-
|
||||
</div>
|
||||
<Badge
|
||||
v-if="detail?.status_code === 200"
|
||||
@@ -159,47 +157,6 @@
|
||||
v-else-if="detail"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- 执行失败原因:优先展示本地调度/运行时失败摘要 -->
|
||||
<Card
|
||||
v-if="failureNotice"
|
||||
class="border-red-200 bg-red-50/80 shadow-sm dark:border-red-900/60 dark:bg-red-950/30"
|
||||
>
|
||||
<div class="p-3 sm:p-4 flex gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-300">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
|
||||
{{ failureNotice.title }}
|
||||
</h4>
|
||||
<Badge
|
||||
v-if="failureNotice.isSchedulingFailure"
|
||||
variant="outline"
|
||||
class="border-red-300 bg-white/60 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
|
||||
>
|
||||
调度阶段
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
|
||||
{{ failureNotice.message }}
|
||||
</p>
|
||||
<div
|
||||
v-if="failureNotice.meta.length > 0"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-for="item in failureNotice.meta"
|
||||
:key="item"
|
||||
class="rounded-full border border-red-200 bg-white/70 px-2 py-0.5 text-[11px] font-mono text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 费用与性能概览 -->
|
||||
<Card>
|
||||
<div class="p-3 sm:p-4">
|
||||
@@ -253,8 +210,6 @@
|
||||
v-if="hasServiceTierFacts || processingTierPriceMultiplier !== null"
|
||||
class="mt-3"
|
||||
:requested="serviceTierFacts.requested"
|
||||
:actual="serviceTierFacts.actual"
|
||||
:billing="serviceTierFacts.billing"
|
||||
:price-multiplier="processingTierPriceMultiplier"
|
||||
/>
|
||||
</div>
|
||||
@@ -886,6 +841,7 @@ import TabsContent from '@/components/ui/tabs-content.vue'
|
||||
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
||||
import type { UsageRecord } from '../types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import {
|
||||
formatByteSize,
|
||||
@@ -906,7 +862,11 @@ import {
|
||||
resolveDisplayRequestStatus,
|
||||
resolveUsageStreamLabelSegments,
|
||||
} from '../utils/status'
|
||||
import { resolveRequestFailureNotice } from '../utils/errorNotice'
|
||||
import { isCyberPolicyError } from '../utils/cyberError'
|
||||
import {
|
||||
mergeUsageRecordErrorMessage,
|
||||
parseUsageTimestampMs,
|
||||
} from '../utils/recordSync'
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
resolveProcessingTierPriceMultiplier,
|
||||
@@ -922,7 +882,11 @@ import ConversationView from './RequestDetailDrawer/ConversationView.vue'
|
||||
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
||||
import ReplayDialog from './ReplayDialog.vue'
|
||||
import ServiceTierFacts from './ServiceTierFacts.vue'
|
||||
import { hasServiceTierFact, resolveServiceTierFacts } from '../utils/service-tier'
|
||||
import UsageModelDisplay from './UsageModelDisplay.vue'
|
||||
import {
|
||||
hasServiceTierFact,
|
||||
resolveServiceTierFacts,
|
||||
} from '../utils/service-tier'
|
||||
|
||||
// 对话解析器
|
||||
import {
|
||||
@@ -937,6 +901,7 @@ type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'ca
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
requestId: string | null
|
||||
summaryRecord?: UsageRecord | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -966,11 +931,13 @@ const emit = defineEmits<{
|
||||
endpointApiFormat?: string | null
|
||||
hasFormatConversion?: boolean | null
|
||||
targetModel?: string | null
|
||||
requestedReasoningEffort?: string | null
|
||||
reasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
actualServiceTier?: string | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
updatedAt?: string | null
|
||||
}]
|
||||
}>()
|
||||
|
||||
@@ -1098,6 +1065,114 @@ function resolveRequestStateStatusFromDetail(nextDetail: Pick<RequestDetail, 'st
|
||||
return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message)
|
||||
}
|
||||
|
||||
type HeaderModelTextField =
|
||||
| 'model'
|
||||
| 'target_model'
|
||||
| 'model_version'
|
||||
| 'requested_reasoning_effort'
|
||||
| 'reasoning_effort'
|
||||
| 'service_tier'
|
||||
| 'actual_service_tier'
|
||||
|
||||
const FINAL_PROVIDER_HEADER_FIELDS = new Set<HeaderModelTextField>([
|
||||
'target_model',
|
||||
'reasoning_effort',
|
||||
'service_tier',
|
||||
'actual_service_tier',
|
||||
])
|
||||
|
||||
let modelSnapshotRevision = 0
|
||||
const summaryModelRevision = ref(0)
|
||||
const detailModelRevision = ref(0)
|
||||
|
||||
function usageSnapshotUpdatedAtMs(
|
||||
source: UsageRecord | RequestDetail | null | undefined,
|
||||
): number | null {
|
||||
const value = source?.updated_at
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
return parseUsageTimestampMs(value)
|
||||
}
|
||||
|
||||
function summaryNullIsNewerForProviderField(
|
||||
field: HeaderModelTextField,
|
||||
nextDetail: RequestDetail | null | undefined,
|
||||
): boolean {
|
||||
if (!FINAL_PROVIDER_HEADER_FIELDS.has(field) || !props.summaryRecord) return false
|
||||
|
||||
const summaryUpdatedAt = usageSnapshotUpdatedAtMs(props.summaryRecord)
|
||||
const detailUpdatedAt = usageSnapshotUpdatedAtMs(nextDetail)
|
||||
if (summaryUpdatedAt != null && detailUpdatedAt != null && summaryUpdatedAt !== detailUpdatedAt) {
|
||||
return summaryUpdatedAt > detailUpdatedAt
|
||||
}
|
||||
|
||||
if (summaryModelRevision.value > detailModelRevision.value) return true
|
||||
|
||||
// A terminal list row is a complete final-provider snapshot. When no
|
||||
// comparable timestamps exist, its explicit null must beat a cached detail
|
||||
// from an earlier candidate. Non-terminal rows may still be filled by a
|
||||
// detail request that completed after the lightweight list response.
|
||||
return ['completed', 'failed', 'cancelled'].includes(props.summaryRecord.status ?? '')
|
||||
}
|
||||
|
||||
function readHeaderModelTextField(
|
||||
source: UsageRecord | RequestDetail | null | undefined,
|
||||
field: HeaderModelTextField,
|
||||
): { resolved: boolean, value: string | null } {
|
||||
if (!source || !Object.prototype.hasOwnProperty.call(source, field)) {
|
||||
return { resolved: false, value: null }
|
||||
}
|
||||
|
||||
const value = (source as unknown as Record<string, unknown>)[field]
|
||||
if (value === null) return { resolved: true, value: null }
|
||||
if (typeof value !== 'string') return { resolved: false, value: null }
|
||||
|
||||
const normalized = value.trim()
|
||||
return { resolved: true, value: normalized || null }
|
||||
}
|
||||
|
||||
function resolveHeaderModelTextField(
|
||||
field: HeaderModelTextField,
|
||||
nextDetail: RequestDetail | null | undefined,
|
||||
): string | null | undefined {
|
||||
// Prefer a populated list/active fact so sparse detail cannot make the header
|
||||
// flicker. A summary null is often only a lightweight-contract placeholder,
|
||||
// though, so a later populated detail is still useful. Final-provider stale
|
||||
// facts are cleared when full list/active snapshots merge into the summary.
|
||||
const summaryValue = readHeaderModelTextField(props.summaryRecord, field)
|
||||
if (summaryValue.value) return summaryValue.value
|
||||
|
||||
const detailValue = readHeaderModelTextField(nextDetail, field)
|
||||
if (detailValue.value) {
|
||||
if (
|
||||
summaryValue.resolved
|
||||
&& summaryValue.value === null
|
||||
&& summaryNullIsNewerForProviderField(field, nextDetail)
|
||||
) return null
|
||||
return detailValue.value
|
||||
}
|
||||
|
||||
return summaryValue.resolved || detailValue.resolved ? null : undefined
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.requestId,
|
||||
props.summaryRecord?.status,
|
||||
props.summaryRecord?.updated_at,
|
||||
props.summaryRecord?.model,
|
||||
props.summaryRecord?.target_model,
|
||||
props.summaryRecord?.model_version,
|
||||
props.summaryRecord?.requested_reasoning_effort,
|
||||
props.summaryRecord?.reasoning_effort,
|
||||
props.summaryRecord?.service_tier,
|
||||
props.summaryRecord?.actual_service_tier,
|
||||
],
|
||||
() => {
|
||||
summaryModelRevision.value = ++modelSnapshotRevision
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function detailTotalCost(nextDetail: RequestDetail): number | null {
|
||||
const structuredCost = typeof nextDetail.cost === 'object' ? nextDetail.cost?.total : null
|
||||
const totalCost = toNumber(nextDetail.total_cost)
|
||||
@@ -1124,6 +1199,15 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
|
||||
const id = props.requestId
|
||||
if (!id) return
|
||||
|
||||
const targetModel = resolveHeaderModelTextField('target_model', nextDetail)
|
||||
const requestedReasoningEffort = resolveHeaderModelTextField(
|
||||
'requested_reasoning_effort',
|
||||
nextDetail,
|
||||
)
|
||||
const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail)
|
||||
const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail)
|
||||
const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail)
|
||||
|
||||
emit('requestState', {
|
||||
id,
|
||||
requestId: nextDetail.request_id || nextDetail.id || null,
|
||||
@@ -1148,11 +1232,13 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
|
||||
apiFormat: nextDetail.api_format ?? null,
|
||||
endpointApiFormat: nextDetail.endpoint_api_format ?? null,
|
||||
hasFormatConversion: nextDetail.has_format_conversion ?? null,
|
||||
targetModel: nextDetail.target_model ?? null,
|
||||
reasoningEffort: nextDetail.reasoning_effort ?? null,
|
||||
serviceTier: nextDetail.service_tier ?? null,
|
||||
actualServiceTier: nextDetail.actual_service_tier ?? null,
|
||||
...(targetModel ? { targetModel } : {}),
|
||||
...(requestedReasoningEffort ? { requestedReasoningEffort } : {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...(actualServiceTier ? { actualServiceTier } : {}),
|
||||
errorMessage: nextDetail.error_message ?? undefined,
|
||||
updatedAt: nextDetail.updated_at ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1358,10 +1444,106 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
|
||||
: null
|
||||
})
|
||||
|
||||
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
||||
const detailForCurrentRequest = computed(() => (
|
||||
detailMatchesRequestId(detail.value, props.requestId) ? detail.value : null
|
||||
))
|
||||
|
||||
const serviceTierFacts = computed(() => resolveServiceTierFacts(detail.value))
|
||||
type AuthoritativeErrorSource = 'summary' | 'detail' | null
|
||||
|
||||
function isTerminalRequestState(status: RequestStateStatus | undefined): boolean {
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function isSuccessfulTerminalRequestState(status: RequestStateStatus | undefined): boolean {
|
||||
return status === 'completed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
const authoritativeErrorSource = computed<AuthoritativeErrorSource>(() => {
|
||||
const summary = props.summaryRecord
|
||||
const currentDetail = detailForCurrentRequest.value
|
||||
if (!summary || !currentDetail) return null
|
||||
|
||||
const summaryStatus = resolveRequestStateStatus(
|
||||
summary.status,
|
||||
summary.status_code,
|
||||
summary.error_message,
|
||||
)
|
||||
const detailStatus = resolveRequestStateStatusFromDetail(currentDetail)
|
||||
const summaryUpdatedAtMs = usageSnapshotUpdatedAtMs(summary)
|
||||
const detailUpdatedAtMs = usageSnapshotUpdatedAtMs(currentDetail)
|
||||
|
||||
if (summaryUpdatedAtMs != null && detailUpdatedAtMs != null &&
|
||||
summaryUpdatedAtMs !== detailUpdatedAtMs) {
|
||||
if (detailUpdatedAtMs > summaryUpdatedAtMs && isTerminalRequestState(detailStatus)) {
|
||||
return 'detail'
|
||||
}
|
||||
if (summaryUpdatedAtMs > detailUpdatedAtMs && isTerminalRequestState(summaryStatus)) {
|
||||
return 'summary'
|
||||
}
|
||||
}
|
||||
|
||||
// Without a comparable timestamp, a successful/cancelled terminal snapshot
|
||||
// still has to clear a failure from the other source. Generic failed detail
|
||||
// remains non-authoritative so opening the drawer cannot flash away a Cyber
|
||||
// refusal already resolved by the list.
|
||||
const detailSucceeded = isSuccessfulTerminalRequestState(detailStatus)
|
||||
const summarySucceeded = isSuccessfulTerminalRequestState(summaryStatus)
|
||||
if (detailSucceeded && !summarySucceeded) return 'detail'
|
||||
if (summarySucceeded && !detailSucceeded) return 'summary'
|
||||
if (detailSucceeded && summarySucceeded) {
|
||||
return detailModelRevision.value >= summaryModelRevision.value ? 'detail' : 'summary'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const headerModelRecord = computed(() => {
|
||||
const summary = props.summaryRecord
|
||||
const currentDetail = detailForCurrentRequest.value
|
||||
if (!summary && !currentDetail) return null
|
||||
|
||||
const authoritativeSource = authoritativeErrorSource.value
|
||||
const errorMessage = authoritativeSource === 'summary'
|
||||
? mergeUsageRecordErrorMessage(undefined, summary?.error_message, { authoritative: true })
|
||||
: mergeUsageRecordErrorMessage(
|
||||
summary?.error_message,
|
||||
currentDetail?.error_message,
|
||||
{ authoritative: authoritativeSource === 'detail' },
|
||||
)
|
||||
|
||||
return {
|
||||
model: resolveHeaderModelTextField('model', currentDetail) ?? '-',
|
||||
target_model: resolveHeaderModelTextField('target_model', currentDetail),
|
||||
model_version: resolveHeaderModelTextField('model_version', currentDetail),
|
||||
requested_reasoning_effort: resolveHeaderModelTextField(
|
||||
'requested_reasoning_effort',
|
||||
currentDetail,
|
||||
),
|
||||
reasoning_effort: resolveHeaderModelTextField('reasoning_effort', currentDetail),
|
||||
service_tier: resolveHeaderModelTextField('service_tier', currentDetail),
|
||||
error_message: errorMessage,
|
||||
}
|
||||
})
|
||||
const serviceTierFacts = computed(() => resolveServiceTierFacts(headerModelRecord.value))
|
||||
const hasServiceTierFacts = computed(() => hasServiceTierFact(serviceTierFacts.value))
|
||||
const detailCyberPolicyError = computed(() => {
|
||||
const summaryError = props.summaryRecord?.error_message
|
||||
const currentDetail = detailForCurrentRequest.value
|
||||
const detailErrors = [
|
||||
currentDetail?.error_message,
|
||||
currentDetail?.upstream_error,
|
||||
currentDetail?.failure_summary,
|
||||
currentDetail?.response_body,
|
||||
]
|
||||
|
||||
if (authoritativeErrorSource.value === 'summary') {
|
||||
return isCyberPolicyError(summaryError)
|
||||
}
|
||||
if (authoritativeErrorSource.value === 'detail') {
|
||||
return isCyberPolicyError(detailErrors)
|
||||
}
|
||||
return isCyberPolicyError([summaryError, ...detailErrors])
|
||||
})
|
||||
const processingTierPriceMultiplier = computed(() => (
|
||||
resolveProcessingTierPriceMultiplier(detail.value)
|
||||
))
|
||||
@@ -2257,15 +2439,12 @@ const visibleTabs = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.requestId, async (newId) => {
|
||||
if (newId && props.isOpen) {
|
||||
await loadDetail(newId)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.isOpen, async (isOpen) => {
|
||||
if (isOpen && props.requestId) {
|
||||
await loadDetail(props.requestId)
|
||||
watch([() => props.isOpen, () => props.requestId], async ([isOpen, requestId]) => {
|
||||
if (isOpen && requestId) {
|
||||
if (!detailMatchesRequestId(detail.value, requestId)) {
|
||||
detail.value = null
|
||||
}
|
||||
await loadDetail(requestId)
|
||||
} else if (!isOpen) {
|
||||
stopAutoRefresh()
|
||||
showTimeline.value = false
|
||||
@@ -2276,6 +2455,14 @@ watch(() => props.isOpen, async (isOpen) => {
|
||||
}
|
||||
})
|
||||
|
||||
function detailMatchesRequestId(
|
||||
candidate: RequestDetail | null | undefined,
|
||||
requestId: string | null | undefined,
|
||||
): boolean {
|
||||
if (!candidate || !requestId) return false
|
||||
return candidate.id === requestId || candidate.request_id === requestId
|
||||
}
|
||||
|
||||
async function ensureBodyContentLoaded() {
|
||||
if (!props.requestId || !detail.value) return
|
||||
|
||||
@@ -2334,6 +2521,9 @@ async function loadDetail(id: string, silent = false) {
|
||||
const requestId = ++loadDetailRequestId
|
||||
loadDetailInFlight = true
|
||||
if (!silent) {
|
||||
if (!detailMatchesRequestId(detail.value, id)) {
|
||||
detail.value = null
|
||||
}
|
||||
loading.value = true
|
||||
historicalPricing.value = null
|
||||
timelineLoaded.value = false
|
||||
@@ -2371,6 +2561,7 @@ async function loadDetail(id: string, silent = false) {
|
||||
error_flow: response.error_flow,
|
||||
scheduling_failure: response.scheduling_failure,
|
||||
}
|
||||
detailModelRevision.value = ++modelSnapshotRevision
|
||||
detail.value = nextDetail
|
||||
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
|
||||
emitDetailRequestState(nextDetail)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<dl
|
||||
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs"
|
||||
:class="hasPriceMultiplier ? 'sm:grid-cols-4' : 'sm:grid-cols-3'"
|
||||
:class="hasPriceMultiplier ? 'sm:grid-cols-3' : 'sm:grid-cols-2'"
|
||||
data-testid="service-tier-facts"
|
||||
>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
<dt class="text-muted-foreground">
|
||||
请求层级
|
||||
上游请求层级
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
@@ -15,26 +15,15 @@
|
||||
{{ formatServiceTierFact(requested) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
<dt class="text-muted-foreground">
|
||||
实际层级
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="formatServiceTierFact(actual) || '-'"
|
||||
>
|
||||
{{ formatServiceTierFact(actual) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
<dt class="text-muted-foreground">
|
||||
计费层级
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="formatServiceTierFact(billing) || '-'"
|
||||
:title="formatServiceTierFact(requested) || '-'"
|
||||
>
|
||||
{{ formatServiceTierFact(billing) || '-' }}
|
||||
{{ formatServiceTierFact(requested) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
@@ -58,8 +47,6 @@ import { formatServiceTierFact } from '../utils/service-tier'
|
||||
|
||||
const props = defineProps<{
|
||||
requested: string | null
|
||||
actual: string | null
|
||||
billing: string | null
|
||||
priceMultiplier?: number | null
|
||||
}>()
|
||||
|
||||
@@ -70,7 +57,7 @@ const hasPriceMultiplier = computed(() => (
|
||||
))
|
||||
|
||||
const multiplierTierLabel = computed(() => (
|
||||
formatServiceTierFact(props.billing ?? props.actual ?? props.requested) ?? '处理层级'
|
||||
formatServiceTierFact(props.requested) ?? '处理层级'
|
||||
))
|
||||
|
||||
const formattedPriceMultiplier = computed(() => (
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-0 max-w-full flex-col gap-0.5"
|
||||
:class="shouldStackBadges && stackFullWidth ? 'w-full items-start' : 'items-start'"
|
||||
:data-usage-model-layout="shouldStackBadges ? 'stacked' : 'inline'"
|
||||
:data-request-detail-model-layout="context === 'detail'
|
||||
? (shouldStackBadges ? 'stacked' : 'inline')
|
||||
: undefined"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 max-w-full items-center gap-1"
|
||||
:class="modelRowClass"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 truncate"
|
||||
:class="modelClass"
|
||||
data-usage-model-source
|
||||
>{{ record.model }}</span>
|
||||
<template v-if="actualModel">
|
||||
<span class="shrink-0 text-muted-foreground/70">-></span>
|
||||
<span
|
||||
class="min-w-0 truncate"
|
||||
:class="modelClass"
|
||||
data-usage-model-target
|
||||
>{{ actualModel }}</span>
|
||||
</template>
|
||||
<template v-if="!shouldStackBadges">
|
||||
<Badge
|
||||
v-for="badge in modelBadges"
|
||||
:key="badge.key"
|
||||
:data-usage-model-badge="badge.key"
|
||||
:data-request-detail-model-badge="context === 'detail' ? badge.key : undefined"
|
||||
:variant="badge.variant"
|
||||
class="h-4 shrink-0 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4"
|
||||
:class="badge.className"
|
||||
:title="badge.title"
|
||||
:aria-label="badge.ariaLabel"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</Badge>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="shouldStackBadges && modelBadges.length > 0"
|
||||
class="flex min-w-0 max-w-full flex-wrap items-center gap-1"
|
||||
data-usage-model-badges-row
|
||||
:data-request-detail-model-badges-row="context === 'detail' ? '' : undefined"
|
||||
>
|
||||
<Badge
|
||||
v-for="badge in modelBadges"
|
||||
:key="badge.key"
|
||||
:data-usage-model-badge="badge.key"
|
||||
:data-request-detail-model-badge="context === 'detail' ? badge.key : undefined"
|
||||
:variant="badge.variant"
|
||||
class="h-4 shrink-0 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4"
|
||||
:class="badge.className"
|
||||
:title="badge.title"
|
||||
:aria-label="badge.ariaLabel"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui'
|
||||
import { isCyberPolicyError } from '../utils/cyberError'
|
||||
import { formatServiceTierFact } from '../utils/service-tier'
|
||||
|
||||
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'cyber'
|
||||
|
||||
interface ModelBadgePresentation {
|
||||
key: ModelBadgeKey
|
||||
label: string
|
||||
variant: 'outline' | 'outline-transparent'
|
||||
className: string
|
||||
title: string
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
interface UsageModelDisplayRecord {
|
||||
model: string
|
||||
target_model?: string | null
|
||||
model_version?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
record: UsageModelDisplayRecord
|
||||
modelClass?: string
|
||||
modelRowClass?: string
|
||||
context?: 'usage' | 'detail'
|
||||
cyber?: boolean | null
|
||||
stackFullWidth?: boolean
|
||||
}>(), {
|
||||
modelClass: '',
|
||||
modelRowClass: '',
|
||||
context: 'usage',
|
||||
cyber: null,
|
||||
stackFullWidth: false,
|
||||
})
|
||||
|
||||
const actualModel = computed(() => {
|
||||
const targetModel = normalizeText(props.record.target_model)
|
||||
if (targetModel && targetModel !== props.record.model) return targetModel
|
||||
|
||||
const modelVersion = normalizeText(props.record.model_version)
|
||||
if (modelVersion && modelVersion !== props.record.model) return modelVersion
|
||||
return null
|
||||
})
|
||||
|
||||
const reasoningLabel = computed(() => {
|
||||
const requested = normalizeText(props.record.requested_reasoning_effort)
|
||||
const actual = normalizeText(props.record.reasoning_effort)
|
||||
if (requested && actual && requested.toLowerCase() !== actual.toLowerCase()) {
|
||||
return `${requested} -> ${actual}`
|
||||
}
|
||||
return actual ?? requested
|
||||
})
|
||||
|
||||
const modelBadges = computed<ModelBadgePresentation[]>(() => {
|
||||
const badges: ModelBadgePresentation[] = []
|
||||
if (normalizeText(props.record.request_type)?.toLowerCase() === 'compact') {
|
||||
badges.push({
|
||||
key: 'compact',
|
||||
label: '会话压缩',
|
||||
variant: 'outline',
|
||||
className: 'border-sky-500/30 bg-sky-500/5 text-sky-700 dark:text-sky-300',
|
||||
title: '会话压缩',
|
||||
ariaLabel: '会话压缩',
|
||||
})
|
||||
}
|
||||
if (reasoningLabel.value) {
|
||||
badges.push({
|
||||
key: 'reasoning',
|
||||
label: reasoningLabel.value,
|
||||
variant: 'outline',
|
||||
className: 'border-primary/30 bg-primary/5 text-primary',
|
||||
title: `Reasoning: ${reasoningLabel.value}`,
|
||||
ariaLabel: `Reasoning: ${reasoningLabel.value}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (formatServiceTierFact(props.record.service_tier) === 'Fast') {
|
||||
badges.push({
|
||||
key: 'fast',
|
||||
label: 'Fast',
|
||||
variant: 'outline-transparent',
|
||||
className: 'text-amber-700 dark:text-amber-300',
|
||||
title: '上游请求档位:Fast\n计费档位:Fast',
|
||||
ariaLabel: '上游请求档位:Fast,计费档位:Fast',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.cyber ?? isCyberPolicyError(props.record.error_message)) {
|
||||
badges.push({
|
||||
key: 'cyber',
|
||||
label: 'Cyber',
|
||||
variant: 'outline',
|
||||
className: 'border-primary/30 bg-primary/5 text-rose-600 dark:text-rose-300',
|
||||
title: '上游 Cyber Policy 拒绝',
|
||||
ariaLabel: '上游 Cyber Policy 拒绝',
|
||||
})
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
const shouldStackBadges = computed(() => (
|
||||
actualModel.value !== null || modelBadges.value.length >= 3
|
||||
))
|
||||
|
||||
function normalizeText(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim()
|
||||
return normalized || null
|
||||
}
|
||||
</script>
|
||||
@@ -242,34 +242,12 @@
|
||||
<!-- 第一行:模型 + 费用 -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="min-w-0 truncate text-[15px] font-semibold leading-5">{{ record.model }}</span>
|
||||
<Badge
|
||||
v-if="getRequestTypeLabel(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
|
||||
title="会话压缩"
|
||||
>
|
||||
{{ getRequestTypeLabel(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getReasoningEffort(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
|
||||
:title="getReasoningEffortTitle(record)"
|
||||
>
|
||||
{{ getReasoningEffort(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getServiceTierBadge(record)"
|
||||
variant="outline"
|
||||
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
|
||||
:class="getServiceTierBadge(record)?.className"
|
||||
:title="getServiceTierBadge(record)?.title"
|
||||
:aria-label="getServiceTierBadge(record)?.ariaLabel"
|
||||
>
|
||||
{{ getServiceTierBadge(record)?.label }}
|
||||
</Badge>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<UsageModelDisplay
|
||||
:record="record"
|
||||
model-class="text-[15px] font-semibold leading-5"
|
||||
stack-full-width
|
||||
/>
|
||||
<!-- 状态 Badge -->
|
||||
<Badge
|
||||
v-if="isUsageRecordFailed(record)"
|
||||
@@ -320,10 +298,6 @@
|
||||
{{ getStreamModeLabel(record) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<span
|
||||
v-if="getActualModel(record)"
|
||||
class="text-[11px] text-muted-foreground truncate block"
|
||||
>-> {{ getActualModel(record) }}</span>
|
||||
</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>
|
||||
@@ -745,85 +719,10 @@
|
||||
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
:title="getModelTooltip(record)"
|
||||
>
|
||||
<div
|
||||
v-if="getActualModel(record)"
|
||||
class="flex flex-col text-xs gap-0.5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<span class="truncate">{{ record.model }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 text-muted-foreground flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<Badge
|
||||
v-if="getRequestTypeLabel(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
|
||||
title="会话压缩"
|
||||
>
|
||||
{{ getRequestTypeLabel(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getReasoningEffort(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
|
||||
:title="getReasoningEffortTitle(record)"
|
||||
>
|
||||
{{ getReasoningEffort(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getServiceTierBadge(record)"
|
||||
variant="outline"
|
||||
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
|
||||
:class="getServiceTierBadge(record)?.className"
|
||||
:title="getServiceTierBadge(record)?.title"
|
||||
:aria-label="getServiceTierBadge(record)?.ariaLabel"
|
||||
>
|
||||
{{ getServiceTierBadge(record)?.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
<span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="flex min-w-0 items-center gap-1"
|
||||
>
|
||||
<span class="truncate">{{ record.model }}</span>
|
||||
<Badge
|
||||
v-if="getRequestTypeLabel(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
|
||||
title="会话压缩"
|
||||
>
|
||||
{{ getRequestTypeLabel(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getReasoningEffort(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
|
||||
:title="getReasoningEffortTitle(record)"
|
||||
>
|
||||
{{ getReasoningEffort(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getServiceTierBadge(record)"
|
||||
variant="outline"
|
||||
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
|
||||
:class="getServiceTierBadge(record)?.className"
|
||||
:title="getServiceTierBadge(record)?.title"
|
||||
:aria-label="getServiceTierBadge(record)?.ariaLabel"
|
||||
>
|
||||
{{ getServiceTierBadge(record)?.label }}
|
||||
</Badge>
|
||||
</span>
|
||||
<UsageModelDisplay
|
||||
:record="record"
|
||||
class="text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="isAdmin && isColumnVisible('provider')"
|
||||
@@ -1130,11 +1029,13 @@ import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
|
||||
import { formatServiceTierFact } from '../utils/service-tier'
|
||||
import { isCyberPolicyError } from '../utils/cyberError'
|
||||
import type { DateRangeParams, UsageRecord } from '../types'
|
||||
import { MultiSelect, TimeRangePicker } from '@/components/common'
|
||||
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
|
||||
import ElapsedTimeText from './ElapsedTimeText.vue'
|
||||
import ServerUserSelector from './ServerUserSelector.vue'
|
||||
import UsageModelDisplay from './UsageModelDisplay.vue'
|
||||
|
||||
export interface UserOption {
|
||||
id: string
|
||||
@@ -1632,24 +1533,20 @@ function getActualModel(record: UsageRecord): string | null {
|
||||
}
|
||||
|
||||
function getReasoningEffort(record: UsageRecord): string | null {
|
||||
const effort = record.reasoning_effort?.trim()
|
||||
return effort || null
|
||||
const requested = record.requested_reasoning_effort?.trim()
|
||||
const actual = record.reasoning_effort?.trim()
|
||||
if (requested && actual && requested.toLowerCase() !== actual.toLowerCase()) {
|
||||
return `${requested} -> ${actual}`
|
||||
}
|
||||
return actual || requested || null
|
||||
}
|
||||
|
||||
function getRequestTypeLabel(record: UsageRecord): string | null {
|
||||
return record.request_type?.trim().toLowerCase() === 'compact' ? '会话压缩' : null
|
||||
function hasCyberPolicyError(record: UsageRecord): boolean {
|
||||
return isCyberPolicyError(record.error_message)
|
||||
}
|
||||
|
||||
function getReasoningEffortTitle(record: UsageRecord): string {
|
||||
const effort = getReasoningEffort(record)
|
||||
return effort ? `Reasoning: ${effort}` : ''
|
||||
}
|
||||
|
||||
type ServiceTierBadgeState = 'confirmed' | 'downgraded' | 'upgraded' | 'pending' | 'unconfirmed'
|
||||
|
||||
interface ServiceTierBadgePresentation {
|
||||
label: string
|
||||
state: ServiceTierBadgeState
|
||||
className: string
|
||||
title: string
|
||||
ariaLabel: string
|
||||
@@ -1670,44 +1567,19 @@ function canonicalServiceTier(value: string | null): string | null {
|
||||
return value
|
||||
}
|
||||
|
||||
function serviceTierBadgeClass(state: ServiceTierBadgeState): string {
|
||||
switch (state) {
|
||||
case 'confirmed':
|
||||
return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
case 'downgraded':
|
||||
return 'border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
case 'upgraded':
|
||||
return 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300'
|
||||
case 'pending':
|
||||
return 'border-dashed border-muted-foreground/30 bg-muted/30 text-muted-foreground'
|
||||
case 'unconfirmed':
|
||||
return 'border-dashed border-amber-500/40 bg-amber-500/5 text-amber-700 dark:text-amber-300'
|
||||
}
|
||||
}
|
||||
|
||||
function buildServiceTierBadgePresentation(
|
||||
label: string,
|
||||
state: ServiceTierBadgeState,
|
||||
requestedRaw: string | null,
|
||||
actualRaw: string | null,
|
||||
billingTier: string | null,
|
||||
): ServiceTierBadgePresentation {
|
||||
const titleLines: string[] = []
|
||||
const requestedLabel = formatServiceTierFact(requestedRaw)
|
||||
const actualLabel = formatServiceTierFact(actualRaw)
|
||||
const billingLabel = formatServiceTierFact(billingTier)
|
||||
if (requestedLabel) titleLines.push(`请求档位:${requestedLabel}`)
|
||||
if (actualLabel) titleLines.push(`实际档位:${actualLabel}`)
|
||||
if (billingLabel) {
|
||||
titleLines.push(`计费档位:${billingLabel}`)
|
||||
} else {
|
||||
titleLines.push(`计费档位:${state === 'pending' ? '待上游确认' : '未确认'}`)
|
||||
}
|
||||
if (requestedLabel) titleLines.push(`上游请求档位:${requestedLabel}`)
|
||||
// Billing is resolved from the same final provider request tier. Keep it
|
||||
// explicit in the tooltip without consulting a response-side tier.
|
||||
if (requestedLabel) titleLines.push(`计费档位:${requestedLabel}`)
|
||||
const title = titleLines.join('\n')
|
||||
return {
|
||||
label,
|
||||
state,
|
||||
className: serviceTierBadgeClass(state),
|
||||
label: 'Fast',
|
||||
className: '!bg-transparent text-amber-700 dark:text-amber-300',
|
||||
title,
|
||||
ariaLabel: titleLines.join(','),
|
||||
}
|
||||
@@ -1715,54 +1587,10 @@ function buildServiceTierBadgePresentation(
|
||||
|
||||
function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation | null {
|
||||
const requestedRaw = normalizeServiceTier(record.service_tier)
|
||||
const actualRaw = normalizeServiceTier(record.actual_service_tier)
|
||||
const requested = canonicalServiceTier(requestedRaw)
|
||||
const actual = canonicalServiceTier(actualRaw)
|
||||
const requestedFast = requested === 'priority'
|
||||
const actualFast = actual === 'priority'
|
||||
|
||||
if (actual) {
|
||||
if (requestedFast && !actualFast) {
|
||||
return buildServiceTierBadgePresentation(
|
||||
`Fast → ${actual}`,
|
||||
'downgraded',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
if (!requestedFast && actualFast) {
|
||||
const requestedLabel = requested ?? 'standard'
|
||||
return buildServiceTierBadgePresentation(
|
||||
requested ? `${requestedLabel} → Fast` : 'Fast',
|
||||
requested ? 'upgraded' : 'confirmed',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
if (actualFast) {
|
||||
return buildServiceTierBadgePresentation(
|
||||
'Fast',
|
||||
'confirmed',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!requestedFast) return null
|
||||
const displayStatus = getDisplayStatus(record)
|
||||
const isActive = displayStatus === 'pending' || displayStatus === 'streaming'
|
||||
return buildServiceTierBadgePresentation(
|
||||
isActive ? 'Fast · 待确认' : 'Fast · 未确认',
|
||||
isActive ? 'pending' : 'unconfirmed',
|
||||
requestedRaw,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
return buildServiceTierBadgePresentation(requestedRaw)
|
||||
}
|
||||
|
||||
function getServiceTierTitle(record: UsageRecord): string {
|
||||
@@ -1770,10 +1598,9 @@ function getServiceTierTitle(record: UsageRecord): string {
|
||||
if (badge) return badge.title
|
||||
|
||||
const requested = formatServiceTierFact(record.service_tier)
|
||||
const actual = formatServiceTierFact(record.actual_service_tier)
|
||||
return [
|
||||
requested ? `请求档位:${requested}` : null,
|
||||
actual ? `实际档位:${actual}` : null,
|
||||
requested ? `上游请求档位:${requested}` : null,
|
||||
requested ? `计费档位:${requested}` : null,
|
||||
].filter((line): line is string => Boolean(line)).join('\n')
|
||||
}
|
||||
|
||||
@@ -1781,10 +1608,10 @@ function getServiceTierTitle(record: UsageRecord): string {
|
||||
function getModelTooltip(record: UsageRecord): string {
|
||||
const actualModel = getActualModel(record)
|
||||
const reasoningEffort = getReasoningEffort(record)
|
||||
const requestType = getRequestTypeLabel(record)
|
||||
const serviceTierTitle = getServiceTierTitle(record)
|
||||
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
|
||||
const suffix = `${requestType ? `\n操作: ${requestType}` : ''}${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}`
|
||||
const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : ''
|
||||
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}`
|
||||
if (actualModel) {
|
||||
return `${record.model} -> ${actualModel}${suffix}`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, type App } from 'vue'
|
||||
import { createApp, h, nextTick, reactive, type App } from 'vue'
|
||||
import ElapsedTimeText from '../ElapsedTimeText.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
@@ -15,6 +15,18 @@ function mountElapsedTimeText(props: Record<string, unknown>) {
|
||||
return root
|
||||
}
|
||||
|
||||
function mountReactiveElapsedTimeText(initialProps: Record<string, unknown>) {
|
||||
const props = reactive(initialProps)
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ElapsedTimeText, { ...props }),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return { props, root }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
@@ -53,4 +65,30 @@ describe('ElapsedTimeText', () => {
|
||||
|
||||
expect(root.textContent).toBe('4.00s')
|
||||
})
|
||||
|
||||
it('does not pause or move total time backwards when the first-byte clock arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-17T12:00:06.250Z'))
|
||||
|
||||
const { props, root } = mountReactiveElapsedTimeText({
|
||||
status: 'pending',
|
||||
createdAt: '2026-07-17T12:00:00Z',
|
||||
responseTimeUpdatedAt: null,
|
||||
responseTimeMs: null,
|
||||
})
|
||||
await nextTick()
|
||||
expect(root.textContent).toBe('6.25s')
|
||||
|
||||
// The first-byte snapshot implies 5.85s at the same instant because its
|
||||
// timestamp is truncated to seconds. The visible clock must stay continuous.
|
||||
props.status = 'streaming'
|
||||
props.responseTimeUpdatedAt = '2026-07-17T12:00:06Z'
|
||||
props.responseTimeMs = 5600
|
||||
await nextTick()
|
||||
expect(root.textContent).toBe('6.25s')
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
await nextTick()
|
||||
expect(Number.parseFloat(root.textContent ?? '')).toBeGreaterThanOrEqual(6.74)
|
||||
})
|
||||
})
|
||||
|
||||
+60
-29
@@ -58,9 +58,13 @@ vi.mock('../JsonContentPanel.vue', async () => {
|
||||
type: null,
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'JSON',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('pre', JSON.stringify(props.data))
|
||||
return () => h('pre', { 'data-title': props.title }, JSON.stringify(props.data))
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -517,7 +521,8 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(requestPathCode?.textContent).toContain('/v1/images/generations')
|
||||
})
|
||||
|
||||
it('shows upstream response JSON inside the error block on trace nodes', async () => {
|
||||
it('shows upstream response headers and body in one error envelope', async () => {
|
||||
const upstreamErrorMessage = 'This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber'
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-upstream-response',
|
||||
@@ -527,21 +532,33 @@ describe('HorizontalRequestTimeline', () => {
|
||||
key_name: 'Upstream Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
error_message: 'execution runtime stream returned non-success status 302',
|
||||
error_message: 'execution runtime stream returned non-success status 400',
|
||||
extra_data: {
|
||||
upstream_response: {
|
||||
status_code: 302,
|
||||
headers: { location: '/' },
|
||||
status_code: 400,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'req_usage-cyber-risk-demo',
|
||||
},
|
||||
body: {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: upstreamErrorMessage,
|
||||
code: 400,
|
||||
},
|
||||
},
|
||||
body_ref: 'usage://request/req-1/response_body',
|
||||
body_state: 'reference',
|
||||
},
|
||||
error_flow: {
|
||||
source: 'upstream_response',
|
||||
status_code: 302,
|
||||
status_code: 400,
|
||||
classification: 'use_default',
|
||||
decision: 'use_default',
|
||||
propagation: 'none',
|
||||
retryable: false,
|
||||
safe_to_expose: false,
|
||||
message: 'execution runtime stream returned non-success status 302',
|
||||
message: 'execution runtime stream returned non-success status 400',
|
||||
},
|
||||
client_response: {
|
||||
status_code: 502,
|
||||
@@ -555,15 +572,31 @@ describe('HorizontalRequestTimeline', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('错误信息')
|
||||
expect(root.textContent).toContain('HTTP 302')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 302')
|
||||
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":302')
|
||||
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"headers"')
|
||||
expect(root.textContent).toContain('HTTP 400')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 400')
|
||||
const upstreamResponse = root.querySelector<HTMLElement>('.error-upstream-response-json pre')
|
||||
expect(upstreamResponse?.dataset.title).toBe('上游响应')
|
||||
expect(JSON.parse(upstreamResponse?.textContent ?? '{}')).toEqual({
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'req_usage-cyber-risk-demo',
|
||||
},
|
||||
body: {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: upstreamErrorMessage,
|
||||
code: 400,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(upstreamResponse?.textContent).not.toContain('"status_code"')
|
||||
expect(upstreamResponse?.textContent).not.toContain('"headers"')
|
||||
expect(upstreamResponse?.textContent).not.toContain('"body_ref"')
|
||||
expect(upstreamResponse?.textContent).not.toContain('"body_state"')
|
||||
expect(root.textContent).not.toContain('上游真实响应')
|
||||
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 302')
|
||||
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 400')
|
||||
expect(root.textContent).not.toContain('真实请求错误')
|
||||
expect(root.textContent).not.toContain('返回客户端响应')
|
||||
expect(root.textContent).not.toContain('上游响应')
|
||||
expect(root.textContent).not.toContain('默认处理')
|
||||
expect(root.textContent).not.toContain('none')
|
||||
expect(root.textContent).not.toContain('不再重试')
|
||||
@@ -601,12 +634,12 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('流式格式转换失败')
|
||||
expect(root.textContent).toContain('上游返回了当前不支持的 stream event')
|
||||
expect(root.textContent).toContain('字段 $.type = "response.future.delta"')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.type"')
|
||||
expect(errorJsonText).toContain('"analysis_hint"')
|
||||
expect(errorJsonText).toContain('"raw"')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.type"')
|
||||
expect(diagnosticText).toContain('"analysis_hint"')
|
||||
expect(diagnosticText).toContain('"raw"')
|
||||
expect(diagnosticText).toContain('"body_state":"disabled"')
|
||||
expect(root.querySelector('.error-upstream-response-json')?.textContent).toContain('"header":{"content-type":"application/json"}')
|
||||
})
|
||||
|
||||
it('formats request conversion diagnostics with field paths on skipped trace nodes', async () => {
|
||||
@@ -664,9 +697,8 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('流式格式转换失败')
|
||||
expect(root.textContent).toContain('finish reason')
|
||||
expect(root.textContent).toContain('字段 $.finish_reason = "future_reason"')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.finish_reason"')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.finish_reason"')
|
||||
})
|
||||
|
||||
it('uses conversion messages from error_flow as the diagnostic breakpoint source', async () => {
|
||||
@@ -701,10 +733,10 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
|
||||
expect(root.textContent).toContain('字段 $.n 会丢失信息')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 500')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.n"')
|
||||
expect(errorJsonText).toContain('断点在请求/响应格式转换器')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"body_state":"disabled"')
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.n"')
|
||||
expect(diagnosticText).toContain('断点在请求/响应格式转换器')
|
||||
})
|
||||
|
||||
it('shows failed diagnostic messages even when the only response panel data is diagnostic metadata', async () => {
|
||||
@@ -735,9 +767,8 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('错误信息')
|
||||
expect(root.textContent).toContain('格式转换失败')
|
||||
expect(root.textContent).toContain('OpenAI Responses 不支持字段 $.temperature')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.temperature"')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.temperature"')
|
||||
})
|
||||
|
||||
it('keeps the failure message when upstream response only records an empty body state', async () => {
|
||||
|
||||
@@ -97,4 +97,388 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
})
|
||||
expect(document.body.textContent).not.toContain('输出 $0/M')
|
||||
})
|
||||
|
||||
it('shows mapping, reasoning, Fast, and Cyber together in the model header', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-cyber-risk-demo',
|
||||
request_id: 'req_usage-cyber-risk-demo',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
request_body: {
|
||||
model: 'gpt-5',
|
||||
reasoning: { effort: 'xhigh' },
|
||||
},
|
||||
service_tier: 'priority',
|
||||
// A response-side tier must not be used for the Fast badge or billing.
|
||||
actual_service_tier: 'default',
|
||||
provider_request_body: {
|
||||
model: 'gpt-5.1',
|
||||
reasoning: { effort: 'max' },
|
||||
service_tier: 'priority',
|
||||
},
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
|
||||
response_body: {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: 'This content was flagged for possible cybersecurity risk.',
|
||||
code: 400,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-cyber-risk-demo',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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(() => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-display]')?.textContent)
|
||||
.toContain('gpt-5')
|
||||
expect(document.body.querySelector('[data-request-detail-model-display]')?.textContent)
|
||||
.toContain('gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
|
||||
.toContain('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent?.trim())
|
||||
.toBe('Fast')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
const modelLayout = document.body.querySelector(
|
||||
'[data-request-detail-model-layout="stacked"]',
|
||||
)
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('->')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5.1')
|
||||
expect(modelLayout?.firstElementChild?.querySelector('[data-request-detail-model-badge]'))
|
||||
.toBeNull()
|
||||
const modelBadgesRow = modelLayout?.querySelector(
|
||||
'[data-request-detail-model-badges-row]',
|
||||
)
|
||||
expect(modelBadgesRow?.textContent).toContain('xhigh -> max')
|
||||
expect(modelBadgesRow?.textContent).toContain('Fast')
|
||||
expect(modelBadgesRow?.textContent).toContain('Cyber')
|
||||
const serviceTierFacts = document.body.querySelector('[data-testid="service-tier-facts"]')
|
||||
expect([...serviceTierFacts?.querySelectorAll('dt') ?? []].map(node => node.textContent?.trim()))
|
||||
.toEqual(['上游请求层级', '计费层级'])
|
||||
expect([...serviceTierFacts?.querySelectorAll('dd') ?? []].map(node => node.textContent?.trim()))
|
||||
.toEqual(['Fast', 'Fast'])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the Cyber badge stable from the selected row while lightweight detail loads', async () => {
|
||||
let resolveDetail!: (value: RequestDetail) => void
|
||||
apiMocks.getRequestDetail.mockReturnValue(new Promise<RequestDetail>((resolve) => {
|
||||
resolveDetail = resolve
|
||||
}))
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const requestId = ref('usage-cyber-summary')
|
||||
const summaryRecord = ref<Record<string, unknown>>({
|
||||
id: 'usage-cyber-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: requestId.value,
|
||||
summaryRecord: summaryRecord.value as never,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
|
||||
resolveDetail({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-cyber-summary',
|
||||
request_id: 'usage-cyber-summary',
|
||||
model: 'gpt-5',
|
||||
// Lightweight detail can legitimately omit these final-provider facts.
|
||||
target_model: null,
|
||||
requested_reasoning_effort: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'execution runtime stream returned non-success status 400',
|
||||
response_body: null,
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent)
|
||||
.toContain('gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
|
||||
.toContain('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
const tierValues = [
|
||||
...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd'),
|
||||
].map(node => node.textContent?.trim())
|
||||
expect(tierValues).toEqual(['Fast', 'Fast'])
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a stale summary Cyber badge when newer detail completed successfully', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-cyber-recovered',
|
||||
request_id: 'usage-cyber-recovered',
|
||||
model: 'gpt-5',
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-cyber-recovered',
|
||||
summaryRecord: {
|
||||
id: 'usage-cyber-recovered',
|
||||
model: 'gpt-5',
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cost: 0,
|
||||
is_stream: false,
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
|
||||
created_at: '2026-07-17T00:00:00Z',
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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(() => {
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]'))
|
||||
.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('uses populated detail fallbacks without overriding a populated summary tier', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-standard-summary',
|
||||
request_id: 'usage-standard-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
// The non-empty summary tier remains authoritative over this stale fact.
|
||||
service_tier: 'priority',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const summaryRecord = ref<Record<string, unknown>>({
|
||||
id: 'usage-standard-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
model_version: null,
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: null,
|
||||
service_tier: 'default',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-standard-summary',
|
||||
summaryRecord: summaryRecord.value as never,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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(() => {
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
|
||||
.toBe('gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
|
||||
const tierValues = [
|
||||
...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd'),
|
||||
].map(node => node.textContent?.trim())
|
||||
expect(tierValues).toEqual(['default', 'default'])
|
||||
})
|
||||
})
|
||||
|
||||
it('uses detail model_version when the lightweight summary has null model facts', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-version-summary',
|
||||
request_id: 'usage-version-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
model_version: 'gpt-5.1-2026-07-17',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-version-summary',
|
||||
summaryRecord: {
|
||||
id: 'usage-version-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
model_version: null,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cost: 0,
|
||||
is_stream: false,
|
||||
created_at: '2026-07-17T00:00:00Z',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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(() => {
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
|
||||
.toBe('gpt-5.1-2026-07-17')
|
||||
})
|
||||
})
|
||||
|
||||
it('lets a newer final-provider summary clear facts cached from an earlier candidate', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-final-candidate',
|
||||
request_id: 'usage-final-candidate',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
status: 'streaming',
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const summaryRecord = ref<Record<string, unknown>>({
|
||||
id: 'usage-final-candidate',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
status: 'streaming',
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-final-candidate',
|
||||
summaryRecord: summaryRecord.value as never,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
summaryRecord.value = {
|
||||
...summaryRecord.value,
|
||||
target_model: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.querySelector('[data-usage-model-target]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
|
||||
expect([...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd')]
|
||||
.some(node => node.textContent?.trim() === 'Fast')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,14 +13,12 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ServiceTierFacts', () => {
|
||||
it('renders all three facts and marks a missing actual tier explicitly', () => {
|
||||
it('renders request and billing facts from the same request tier', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: null,
|
||||
billing: 'flex',
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
@@ -28,30 +26,25 @@ describe('ServiceTierFacts', () => {
|
||||
|
||||
expect(root.querySelector('[data-testid="service-tier-facts"]')).not.toBeNull()
|
||||
expect([...root.querySelectorAll('dt')].map(node => node.textContent?.trim())).toEqual([
|
||||
'请求层级',
|
||||
'实际层级',
|
||||
'上游请求层级',
|
||||
'计费层级',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
'Fast',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
'Fast',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the same Fast label for raw priority and fast facts', () => {
|
||||
it('uses the Fast label for a raw fast request tier', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'priority',
|
||||
requested: 'fast',
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
@@ -60,12 +53,10 @@ describe('ServiceTierFacts', () => {
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'Fast',
|
||||
'Fast',
|
||||
'Fast',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
|
||||
'Fast',
|
||||
'Fast',
|
||||
'Fast',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -75,8 +66,6 @@ describe('ServiceTierFacts', () => {
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'fast',
|
||||
priceMultiplier: 2.5,
|
||||
}),
|
||||
})
|
||||
@@ -94,8 +83,6 @@ describe('ServiceTierFacts', () => {
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: null,
|
||||
billing: null,
|
||||
priceMultiplier: null,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -290,19 +290,31 @@ describe('UsageRecordsTable', () => {
|
||||
})
|
||||
|
||||
it('shows reasoning effort next to the model name', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({ reasoning_effort: 'xhigh' })])
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('gpt-5')
|
||||
expect(root.textContent).toContain('xhigh')
|
||||
const inlineLayout = root.querySelector('[data-usage-model-layout="inline"]')
|
||||
expect(inlineLayout).not.toBeNull()
|
||||
expect(inlineLayout?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh')
|
||||
expect(inlineLayout?.querySelector('[data-usage-model-badge="fast"]')?.textContent?.trim())
|
||||
.toBe('Fast')
|
||||
})
|
||||
|
||||
it('shows request reasoning effort while the record is pending', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'pending',
|
||||
reasoning_effort: 'max',
|
||||
requested_reasoning_effort: 'max',
|
||||
reasoning_effort: null,
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('max')
|
||||
expect(root.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('max')
|
||||
})
|
||||
|
||||
it('marks Responses compaction while the record is pending', () => {
|
||||
@@ -311,65 +323,145 @@ describe('UsageRecordsTable', () => {
|
||||
request_type: 'compact',
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('会话压缩')
|
||||
expect(root.querySelector('[data-usage-model-badge="compact"]')?.textContent?.trim())
|
||||
.toBe('会话压缩')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['priority', 'priority'],
|
||||
['fast', 'fast'],
|
||||
['priority', 'fast'],
|
||||
['fast', 'priority'],
|
||||
])('shows confirmed Fast for requested %s and actual %s', (requested, actual) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: requested,
|
||||
actual_service_tier: actual,
|
||||
})])
|
||||
|
||||
const badge = expectServiceTierBadge(root, 'Fast')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'请求档位:Fast',
|
||||
'实际档位:Fast',
|
||||
'计费档位:Fast',
|
||||
].join('\n'))
|
||||
expect(badge.getAttribute('aria-label')).toBe(
|
||||
'请求档位:Fast,实际档位:Fast,计费档位:Fast',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows fast to standard when the provider downgrades a priority request', () => {
|
||||
it('shows mapping, reasoning, Fast, and Cyber together in the model area', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
// A conflicting response-side value must not affect the Fast badge.
|
||||
actual_service_tier: 'default',
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
|
||||
})])
|
||||
|
||||
const badge = expectServiceTierBadge(root, 'Fast → standard')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'请求档位:Fast',
|
||||
'实际档位:default',
|
||||
'计费档位:standard',
|
||||
].join('\n'))
|
||||
expect(root.textContent).toContain('gpt-5')
|
||||
expect(root.textContent).toContain('gpt-5.1')
|
||||
expect(root.textContent).toContain('xhigh -> max')
|
||||
expect(root.textContent).toContain('Fast')
|
||||
const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]')
|
||||
const fastBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="fast"]')
|
||||
const cyberBadges = root.querySelectorAll<HTMLElement>('[data-usage-model-badge="cyber"]')
|
||||
const cyberBadge = cyberBadges[0]
|
||||
for (const badge of [reasoningBadge, fastBadge, cyberBadge]) {
|
||||
expect(badge).not.toBeNull()
|
||||
expect(badge?.classList.contains('h-4')).toBe(true)
|
||||
expect(badge?.classList.contains('rounded-full')).toBe(true)
|
||||
expect(badge?.classList.contains('px-1.5')).toBe(true)
|
||||
expect(badge?.classList.contains('text-[10px]')).toBe(true)
|
||||
expect(badge?.classList.contains('leading-4')).toBe(true)
|
||||
}
|
||||
expect(reasoningBadge?.classList.contains('border-primary/30')).toBe(true)
|
||||
expect(reasoningBadge?.classList.contains('bg-primary/5')).toBe(true)
|
||||
expect(reasoningBadge?.classList.contains('text-primary')).toBe(true)
|
||||
expect(fastBadge?.getAttribute('variant')).toBe('outline-transparent')
|
||||
expect(fastBadge?.classList.contains('border-amber-400/50')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('!bg-transparent')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('bg-amber-400/10')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('text-amber-700')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('border-primary/30')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('bg-primary/5')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('text-rose-600')).toBe(true)
|
||||
expect(cyberBadges.length).toBeGreaterThan(0)
|
||||
expect([...cyberBadges].every(badge => badge.textContent?.trim() === 'Cyber')).toBe(true)
|
||||
expect([...cyberBadges].every(badge => badge.title === '上游 Cyber Policy 拒绝')).toBe(true)
|
||||
|
||||
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
|
||||
expect(stackedLayout).not.toBeNull()
|
||||
const modelRow = stackedLayout?.firstElementChild
|
||||
expect(modelRow?.textContent).toContain('gpt-5')
|
||||
expect(modelRow?.textContent).toContain('->')
|
||||
expect(modelRow?.textContent).toContain('gpt-5.1')
|
||||
expect(modelRow?.querySelector('[data-usage-model-badge]')).toBeNull()
|
||||
const badgesRow = stackedLayout?.querySelector('[data-usage-model-badges-row]')
|
||||
expect(badgesRow?.textContent).toContain('xhigh -> max')
|
||||
expect(badgesRow?.textContent).toContain('Fast')
|
||||
expect(badgesRow?.textContent).toContain('Cyber')
|
||||
})
|
||||
|
||||
it('shows fast to flex when the provider moves a priority request to flex', () => {
|
||||
it('stacks three model badges even without a model mapping', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'flex',
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast → flex')
|
||||
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
|
||||
expect(stackedLayout?.firstElementChild?.textContent?.trim()).toBe('gpt-5')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('xhigh')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('Fast')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
})
|
||||
|
||||
it('shows standard to fast when the provider upgrades a default request', () => {
|
||||
it.each(['priority', 'fast', ' Priority ', 'FAST'])(
|
||||
'shows Fast from the final provider request tier %s',
|
||||
(requested) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: requested,
|
||||
actual_service_tier: 'default',
|
||||
})])
|
||||
|
||||
const badge = expectServiceTierBadge(root, 'Fast')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'上游请求档位:Fast',
|
||||
'计费档位:Fast',
|
||||
].join('\n'))
|
||||
expect(badge.getAttribute('aria-label')).toBe(
|
||||
'上游请求档位:Fast,计费档位:Fast',
|
||||
)
|
||||
expect(badge.textContent).not.toContain('→')
|
||||
expect(badge.textContent).not.toContain('待确认')
|
||||
expect(badge.textContent).not.toContain('未确认')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['default', 'flex', null])(
|
||||
'ignores the response-side tier %s when the request tier is Fast',
|
||||
(actualServiceTier) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: actualServiceTier,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast')
|
||||
expect(root.textContent).not.toContain('Fast →')
|
||||
},
|
||||
)
|
||||
|
||||
it('does not infer Fast from a response-side priority tier', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'default',
|
||||
actual_service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'standard → Fast')
|
||||
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not infer Fast when only the response has a tier', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: null,
|
||||
actual_service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['pending', 'streaming'] as const)(
|
||||
'shows fast as pending confirmation while a priority request is %s',
|
||||
'keeps Fast stable while a priority request is %s',
|
||||
(status) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
@@ -377,18 +469,18 @@ describe('UsageRecordsTable', () => {
|
||||
status,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast · 待确认')
|
||||
expectServiceTierBadge(root, 'Fast')
|
||||
},
|
||||
)
|
||||
|
||||
it('shows fast as unconfirmed when a completed priority request has no actual tier', () => {
|
||||
it('keeps Fast stable for a completed request without a response tier', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: null,
|
||||
status: 'completed',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast · 未确认')
|
||||
expectServiceTierBadge(root, 'Fast')
|
||||
})
|
||||
|
||||
it('offers embedding API formats in the usage record filter', () => {
|
||||
|
||||
@@ -162,7 +162,146 @@ describe('useUsageData', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves detail-filled usage metrics when a later list refresh is still empty', async () => {
|
||||
it('clears a failed candidate Cyber snapshot when the final candidate completes', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: cyberMessage,
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})
|
||||
expect(currentRecords.value[0]?.error_message).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an older same-rank terminal snapshot as a unit', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: cyberMessage,
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})
|
||||
expect(currentRecords.value[0]?.error_message).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps live response duration and its anchor atomic across stale refreshes', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'streaming',
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
first_byte_time_ms: 2000,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'streaming',
|
||||
response_time_ms: 5000,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
first_byte_time_ms: 1500,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
status: 'streaming',
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
first_byte_time_ms: 2000,
|
||||
})
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
response_time_ms: 5200,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
first_byte_time_ms: 1800,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
response_time_ms: 5200,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
first_byte_time_ms: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves client/detail metrics but clears stale final-provider facts from the next list snapshot', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
@@ -217,9 +356,10 @@ describe('useUsageData', () => {
|
||||
has_retry: true,
|
||||
target_model: 'gpt-5.5',
|
||||
request_type: 'compact',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'auto',
|
||||
actual_service_tier: 'priority',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'default',
|
||||
})
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
@@ -245,11 +385,12 @@ describe('useUsageData', () => {
|
||||
endpoint_api_format: undefined,
|
||||
has_format_conversion: undefined,
|
||||
has_retry: false,
|
||||
target_model: null,
|
||||
target_model: undefined,
|
||||
request_type: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
actual_service_tier: null,
|
||||
requested_reasoning_effort: null,
|
||||
reasoning_effort: undefined,
|
||||
service_tier: undefined,
|
||||
actual_service_tier: undefined,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
@@ -280,11 +421,12 @@ describe('useUsageData', () => {
|
||||
endpoint_api_format: 'openai:responses',
|
||||
has_format_conversion: false,
|
||||
has_retry: true,
|
||||
target_model: 'gpt-5.5',
|
||||
target_model: null,
|
||||
request_type: 'compact',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'auto',
|
||||
actual_service_tier: 'priority',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
actual_service_tier: null,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ import { createDefaultStats } from '../types'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getErrorStatus } from '@/types/api-error'
|
||||
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../utils/providerStats'
|
||||
import {
|
||||
mergeUsageRecordErrorMessage,
|
||||
mergeUsageRecordFirstByteTimeMs,
|
||||
mergeUsageRecordResponseTiming,
|
||||
parseUsageTimestampMs,
|
||||
} from '../utils/recordSync'
|
||||
|
||||
export interface UseUsageDataOptions {
|
||||
isAdminPage: Ref<boolean>
|
||||
@@ -464,25 +470,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function mergePositiveDurationMs(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
): number | null | undefined {
|
||||
const existingIsPositive = typeof existingValue === 'number' && Number.isFinite(existingValue) && existingValue > 0
|
||||
const nextIsPositive = typeof nextValue === 'number' && Number.isFinite(nextValue) && nextValue > 0
|
||||
|
||||
if (existingIsPositive && nextIsPositive) {
|
||||
return Math.max(existingValue, nextValue)
|
||||
}
|
||||
if (existingIsPositive) {
|
||||
return existingValue
|
||||
}
|
||||
if (nextIsPositive) {
|
||||
return nextValue
|
||||
}
|
||||
return existingValue ?? nextValue
|
||||
}
|
||||
|
||||
function mergeSparseRecordMetric(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
@@ -539,10 +526,18 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
const hasNextStatus = typeof record.status === 'string' && record.status.length > 0
|
||||
const currentRank = hasExistingStatus ? (statusPriority[existing.status] ?? -1) : -1
|
||||
const nextRank = hasNextStatus ? (statusPriority[record.status] ?? -1) : -1
|
||||
const statusProgressed = hasNextStatus && (
|
||||
const existingUpdatedAtMs = parseUsageTimestampMs(existing.updated_at)
|
||||
const nextUpdatedAtMs = parseUsageTimestampMs(record.updated_at)
|
||||
const nextStatusSnapshotIsStale = existingUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs < existingUpdatedAtMs
|
||||
const sameRankTerminalTransition = currentRank === 2 && nextRank === 2
|
||||
const statusProgressed = hasNextStatus && !nextStatusSnapshotIsStale && (
|
||||
!hasExistingStatus ||
|
||||
nextRank > currentRank ||
|
||||
(nextRank === currentRank && existing.status === record.status)
|
||||
(nextRank === currentRank && (
|
||||
existing.status === record.status || sameRankTerminalTransition
|
||||
))
|
||||
)
|
||||
const mergedStatus = statusProgressed ? record.status : existing.status
|
||||
|
||||
@@ -586,12 +581,27 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
? existing.client_requested_stream
|
||||
: undefined
|
||||
const clientIsStream = mergeBooleanTrueWins(existingClientIsStream, recordClientIsStream) ?? clientRequestedStream
|
||||
const nextTimingIsAuthoritative = statusProgressed &&
|
||||
(record.status === 'completed' || record.status === 'failed' || record.status === 'cancelled')
|
||||
const responseTiming = mergeUsageRecordResponseTiming(
|
||||
{
|
||||
response_time_ms: existing.response_time_ms,
|
||||
response_time_updated_at: existing.response_time_updated_at,
|
||||
},
|
||||
{
|
||||
response_time_ms: record.response_time_ms,
|
||||
response_time_updated_at: record.response_time_updated_at,
|
||||
},
|
||||
{ preferNext: nextTimingIsAuthoritative },
|
||||
)
|
||||
|
||||
return {
|
||||
...record,
|
||||
// 保留详情抽屉/活跃轮询已经拿到的完整指标,避免列表刷新用 0 或空值回退。
|
||||
status: mergedStatus,
|
||||
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
||||
provider: statusProgressed
|
||||
? (protectProvider ? existing.provider : (record.provider || existing.provider))
|
||||
: existing.provider,
|
||||
input_tokens: mergeSparseRecordMetric(existing.input_tokens, record.input_tokens) ?? record.input_tokens,
|
||||
effective_input_tokens: mergeSparseRecordMetric(existing.effective_input_tokens, record.effective_input_tokens) ?? record.effective_input_tokens,
|
||||
output_tokens: mergeSparseRecordMetric(existing.output_tokens, record.output_tokens) ?? record.output_tokens,
|
||||
@@ -610,13 +620,31 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
cache_read_input_tokens: mergeSparseRecordMetric(existing.cache_read_input_tokens, record.cache_read_input_tokens) ?? record.cache_read_input_tokens,
|
||||
cost: mergeSparseRecordMetric(existing.cost, record.cost) ?? record.cost,
|
||||
actual_cost: mergeSparseRecordMetric(existing.actual_cost, record.actual_cost) ?? record.actual_cost,
|
||||
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
|
||||
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
|
||||
updated_at: record.updated_at ?? existing.updated_at,
|
||||
response_time_updated_at: record.response_time_updated_at ?? existing.response_time_updated_at,
|
||||
status_code: record.status_code ?? existing.status_code,
|
||||
error_message: record.error_message ?? existing.error_message,
|
||||
image_progress: record.image_progress ?? existing.image_progress,
|
||||
response_time_ms: responseTiming.response_time_ms,
|
||||
first_byte_time_ms: mergeUsageRecordFirstByteTimeMs(
|
||||
existing.first_byte_time_ms,
|
||||
record.first_byte_time_ms,
|
||||
),
|
||||
updated_at: statusProgressed
|
||||
? (record.updated_at ?? existing.updated_at)
|
||||
: existing.updated_at,
|
||||
response_time_updated_at: responseTiming.response_time_updated_at,
|
||||
// Status, code and error are one lifecycle snapshot. An accepted full
|
||||
// list snapshot may clear an earlier candidate's 400/Cyber failure;
|
||||
// a rejected stale status snapshot must not mutate either field.
|
||||
status_code: statusProgressed
|
||||
? (record.status_code ?? undefined)
|
||||
: existing.status_code,
|
||||
error_message: statusProgressed
|
||||
? mergeUsageRecordErrorMessage(
|
||||
existing.error_message,
|
||||
record.error_message,
|
||||
{ authoritative: true },
|
||||
)
|
||||
: existing.error_message,
|
||||
image_progress: statusProgressed
|
||||
? (record.image_progress ?? existing.image_progress)
|
||||
: existing.image_progress,
|
||||
is_stream: upstreamIsStream,
|
||||
upstream_is_stream: upstreamIsStream,
|
||||
client_requested_stream: clientRequestedStream,
|
||||
@@ -627,13 +655,50 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
has_fallback: existing.has_fallback === true || record.has_fallback === true,
|
||||
has_retry: existing.has_retry === true || record.has_retry === true,
|
||||
api_key_name: record.api_key_name || existing.api_key_name,
|
||||
provider_key_name: record.provider_key_name || existing.provider_key_name,
|
||||
rate_multiplier: record.rate_multiplier ?? existing.rate_multiplier,
|
||||
target_model: record.target_model ?? existing.target_model,
|
||||
request_type: record.request_type ?? existing.request_type,
|
||||
reasoning_effort: record.reasoning_effort ?? existing.reasoning_effort,
|
||||
service_tier: record.service_tier ?? existing.service_tier,
|
||||
actual_service_tier: record.actual_service_tier ?? existing.actual_service_tier
|
||||
provider_key_name: statusProgressed
|
||||
? (record.provider_key_name || existing.provider_key_name)
|
||||
: existing.provider_key_name,
|
||||
rate_multiplier: statusProgressed
|
||||
? (record.rate_multiplier ?? existing.rate_multiplier)
|
||||
: existing.rate_multiplier,
|
||||
// Full list snapshots describe the final provider candidate. Missing/null means the
|
||||
// final request did not map the model and must clear an earlier candidate's arrow.
|
||||
target_model: statusProgressed
|
||||
? (typeof record.target_model === 'string' && record.target_model.trim()
|
||||
? record.target_model
|
||||
: null)
|
||||
: existing.target_model,
|
||||
// Request type is client-request identity, not a provider-candidate fact. Preserve a
|
||||
// known compact operation when a later sparse snapshot omits it.
|
||||
request_type:
|
||||
typeof record.request_type === 'string' && record.request_type.trim()
|
||||
? record.request_type
|
||||
: existing.request_type,
|
||||
requested_reasoning_effort:
|
||||
typeof record.requested_reasoning_effort === 'string'
|
||||
&& record.requested_reasoning_effort.trim()
|
||||
? record.requested_reasoning_effort
|
||||
: existing.requested_reasoning_effort,
|
||||
// Provider reasoning belongs to the final candidate just like service_tier; do not
|
||||
// retain a previous candidate's `max` when the final request has no reasoning field.
|
||||
reasoning_effort: statusProgressed
|
||||
? (typeof record.reasoning_effort === 'string' && record.reasoning_effort.trim()
|
||||
? record.reasoning_effort
|
||||
: null)
|
||||
: existing.reasoning_effort,
|
||||
// The list response is the authoritative snapshot of the final provider request. Do not
|
||||
// carry a tier forward when this response has no tier; doing so can leave a stale Fast
|
||||
// badge after the final upstream request falls back to Standard.
|
||||
service_tier: statusProgressed
|
||||
? (typeof record.service_tier === 'string' && record.service_tier.trim()
|
||||
? record.service_tier
|
||||
: null)
|
||||
: existing.service_tier,
|
||||
actual_service_tier: statusProgressed
|
||||
? (typeof record.actual_service_tier === 'string' && record.actual_service_tier.trim()
|
||||
? record.actual_service_tier
|
||||
: null)
|
||||
: existing.actual_service_tier
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,9 +97,10 @@ export interface UsageRecord {
|
||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
|
||||
request_type?: string | null // 由请求语义识别出的操作类型
|
||||
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
|
||||
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
|
||||
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
|
||||
actual_service_tier?: string | null // Provider 响应确认的实际服务层级
|
||||
actual_service_tier?: string | null // 响应侧审计事实,不用于 Fast 展示或计费
|
||||
api_format?: string
|
||||
endpoint_api_format?: string // 端点原生格式
|
||||
has_format_conversion?: boolean // 是否发生了格式转换
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isCyberPolicyError } from '../cyberError'
|
||||
|
||||
describe('isCyberPolicyError', () => {
|
||||
it('recognizes the provider cybersecurity refusal message', () => {
|
||||
expect(isCyberPolicyError({
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
|
||||
code: 400,
|
||||
},
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes an explicit cyber_policy code', () => {
|
||||
expect(isCyberPolicyError({ error: { code: 'CYBER_POLICY' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes explicit Cyber Policy types and reasons', () => {
|
||||
expect(isCyberPolicyError({ error: { type: 'cyber_policy' } })).toBe(true)
|
||||
expect(isCyberPolicyError({ error: { type: 'CYBER' } })).toBe(true)
|
||||
expect(isCyberPolicyError({ error: { reason: 'cyber-policy' } })).toBe(true)
|
||||
expect(isCyberPolicyError({ error: { category: 'cyber_policy_violation' } })).toBe(true)
|
||||
expect(isCyberPolicyError({ error: { type: 'cybersecurity-risk' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes structured Cyber classifiers inside a serialized error', () => {
|
||||
expect(isCyberPolicyError('{"error":{"type":"cyber"}}')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not classify ordinary invalid requests as Cyber Policy failures', () => {
|
||||
expect(isCyberPolicyError({
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: 'The request payload is malformed',
|
||||
code: 400,
|
||||
},
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('does not classify a generic use of the word cyber', () => {
|
||||
expect(isCyberPolicyError('The cyber security report was generated successfully')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UsageRecord } from '../../types'
|
||||
import {
|
||||
mergeUsageRecordErrorMessage,
|
||||
mergeUsageRecordFirstByteTimeMs,
|
||||
mergeUsageRecordLifecycleSnapshot,
|
||||
mergeUsageRecordResponseTiming,
|
||||
syncUsageRecordStreamResolution,
|
||||
} from '../recordSync'
|
||||
|
||||
@@ -75,3 +78,140 @@ describe('mergeUsageRecordFirstByteTimeMs', () => {
|
||||
expect(mergeUsageRecordFirstByteTimeMs(-1, null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeUsageRecordResponseTiming', () => {
|
||||
it('keeps duration and update timestamp as one monotonic active snapshot', () => {
|
||||
const existing = {
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
}
|
||||
const stale = {
|
||||
response_time_ms: 5000,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
}
|
||||
|
||||
expect(mergeUsageRecordResponseTiming(existing, stale)).toBe(existing)
|
||||
})
|
||||
|
||||
it('accepts a live snapshot whose projected elapsed time has advanced', () => {
|
||||
const existing = {
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
}
|
||||
const advanced = {
|
||||
response_time_ms: 7000,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
}
|
||||
|
||||
expect(mergeUsageRecordResponseTiming(existing, advanced)).toBe(advanced)
|
||||
})
|
||||
|
||||
it('does not combine an unanchored detail estimate with an existing anchor', () => {
|
||||
const existing = {
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
}
|
||||
const detailEstimate = {
|
||||
response_time_ms: 6000,
|
||||
response_time_updated_at: null,
|
||||
}
|
||||
|
||||
expect(mergeUsageRecordResponseTiming(existing, detailEstimate)).toBe(existing)
|
||||
})
|
||||
|
||||
it('lets a terminal snapshot replace the active estimate', () => {
|
||||
const terminal = {
|
||||
response_time_ms: 5200,
|
||||
response_time_updated_at: null,
|
||||
}
|
||||
|
||||
expect(mergeUsageRecordResponseTiming({
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
}, terminal, { preferNext: true })).toBe(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeUsageRecordErrorMessage', () => {
|
||||
const cyberMessage = 'This content was flagged for possible cybersecurity risk. Join the Trusted Access for Cyber program: https://chatgpt.com/cyber'
|
||||
|
||||
it('keeps an authoritative Cyber Policy message when trace reports a generic error', () => {
|
||||
expect(mergeUsageRecordErrorMessage(
|
||||
cyberMessage,
|
||||
'execution runtime stream ended with a terminal error',
|
||||
)).toBe(cyberMessage)
|
||||
})
|
||||
|
||||
it('keeps an existing error when trace omits its error message', () => {
|
||||
expect(mergeUsageRecordErrorMessage(cyberMessage, undefined)).toBe(cyberMessage)
|
||||
expect(mergeUsageRecordErrorMessage(cyberMessage, null)).toBe(cyberMessage)
|
||||
expect(mergeUsageRecordErrorMessage(cyberMessage, ' ')).toBe(cyberMessage)
|
||||
})
|
||||
|
||||
it('accepts a Cyber Policy message discovered by trace', () => {
|
||||
expect(mergeUsageRecordErrorMessage('Request failed', cyberMessage)).toBe(cyberMessage)
|
||||
})
|
||||
|
||||
it('updates ordinary errors when the next snapshot has a more specific message', () => {
|
||||
expect(mergeUsageRecordErrorMessage('Request failed', 'rate limit exceeded'))
|
||||
.toBe('rate limit exceeded')
|
||||
expect(mergeUsageRecordErrorMessage(undefined, 'Request failed')).toBe('Request failed')
|
||||
})
|
||||
|
||||
it('lets an authoritative final-candidate snapshot replace or clear Cyber', () => {
|
||||
expect(mergeUsageRecordErrorMessage(
|
||||
cyberMessage,
|
||||
'rate limit exceeded',
|
||||
{ authoritative: true },
|
||||
)).toBe('rate limit exceeded')
|
||||
expect(mergeUsageRecordErrorMessage(
|
||||
cyberMessage,
|
||||
null,
|
||||
{ authoritative: true },
|
||||
)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeUsageRecordLifecycleSnapshot', () => {
|
||||
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
|
||||
|
||||
it('rejects an older failed detail without changing status, code, or error', () => {
|
||||
expect(mergeUsageRecordLifecycleSnapshot({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
}, {
|
||||
status: 'failed',
|
||||
statusCode: 400,
|
||||
errorMessage: cyberMessage,
|
||||
updatedAt: '2026-07-17T00:00:01Z',
|
||||
})).toEqual({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
accepted: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a newer completed detail and clears an earlier Cyber failure', () => {
|
||||
expect(mergeUsageRecordLifecycleSnapshot({
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: cyberMessage,
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
}, {
|
||||
status: 'completed',
|
||||
statusCode: 200,
|
||||
errorMessage: null,
|
||||
updatedAt: '2026-07-17T00:00:02Z',
|
||||
})).toEqual({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
accepted: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from '../service-tier'
|
||||
|
||||
describe('service tier facts', () => {
|
||||
it('keeps requested, actual and billing tiers independent', () => {
|
||||
const facts = resolveServiceTierFacts({
|
||||
it('uses the final provider request tier for display and billing', () => {
|
||||
const source = {
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'default',
|
||||
settlement: {
|
||||
@@ -21,17 +21,28 @@ describe('service tier facts', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
const facts = resolveServiceTierFacts(source)
|
||||
|
||||
expect(facts).toEqual({ requested: 'priority', actual: 'default', billing: 'standard' })
|
||||
expect(facts).toEqual({ requested: 'priority' })
|
||||
expect(hasServiceTierFact(facts)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not infer billing from requested or actual tiers', () => {
|
||||
expect(resolveServiceTierFacts({
|
||||
service_tier: 'priority',
|
||||
it('does not infer a tier from the provider response or settlement snapshot', () => {
|
||||
const source = {
|
||||
actual_service_tier: 'flex',
|
||||
})).toEqual({ requested: 'priority', actual: 'flex', billing: null })
|
||||
settlement: {
|
||||
settlement_snapshot: {
|
||||
pricing_snapshot: {
|
||||
billing_processing_tier: 'priority',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const facts = resolveServiceTierFacts(source)
|
||||
expect(facts).toEqual({ requested: null })
|
||||
expect(hasServiceTierFact(facts)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes only non-empty string facts', () => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const CYBER_POLICY_TEXT_MARKERS = [
|
||||
'possible cybersecurity risk',
|
||||
'trusted access for cyber',
|
||||
'chatgpt.com/cyber',
|
||||
]
|
||||
|
||||
const CYBER_POLICY_CLASSIFIER_FIELDS = [
|
||||
'code',
|
||||
'type',
|
||||
'category',
|
||||
'reason',
|
||||
] as const
|
||||
|
||||
const CYBER_ERROR_OBJECT_FIELDS = [
|
||||
'error',
|
||||
'errors',
|
||||
'message',
|
||||
'error_message',
|
||||
'detail',
|
||||
'body',
|
||||
'response_body',
|
||||
'upstream_error',
|
||||
'failure_summary',
|
||||
] as const
|
||||
|
||||
function normalizeCyberClassifier(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[\s-]+/g, '_')
|
||||
}
|
||||
|
||||
function isCyberPolicyClassifier(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false
|
||||
const normalized = normalizeCyberClassifier(value)
|
||||
if (normalized === 'cyber' || normalized === 'cyber_policy') return true
|
||||
|
||||
// Providers have used nearby classifier spellings while keeping the same
|
||||
// structured error contract. Keep this deliberately narrower than a generic
|
||||
// substring check so ordinary cybersecurity content is not badged.
|
||||
return /^(?:cyber|cybersecurity)_(?:policy|safety|risk)(?:_(?:violation|error|refusal|blocked))?$/.test(normalized)
|
||||
}
|
||||
|
||||
function isCyberPolicyText(value: string): boolean {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
return CYBER_POLICY_TEXT_MARKERS.some(marker => normalized.includes(marker))
|
||||
|| /["'](?:code|type|category|reason)["']\s*:\s*["'](?:cyber|cyber[-_ ]policy|cyber[-_ ]safety|cybersecurity[-_ ](?:policy|risk))["']/i.test(normalized)
|
||||
}
|
||||
|
||||
function detectCyberPolicyError(value: unknown, seen: WeakSet<object>): boolean {
|
||||
if (typeof value === 'string') return isCyberPolicyText(value)
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) return value.some(item => detectCyberPolicyError(item, seen))
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (CYBER_POLICY_CLASSIFIER_FIELDS.some(field => isCyberPolicyClassifier(record[field]))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return CYBER_ERROR_OBJECT_FIELDS.some(field => detectCyberPolicyError(record[field], seen))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the provider's Cyber Policy refusal without treating generic HTTP 400,
|
||||
* invalid_request, or ordinary uses of the word "cyber" as policy failures.
|
||||
*/
|
||||
export function isCyberPolicyError(value: unknown): boolean {
|
||||
return detectCyberPolicyError(value, new WeakSet<object>())
|
||||
}
|
||||
@@ -1,10 +1,63 @@
|
||||
import type { UsageRecord } from '../types'
|
||||
import type { RequestStatus, UsageRecord } from '../types'
|
||||
import { isCyberPolicyError } from './cyberError'
|
||||
|
||||
export type UsageRecordStreamResolution = Pick<
|
||||
UsageRecord,
|
||||
'id' | 'is_stream' | 'upstream_is_stream' | 'client_requested_stream' | 'client_is_stream'
|
||||
>
|
||||
|
||||
export type UsageRecordResponseTiming = Pick<
|
||||
UsageRecord,
|
||||
'response_time_ms' | 'response_time_updated_at'
|
||||
>
|
||||
|
||||
function finiteNonNegativeDurationMs(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
export function parseUsageTimestampMs(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
|
||||
const timestampMs = new Date(normalized).getTime()
|
||||
return Number.isFinite(timestampMs) ? timestampMs : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a live response duration together with the timestamp that anchors it.
|
||||
*
|
||||
* These fields form one clock snapshot: active elapsed time is projected as
|
||||
* `response_time_ms + (now - response_time_updated_at)`. Merging the larger
|
||||
* duration with a newer timestamp can therefore manufacture a shorter clock
|
||||
* that never existed. Keep the pair atomic and, while active, retain whichever
|
||||
* snapshot projects the larger elapsed value.
|
||||
*/
|
||||
export function mergeUsageRecordResponseTiming(
|
||||
existing: UsageRecordResponseTiming,
|
||||
next: UsageRecordResponseTiming,
|
||||
options: { preferNext?: boolean } = {},
|
||||
): UsageRecordResponseTiming {
|
||||
const existingDurationMs = finiteNonNegativeDurationMs(existing.response_time_ms)
|
||||
const nextDurationMs = finiteNonNegativeDurationMs(next.response_time_ms)
|
||||
|
||||
if (nextDurationMs == null) return existing
|
||||
if (options.preferNext || existingDurationMs == null) return next
|
||||
|
||||
const existingUpdatedAtMs = parseUsageTimestampMs(existing.response_time_updated_at)
|
||||
const nextUpdatedAtMs = parseUsageTimestampMs(next.response_time_updated_at)
|
||||
|
||||
if (existingUpdatedAtMs != null && nextUpdatedAtMs != null) {
|
||||
const existingStartedAtMs = existingUpdatedAtMs - existingDurationMs
|
||||
const nextStartedAtMs = nextUpdatedAtMs - nextDurationMs
|
||||
return nextStartedAtMs <= existingStartedAtMs ? next : existing
|
||||
}
|
||||
|
||||
// An anchored snapshot is safer than an unanchored duration for a live clock.
|
||||
if (existingUpdatedAtMs != null) return existing
|
||||
if (nextUpdatedAtMs != null) return next
|
||||
|
||||
return nextDurationMs >= existingDurationMs ? next : existing
|
||||
}
|
||||
|
||||
export function mergeUsageRecordFirstByteTimeMs(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
@@ -29,6 +82,109 @@ export function mergeUsageRecordFirstByteTimeMs(
|
||||
return existingValue == null ? existingValue : undefined
|
||||
}
|
||||
|
||||
export function mergeUsageRecordErrorMessage(
|
||||
existingValue: string | null | undefined,
|
||||
nextValue: string | null | undefined,
|
||||
options: { authoritative?: boolean } = {},
|
||||
): string | undefined {
|
||||
const existing = typeof existingValue === 'string' && existingValue.trim()
|
||||
? existingValue
|
||||
: undefined
|
||||
const next = typeof nextValue === 'string' && nextValue.trim()
|
||||
? nextValue
|
||||
: undefined
|
||||
|
||||
// Complete list/active snapshots describe the current final candidate. They
|
||||
// must be able to replace *and clear* an error left by an earlier candidate.
|
||||
if (options.authoritative) return next
|
||||
|
||||
if (!next) return existing
|
||||
|
||||
// Detail/trace snapshots may carry a generic runtime message. Do not let that
|
||||
// downgrade a provider Cyber Policy refusal already resolved by the usage list.
|
||||
if (isCyberPolicyError(existing) && !isCyberPolicyError(next)) return existing
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export type UsageRecordLifecycleSnapshot = Pick<
|
||||
UsageRecord,
|
||||
'status' | 'status_code' | 'error_message' | 'updated_at'
|
||||
>
|
||||
|
||||
export type UsageRecordLifecycleUpdate = {
|
||||
status?: RequestStatus
|
||||
statusCode?: number | null
|
||||
errorMessage?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the sparse lifecycle state emitted by the detail drawer.
|
||||
*
|
||||
* Status, status code and error belong to one snapshot. If a detail response
|
||||
* is older (or its status would regress), none of those fields may leak into
|
||||
* the newer row. A completed/cancelled or explicitly newer terminal snapshot
|
||||
* is authoritative for errors; a same-snapshot generic failure still keeps a
|
||||
* provider Cyber refusal already known by the list.
|
||||
*/
|
||||
export function mergeUsageRecordLifecycleSnapshot(
|
||||
existing: UsageRecordLifecycleSnapshot,
|
||||
update: UsageRecordLifecycleUpdate,
|
||||
): UsageRecordLifecycleSnapshot & { accepted: boolean } {
|
||||
const statusPriority: Record<RequestStatus, number> = {
|
||||
pending: 0,
|
||||
streaming: 1,
|
||||
completed: 2,
|
||||
failed: 2,
|
||||
cancelled: 2,
|
||||
}
|
||||
const existingUpdatedAtMs = parseUsageTimestampMs(existing.updated_at)
|
||||
const nextUpdatedAtMs = parseUsageTimestampMs(update.updatedAt)
|
||||
const nextSnapshotIsOlder = existingUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs < existingUpdatedAtMs
|
||||
const currentRank = existing.status ? statusPriority[existing.status] : -1
|
||||
const nextRank = update.status ? statusPriority[update.status] : -1
|
||||
const statusAccepted = update.status != null &&
|
||||
!nextSnapshotIsOlder &&
|
||||
nextRank >= currentRank
|
||||
const accepted = !nextSnapshotIsOlder && (update.status == null || statusAccepted)
|
||||
|
||||
if (!accepted) {
|
||||
return { ...existing, accepted: false }
|
||||
}
|
||||
|
||||
const terminalSnapshotIsStrictlyNewer = statusAccepted &&
|
||||
(update.status === 'completed' || update.status === 'failed' || update.status === 'cancelled') &&
|
||||
existingUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs > existingUpdatedAtMs
|
||||
const errorIsAuthoritative = statusAccepted && (
|
||||
update.status === 'completed' ||
|
||||
update.status === 'cancelled' ||
|
||||
terminalSnapshotIsStrictlyNewer
|
||||
)
|
||||
const hasStatusCode = Object.prototype.hasOwnProperty.call(update, 'statusCode')
|
||||
const hasErrorMessage = Object.prototype.hasOwnProperty.call(update, 'errorMessage')
|
||||
|
||||
return {
|
||||
status: statusAccepted ? update.status : existing.status,
|
||||
status_code: hasStatusCode ? (update.statusCode ?? undefined) : existing.status_code,
|
||||
error_message: hasErrorMessage
|
||||
? mergeUsageRecordErrorMessage(
|
||||
existing.error_message,
|
||||
update.errorMessage,
|
||||
{ authoritative: errorIsAuthoritative },
|
||||
)
|
||||
: existing.error_message,
|
||||
updated_at: typeof update.updatedAt === 'string'
|
||||
? update.updatedAt
|
||||
: existing.updated_at,
|
||||
accepted: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUsageRecordStreamResolution(
|
||||
records: UsageRecord[],
|
||||
resolved: UsageRecordStreamResolution
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
export interface ServiceTierFacts {
|
||||
requested: string | null
|
||||
actual: string | null
|
||||
billing: string | null
|
||||
}
|
||||
|
||||
export interface ServiceTierFactSource {
|
||||
service_tier?: unknown
|
||||
actual_service_tier?: unknown
|
||||
settlement?: unknown
|
||||
}
|
||||
|
||||
export function resolveServiceTierFacts(
|
||||
source: ServiceTierFactSource | null | undefined,
|
||||
): ServiceTierFacts {
|
||||
const settlement = asRecord(source?.settlement)
|
||||
const settlementSnapshot = asRecord(settlement?.settlement_snapshot)
|
||||
const pricingSnapshot = asRecord(settlementSnapshot?.pricing_snapshot)
|
||||
// The processing tier is an input-side fact: it must come from the final
|
||||
// request body sent to the provider. Response-advertised tiers and old
|
||||
// settlement snapshots can describe a different/legacy value, so they are
|
||||
// deliberately not consulted here. The billing display uses this same
|
||||
// authoritative request tier.
|
||||
return {
|
||||
requested: normalizeServiceTierFact(source?.service_tier),
|
||||
actual: normalizeServiceTierFact(source?.actual_service_tier),
|
||||
billing: normalizeServiceTierFact(pricingSnapshot?.billing_processing_tier),
|
||||
}
|
||||
}
|
||||
|
||||
export function hasServiceTierFact(facts: ServiceTierFacts): boolean {
|
||||
return facts.requested !== null || facts.actual !== null || facts.billing !== null
|
||||
return facts.requested !== null
|
||||
}
|
||||
|
||||
export function normalizeServiceTierFact(value: unknown): string | null {
|
||||
@@ -47,9 +43,3 @@ export function formatServiceTierFact(value: unknown): string | null {
|
||||
? 'Fast'
|
||||
: normalized
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user