mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
fix(usage): 修复含失败信号的 pending 请求被误判为活跃的问题
- pending/streaming 状态含 status_code >= 400 或 error_message 时视为 failed - 候选状态派生改为 failed 优先于 stale pending/streaming - 活跃请求轮询时保留失败信号字段的更新 - 图片流支持 partial_image 事件转发及从 response.completed 读取最终图片
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user