mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Add failure notice resolver for usage records
This commit is contained in:
@@ -142,6 +142,17 @@ export interface RequestErrorFlow {
|
|||||||
summary_source?: string | null
|
summary_source?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RequestSchedulingFailure {
|
||||||
|
source?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
reason_label?: string | null
|
||||||
|
title?: string | null
|
||||||
|
message?: string | null
|
||||||
|
reason_summary?: string | null
|
||||||
|
status_code?: number | null
|
||||||
|
no_upstream_attempt?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface RequestDetail {
|
export interface RequestDetail {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
request_id: string
|
request_id: string
|
||||||
@@ -206,6 +217,7 @@ export interface RequestDetail {
|
|||||||
failure_summary?: RequestErrorDomain | null
|
failure_summary?: RequestErrorDomain | null
|
||||||
errors?: RequestErrorDomains | null
|
errors?: RequestErrorDomains | null
|
||||||
error_flow?: RequestErrorFlow | null
|
error_flow?: RequestErrorFlow | null
|
||||||
|
scheduling_failure?: RequestSchedulingFailure | null
|
||||||
response_time_ms: number
|
response_time_ms: number
|
||||||
first_byte_time_ms?: number | null
|
first_byte_time_ms?: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -366,7 +378,7 @@ export interface TimeRangeParams {
|
|||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
// 获取仪表盘统计数据
|
// 获取仪表盘统计数据
|
||||||
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
||||||
const cacheKey = buildCacheKey('dashboard:stats', params)
|
const cacheKey = buildCacheKey('dashboard:stats', params as Record<string, unknown> | undefined)
|
||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -427,7 +439,7 @@ export const dashboardApi = {
|
|||||||
|
|
||||||
// 获取每日统计数据
|
// 获取每日统计数据
|
||||||
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
||||||
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
|
const cacheKey = buildCacheKey('dashboard:daily-stats', params as Record<string, unknown> | undefined)
|
||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
108
frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts
Normal file
108
frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { RequestDetail } from '@/api/dashboard'
|
||||||
|
import { resolveRequestFailureNotice } from '../errorNotice'
|
||||||
|
|
||||||
|
function buildRequestDetail(overrides: Partial<RequestDetail> = {}): RequestDetail {
|
||||||
|
return {
|
||||||
|
id: 'usage-1',
|
||||||
|
request_id: 'req-1',
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
},
|
||||||
|
api_key: {
|
||||||
|
id: 'key-1',
|
||||||
|
name: 'primary',
|
||||||
|
display: 'primary',
|
||||||
|
},
|
||||||
|
provider: 'OpenAI',
|
||||||
|
model: 'gpt-5',
|
||||||
|
tokens: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
request_type: 'chat',
|
||||||
|
is_stream: true,
|
||||||
|
status_code: 503,
|
||||||
|
status: 'failed',
|
||||||
|
response_time_ms: 0,
|
||||||
|
created_at: '2026-05-14T10:33:21Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('request failure notice', () => {
|
||||||
|
it('prioritizes local scheduling failure details over generic 503 status', () => {
|
||||||
|
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||||
|
error_message: 'generic 503',
|
||||||
|
failure_summary: {
|
||||||
|
status_code: 503,
|
||||||
|
message: '没有可用提供商支持模型 gpt-5 的流式请求',
|
||||||
|
},
|
||||||
|
scheduling_failure: {
|
||||||
|
source: 'local_execution_runtime_miss',
|
||||||
|
reason: 'all_candidates_skipped',
|
||||||
|
reason_label: '所有候选均被跳过',
|
||||||
|
title: '本地调度失败:所有候选均被跳过',
|
||||||
|
message: '没有可用提供商支持模型 gpt-5 的流式请求',
|
||||||
|
reason_summary: 'pool_account_exhausted 2 次',
|
||||||
|
status_code: 503,
|
||||||
|
no_upstream_attempt: true,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(notice).toEqual({
|
||||||
|
title: '本地调度失败:所有候选均被跳过',
|
||||||
|
message: '没有可用提供商支持模型 gpt-5 的流式请求',
|
||||||
|
isSchedulingFailure: true,
|
||||||
|
meta: [
|
||||||
|
'pool_account_exhausted 2 次',
|
||||||
|
'所有候选均被跳过',
|
||||||
|
'all_candidates_skipped',
|
||||||
|
'HTTP 503',
|
||||||
|
'未进入上游执行',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the failure summary for upstream failures', () => {
|
||||||
|
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||||
|
failure_summary: {
|
||||||
|
source: 'upstream_response',
|
||||||
|
status_code: 429,
|
||||||
|
type: 'insufficient_quota',
|
||||||
|
message: 'quota exceeded',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(notice).toEqual({
|
||||||
|
title: '执行失败原因',
|
||||||
|
message: 'quota exceeded',
|
||||||
|
isSchedulingFailure: false,
|
||||||
|
meta: ['HTTP 429', 'insufficient_quota', 'upstream_response'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show a stale notice when the refreshed detail has no error fields', () => {
|
||||||
|
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||||
|
status_code: 200,
|
||||||
|
status: 'completed',
|
||||||
|
error_message: undefined,
|
||||||
|
scheduling_failure: null,
|
||||||
|
failure_summary: null,
|
||||||
|
client_error: null,
|
||||||
|
upstream_error: null,
|
||||||
|
request_error: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(notice).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
82
frontend/src/features/usage/utils/errorNotice.ts
Normal file
82
frontend/src/features/usage/utils/errorNotice.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { RequestDetail, RequestErrorDomain, RequestSchedulingFailure } from '@/api/dashboard'
|
||||||
|
|
||||||
|
export interface RequestFailureNotice {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
meta: string[]
|
||||||
|
isSchedulingFailure: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyString(value: string | null | undefined): string | null {
|
||||||
|
const trimmed = value?.trim()
|
||||||
|
return trimmed ? trimmed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): RequestErrorDomain | null {
|
||||||
|
if (!nonEmptyString(domain?.message)) return null
|
||||||
|
return domain ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHttpStatus(statusCode: number | null | undefined): string | null {
|
||||||
|
return typeof statusCode === 'number' ? `HTTP ${statusCode}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueMeta(values: Array<string | null | undefined>): string[] {
|
||||||
|
return Array.from(new Set(values.map(value => value?.trim()).filter((value): value is string => Boolean(value))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulingFailureMessage(
|
||||||
|
failure: RequestSchedulingFailure,
|
||||||
|
fallbackDomain: RequestErrorDomain | null,
|
||||||
|
fallbackErrorMessage: string | null,
|
||||||
|
): string | null {
|
||||||
|
return nonEmptyString(failure.message)
|
||||||
|
?? nonEmptyString(fallbackDomain?.message)
|
||||||
|
?? fallbackErrorMessage
|
||||||
|
?? nonEmptyString(failure.reason_label)
|
||||||
|
?? nonEmptyString(failure.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRequestFailureNotice(detail: RequestDetail | null | undefined): RequestFailureNotice | null {
|
||||||
|
if (!detail) return null
|
||||||
|
|
||||||
|
const fallbackDomain = normalizeErrorDomain(detail.failure_summary)
|
||||||
|
?? normalizeErrorDomain(detail.client_error)
|
||||||
|
?? normalizeErrorDomain(detail.upstream_error)
|
||||||
|
?? normalizeErrorDomain(detail.request_error)
|
||||||
|
const fallbackErrorMessage = nonEmptyString(detail.error_message ?? null)
|
||||||
|
const schedulingFailure = detail.scheduling_failure ?? null
|
||||||
|
|
||||||
|
if (schedulingFailure) {
|
||||||
|
const message = schedulingFailureMessage(schedulingFailure, fallbackDomain, fallbackErrorMessage)
|
||||||
|
if (message) {
|
||||||
|
return {
|
||||||
|
title: nonEmptyString(schedulingFailure.title) ?? '本地调度失败',
|
||||||
|
message,
|
||||||
|
isSchedulingFailure: true,
|
||||||
|
meta: uniqueMeta([
|
||||||
|
nonEmptyString(schedulingFailure.reason_summary),
|
||||||
|
nonEmptyString(schedulingFailure.reason_label),
|
||||||
|
nonEmptyString(schedulingFailure.reason),
|
||||||
|
formatHttpStatus(schedulingFailure.status_code ?? detail.status_code),
|
||||||
|
schedulingFailure.no_upstream_attempt ? '未进入上游执行' : null,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = fallbackDomain
|
||||||
|
const message = nonEmptyString(domain?.message) ?? fallbackErrorMessage
|
||||||
|
if (!message) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '执行失败原因',
|
||||||
|
message,
|
||||||
|
isSchedulingFailure: false,
|
||||||
|
meta: uniqueMeta([
|
||||||
|
formatHttpStatus(domain?.status_code ?? detail.status_code),
|
||||||
|
nonEmptyString(domain?.type),
|
||||||
|
nonEmptyString(domain?.source),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user