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

@@ -302,6 +302,8 @@ export const meApi = {
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
status_code?: number | null
error_message?: string | null
api_format?: string | null
endpoint_api_format?: string | null
is_stream?: boolean | null

View File

@@ -307,6 +307,8 @@ export const usageApi = {
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
status_code?: number | null
error_message?: string | null
provider?: string | null
api_key_name?: string | null
provider_key_name?: string | null

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
}

View File

@@ -150,7 +150,8 @@ import { reconcileActiveRequestDiscovery } from '@/features/usage/utils/activeRe
import {
hasUsageFallback,
isUsageRecordFailed,
isUsageUpstreamStream
isUsageUpstreamStream,
resolveDisplayRequestStatus,
} from '@/features/usage/utils/status'
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
@@ -346,7 +347,8 @@ const filteredRecords = computed(() => {
)
} 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))
@@ -365,7 +367,10 @@ const filteredRecords = computed(() => {
// 获取活跃请求的 ID 列表
const activeRequestIds = computed(() => {
return currentRecords.value
.filter(record => record.status === 'pending' || record.status === 'streaming')
.filter((record) => {
const displayStatus = resolveDisplayRequestStatus(record)
return displayStatus === 'pending' || displayStatus === 'streaming'
})
.map(record => record.id)
})
@@ -424,11 +429,15 @@ async function pollActiveRequests() {
const currentRank = record.status ? (statusPriority[record.status] ?? 0) : 0
const newRank = update.status ? (statusPriority[update.status] ?? 0) : 0
const shouldApply = newRank >= currentRank
const updateHasFailureSignal =
(typeof update.status_code === 'number' && update.status_code >= 400) ||
(typeof update.error_message === 'string' && update.error_message.trim().length > 0)
const shouldApplyData = shouldApply || updateHasFailureSignal
if (shouldApply && record.status !== update.status) {
record.status = update.status
}
if (shouldApply) {
if (shouldApplyData) {
// 进行中状态也需要持续更新provider/key/TTFB 可能在 streaming 后才落库)
record.input_tokens = update.input_tokens
record.effective_input_tokens = update.effective_input_tokens ?? record.effective_input_tokens
@@ -444,6 +453,8 @@ async function pollActiveRequests() {
record.rate_multiplier = update.rate_multiplier ?? undefined
record.response_time_ms = update.response_time_ms ?? undefined
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
record.status_code = update.status_code ?? undefined
record.error_message = update.error_message ?? undefined
if (typeof update.upstream_is_stream === 'boolean') {
record.upstream_is_stream = update.upstream_is_stream
record.is_stream = update.upstream_is_stream