fix(usage): 修复含失败信号的 pending 请求被误判为活跃的问题

- pending/streaming 状态含 status_code >= 400 或 error_message 时视为 failed
- 候选状态派生改为 failed 优先于 stale pending/streaming
- 活跃请求轮询时保留失败信号字段的更新
- 图片流支持 partial_image 事件转发及从 response.completed 读取最终图片
This commit is contained in:
fawney19
2026-04-24 03:07:11 +08:00
parent 581bc03d4e
commit 5148370253
16 changed files with 394 additions and 40 deletions

View File

@@ -520,6 +520,7 @@ interface NodeGroup {
// 用量数据类型
interface UsageData {
status?: string | null
tokens: {
input: number
output: number
@@ -547,6 +548,8 @@ const props = defineProps<{
requestId?: string | null
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
overrideStatusCode?: number
/** 外部传入的请求状态,用于识别已失败/取消的终态请求 */
requestStatus?: string | null
/** 请求侧 API 格式(客户端入口格式) */
requestApiFormat?: string | null
/** 用量和费用数据 */
@@ -577,6 +580,7 @@ const computedFinalStatus = computed(() => {
return resolveTimelineFinalStatus({
hasPendingCandidates: hasPending,
statusCode: props.overrideStatusCode,
requestStatus: props.requestStatus ?? usageData.value?.status,
traceFinalStatus: trace.value?.final_status,
})
})

View File

@@ -411,6 +411,7 @@
ref="timelineRef"
:request-id="detail.request_id || detail.id"
:override-status-code="detail.status_code"
:request-status="detail.status"
:request-api-format="detail.api_format || null"
:request-metadata="traceRequestMetadata"
/>
@@ -711,6 +712,7 @@ import { getEffectiveInputTokens } from '../token-normalization'
import {
formatUsageStreamLabel,
isUsageUpstreamStream,
resolveDisplayRequestStatus,
resolveUsageStreamLabelSegments,
} from '../utils/status'
@@ -1766,7 +1768,8 @@ function handleClose() {
function isRequestCompleted(): boolean {
if (!detail.value?.status) return true
return !['pending', 'streaming'].includes(detail.value.status)
const displayStatus = resolveDisplayRequestStatus(detail.value)
return displayStatus !== 'pending' && displayStatus !== 'streaming'
}
function stopAutoRefresh() {

View File

@@ -1,6 +1,11 @@
import { ref, computed, type Ref } from 'vue'
import type { UsageRecord, FilterStatusValue } from '../types'
import { hasUsageFallback, isUsageRecordFailed, isUsageUpstreamStream } from '../utils/status'
import {
hasUsageFallback,
isUsageRecordFailed,
isUsageUpstreamStream,
resolveDisplayRequestStatus,
} from '../utils/status'
export interface UseUsageFiltersOptions {
/** 所有记录的响应式引用 */
@@ -73,7 +78,8 @@ export function useUsageFilters(options: UseUsageFiltersOptions) {
)
} else if (filterStatus.value === 'active') {
records = records.filter(record =>
record.status === 'pending' || record.status === 'streaming'
resolveDisplayRequestStatus(record) === 'pending' ||
resolveDisplayRequestStatus(record) === 'streaming'
)
} else if (filterStatus.value === 'failed') {
records = records.filter(record => isUsageRecordFailed(record))

View File

@@ -79,6 +79,17 @@ describe('usage status helpers', () => {
}))).toBe('streaming')
})
it('treats active lifecycle records with failure signals as failed for display', () => {
const record = buildUsageRecord({
status: 'pending',
status_code: 503,
error_message: 'upstream failed',
})
expect(resolveDisplayRequestStatus(record)).toBe('failed')
expect(isUsageRecordFailed(record)).toBe(true)
})
it('treats explicit success status code as authoritative for the timeline', () => {
expect(resolveTimelineFinalStatus({
traceFinalStatus: 'success',
@@ -93,6 +104,13 @@ describe('usage status helpers', () => {
})).toBe('failed')
})
it('does not let stale pending candidates override terminal request status', () => {
expect(resolveTimelineFinalStatus({
hasPendingCandidates: true,
requestStatus: 'failed',
})).toBe('failed')
})
it('uses explicit has_fallback flag for transfer filtering', () => {
expect(hasUsageFallback(buildUsageRecord({ has_fallback: true }))).toBe(true)
expect(hasUsageFallback(buildUsageRecord({ has_fallback: false }))).toBe(false)

View File

@@ -4,8 +4,18 @@ export type TimelineFinalStatus = 'success' | 'failed' | 'streaming' | 'pending'
type RequestStatusLike = RequestStatus | string | null | undefined
type UsageFailureSignal = {
status_code?: number | null
error_message?: string | null
}
type UsageDisplayStatusRecord = UsageFailureSignal & {
status?: RequestStatusLike
first_byte_time_ms?: number | null
}
function hasLegacyFailureSignal(
record: Pick<UsageRecord, 'status_code' | 'error_message'>
record: UsageFailureSignal
): boolean {
return (typeof record.status_code === 'number' && record.status_code >= 400) ||
(typeof record.error_message === 'string' && record.error_message.trim().length > 0)
@@ -138,7 +148,7 @@ function usageApiFormatDefaultsToNonStream(apiFormat: string): boolean {
}
function hasTerminalSuccessStatusCode(
record: Pick<UsageRecord, 'status_code'>
record: UsageFailureSignal
): boolean {
return typeof record.status_code === 'number' &&
record.status_code >= 200 &&
@@ -150,7 +160,10 @@ export function isUsageRecordFailed(
): boolean {
const status = typeof record.status === 'string' ? record.status.trim().toLowerCase() : ''
if (status) {
if (status === 'pending' || status === 'streaming' || status === 'cancelled') {
if (status === 'pending' || status === 'streaming') {
return !hasTerminalSuccessStatusCode(record) && hasLegacyFailureSignal(record)
}
if (status === 'cancelled') {
return false
}
if (status === 'completed') {
@@ -202,10 +215,13 @@ export function normalizeRequestStatus(status: RequestStatusLike): RequestStatus
}
}
export function resolveDisplayRequestStatus(
record: Pick<UsageRecord, 'status' | 'first_byte_time_ms'>
): RequestStatus | undefined {
export function resolveDisplayRequestStatus(record: UsageDisplayStatusRecord): RequestStatus | undefined {
const status = normalizeRequestStatus(record.status)
if ((status === 'pending' || status === 'streaming') &&
!hasTerminalSuccessStatusCode(record) &&
hasLegacyFailureSignal(record)) {
return 'failed'
}
if (status === 'streaming' && record.first_byte_time_ms == null) {
return 'pending'
}
@@ -251,20 +267,28 @@ export function resolveTimelineFinalStatus(params: {
requestStatus?: RequestStatusLike
statusCode?: number
}): TimelineFinalStatus {
if (params.hasPendingCandidates) {
return 'pending'
}
if (typeof params.statusCode === 'number') {
return params.statusCode >= 200 && params.statusCode < 400 ? 'success' : 'failed'
}
const traceStatus = normalizeTimelineFinalStatus(params.traceFinalStatus)
if (traceStatus) {
if (traceStatus === 'success' || traceStatus === 'failed' || traceStatus === 'cancelled') {
return traceStatus
}
const requestStatus = mapRequestStatusToTimelineStatus(params.requestStatus)
if (requestStatus === 'success' || requestStatus === 'failed' || requestStatus === 'cancelled') {
return requestStatus
}
if (params.hasPendingCandidates) {
return 'pending'
}
if (traceStatus) {
return traceStatus
}
if (requestStatus) {
return requestStatus
}