mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge PR #504
This commit is contained in:
@@ -151,6 +151,19 @@ export interface RequestSchedulingFailure {
|
||||
reason_summary?: string | null
|
||||
status_code?: number | null
|
||||
no_upstream_attempt?: boolean | null
|
||||
requested_model?: string | null
|
||||
candidate_count?: number | null
|
||||
persisted_candidate_count?: number | null
|
||||
skipped_candidate_count?: number | null
|
||||
skip_reasons?: Record<string, number> | null
|
||||
provider_hint?: {
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
} | null
|
||||
endpoint_hint?: {
|
||||
id?: string | null
|
||||
api_format?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface RequestDetail {
|
||||
|
||||
@@ -547,6 +547,38 @@
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Local Scheduling Failure State -->
|
||||
<Card
|
||||
v-else-if="schedulingFailureNotice"
|
||||
class="border-red-200 dark:border-red-800"
|
||||
>
|
||||
<div class="p-4 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
调度失败
|
||||
</Badge>
|
||||
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
|
||||
{{ schedulingFailureNotice.title }}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
|
||||
{{ schedulingFailureNotice.message }}
|
||||
</p>
|
||||
<div
|
||||
v-if="schedulingFailureNotice.meta.length > 0"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-for="item in schedulingFailureNotice.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>
|
||||
</Card>
|
||||
|
||||
<!-- Empty State -->
|
||||
<Card
|
||||
v-else
|
||||
@@ -575,6 +607,7 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { resolveTimelineFinalStatus } from '../utils/status'
|
||||
import type { RequestSchedulingFailure } from '@/api/dashboard'
|
||||
import {
|
||||
buildPoolGroupVisibleAttempts,
|
||||
buildPoolParticipatedCandidates,
|
||||
@@ -637,6 +670,8 @@ const props = defineProps<{
|
||||
usageData?: UsageData | null
|
||||
/** 请求元数据(用于号池调度组装) */
|
||||
requestMetadata?: Record<string, unknown> | null
|
||||
/** 本地调度失败摘要;用于没有 trace candidate 时替代空态 */
|
||||
schedulingFailure?: RequestSchedulingFailure | null
|
||||
/** 已获取的追踪数据;传入时不再内部拉取 */
|
||||
traceData?: RequestTrace | null
|
||||
}>()
|
||||
@@ -812,6 +847,62 @@ const computedFinalStatus = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const nonEmptyNoticeString = (value: string | null | undefined): string | null => {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
const uniqueNoticeMeta = (values: Array<string | null | undefined>): string[] => {
|
||||
return Array.from(new Set(values.map(value => value?.trim()).filter((value): value is string => Boolean(value))))
|
||||
}
|
||||
|
||||
const isUnknownProviderHint = (value: string | null): boolean => {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
return !normalized || ['unknown', 'unknow', 'pending'].includes(normalized)
|
||||
}
|
||||
|
||||
const schedulingFailureProviderHint = (failure: RequestSchedulingFailure): string | null => {
|
||||
const name = nonEmptyNoticeString(failure.provider_hint?.name)
|
||||
if (name && !isUnknownProviderHint(name)) return name
|
||||
|
||||
const id = nonEmptyNoticeString(failure.provider_hint?.id)
|
||||
if (id && !isUnknownProviderHint(id)) return id
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const schedulingFailureEndpointHint = (failure: RequestSchedulingFailure): string | null => {
|
||||
return nonEmptyNoticeString(failure.endpoint_hint?.api_format)
|
||||
?? nonEmptyNoticeString(failure.endpoint_hint?.id)
|
||||
}
|
||||
|
||||
const schedulingFailureNotice = computed(() => {
|
||||
const failure = props.schedulingFailure
|
||||
if (!failure) return null
|
||||
|
||||
const title = nonEmptyNoticeString(failure.title) ?? '本地调度失败'
|
||||
const message = nonEmptyNoticeString(failure.message)
|
||||
?? nonEmptyNoticeString(failure.reason_summary)
|
||||
?? nonEmptyNoticeString(failure.reason_label)
|
||||
?? nonEmptyNoticeString(failure.reason)
|
||||
?? '本地调度阶段没有选出可用上游提供商'
|
||||
|
||||
return {
|
||||
title,
|
||||
message,
|
||||
meta: uniqueNoticeMeta([
|
||||
schedulingFailureProviderHint(failure),
|
||||
schedulingFailureEndpointHint(failure),
|
||||
nonEmptyNoticeString(failure.requested_model),
|
||||
nonEmptyNoticeString(failure.reason_summary),
|
||||
nonEmptyNoticeString(failure.reason_label),
|
||||
nonEmptyNoticeString(failure.reason),
|
||||
typeof failure.status_code === 'number' ? `HTTP ${failure.status_code}` : null,
|
||||
failure.no_upstream_attempt ? '未进入上游执行' : null,
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
const compareBySchedulingOrder = (a: CandidateRecord, b: CandidateRecord): number => {
|
||||
if (a.candidate_index !== b.candidate_index) {
|
||||
return a.candidate_index - b.candidate_index
|
||||
|
||||
@@ -544,6 +544,7 @@
|
||||
:request-status="detail.status"
|
||||
:request-api-format="detail.api_format || null"
|
||||
:request-metadata="traceRequestMetadata"
|
||||
:scheduling-failure="detail.scheduling_failure"
|
||||
@trace-state="handleTraceState"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -584,15 +584,18 @@
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<div class="flex min-w-0 flex-col text-xs gap-0.5">
|
||||
<span class="truncate">{{ record.provider }}</span>
|
||||
<span
|
||||
v-if="record.provider_key_name"
|
||||
class="truncate"
|
||||
:title="getRecordProviderTitle(record)"
|
||||
>{{ getRecordProviderDisplay(record) }}</span>
|
||||
<span
|
||||
v-if="getRecordProviderSecondaryText(record)"
|
||||
class="text-muted-foreground truncate"
|
||||
:title="record.provider_key_name"
|
||||
:title="getRecordProviderSecondaryTitle(record)"
|
||||
>
|
||||
{{ record.provider_key_name }}
|
||||
{{ getRecordProviderSecondaryText(record) }}
|
||||
<span
|
||||
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
|
||||
v-if="record.provider_key_name && record.rate_multiplier && record.rate_multiplier !== 1.0"
|
||||
class="text-foreground/60"
|
||||
>({{ record.rate_multiplier }}x)</span>
|
||||
</span>
|
||||
@@ -1174,6 +1177,57 @@ function getDisplayStatus(record: UsageRecord) {
|
||||
return resolveDisplayRequestStatus(record)
|
||||
}
|
||||
|
||||
function nonEmptyDisplayString(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
function isUnknownProvider(value: string | null | undefined): boolean {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
return !normalized || ['unknown', 'unknow', 'pending'].includes(normalized)
|
||||
}
|
||||
|
||||
function getRecordSchedulingProviderHint(record: UsageRecord): string | null {
|
||||
const name = nonEmptyDisplayString(record.scheduling_failure?.provider_hint?.name)
|
||||
if (name && !isUnknownProvider(name)) return name
|
||||
|
||||
const id = nonEmptyDisplayString(record.scheduling_failure?.provider_hint?.id)
|
||||
if (id && !isUnknownProvider(id)) return id
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getRecordProviderDisplay(record: UsageRecord): string {
|
||||
const provider = nonEmptyDisplayString(record.provider)
|
||||
if (provider && !isUnknownProvider(provider)) return provider
|
||||
|
||||
const providerHint = getRecordSchedulingProviderHint(record)
|
||||
if (providerHint) return providerHint
|
||||
|
||||
if (record.scheduling_failure) return '未选定提供商'
|
||||
return provider ?? 'unknown'
|
||||
}
|
||||
|
||||
function getRecordProviderTitle(record: UsageRecord): string {
|
||||
const schedulingMessage = nonEmptyDisplayString(record.scheduling_failure?.message)
|
||||
const providerDisplay = getRecordProviderDisplay(record)
|
||||
return schedulingMessage ? `${providerDisplay}\n${schedulingMessage}` : providerDisplay
|
||||
}
|
||||
|
||||
function getRecordProviderSecondaryText(record: UsageRecord): string | null {
|
||||
return nonEmptyDisplayString(record.provider_key_name)
|
||||
?? nonEmptyDisplayString(record.scheduling_failure?.reason_label)
|
||||
?? nonEmptyDisplayString(record.scheduling_failure?.reason_summary)
|
||||
?? nonEmptyDisplayString(record.scheduling_failure?.reason)
|
||||
}
|
||||
|
||||
function getRecordProviderSecondaryTitle(record: UsageRecord): string | undefined {
|
||||
return nonEmptyDisplayString(record.provider_key_name)
|
||||
?? nonEmptyDisplayString(record.scheduling_failure?.message)
|
||||
?? getRecordProviderSecondaryText(record)
|
||||
?? undefined
|
||||
}
|
||||
|
||||
function getStreamModeLabel(record: UsageRecord): string {
|
||||
return formatUsageStreamLabel(record)
|
||||
}
|
||||
|
||||
@@ -350,6 +350,40 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(nodeDot?.classList.contains('status-success')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows scheduling failure context instead of an empty trace state when no candidates exist', async () => {
|
||||
const trace = buildTrace([])
|
||||
trace.final_status = 'failed'
|
||||
|
||||
const root = mountTimeline(trace, {
|
||||
requestStatus: 'failed',
|
||||
overrideStatusCode: 503,
|
||||
schedulingFailure: {
|
||||
source: 'local_execution_runtime_miss',
|
||||
reason: 'all_candidates_skipped',
|
||||
reason_label: '所有候选均被跳过',
|
||||
title: '本地调度失败:所有候选均被跳过',
|
||||
message: '没有可用提供商支持模型 gemma-4-31b-it 的同步请求',
|
||||
status_code: 503,
|
||||
no_upstream_attempt: true,
|
||||
provider_hint: {
|
||||
id: 'provider-google-api',
|
||||
name: 'Google API',
|
||||
},
|
||||
endpoint_hint: {
|
||||
id: 'endpoint-gemini',
|
||||
api_format: 'gemini:generate_content',
|
||||
},
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('本地调度失败:所有候选均被跳过')
|
||||
expect(root.textContent).toContain('没有可用提供商支持模型 gemma-4-31b-it 的同步请求')
|
||||
expect(root.textContent).toContain('Google API')
|
||||
expect(root.textContent).toContain('gemini:generate_content')
|
||||
expect(root.textContent).not.toContain('暂无追踪数据')
|
||||
})
|
||||
|
||||
it('keeps emitted trace state active while the request lifecycle is still streaming', async () => {
|
||||
const onTraceState = vi.fn()
|
||||
const trace = buildTrace([
|
||||
|
||||
@@ -255,6 +255,31 @@ describe('UsageRecordsTable', () => {
|
||||
expect(root.textContent).not.toContain('等待中')
|
||||
})
|
||||
|
||||
it('uses scheduling failure provider hints instead of rendering unknown provider', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
provider: 'unknown',
|
||||
status: 'failed',
|
||||
status_code: 503,
|
||||
scheduling_failure: {
|
||||
source: 'local_execution_runtime_miss',
|
||||
reason: 'all_candidates_skipped',
|
||||
reason_label: '所有候选均被跳过',
|
||||
title: '本地调度失败:所有候选均被跳过',
|
||||
message: '没有可用提供商支持模型 gemma-4-31b-it 的同步请求',
|
||||
status_code: 503,
|
||||
no_upstream_attempt: true,
|
||||
provider_hint: {
|
||||
id: 'provider-google-api',
|
||||
name: 'Google API',
|
||||
},
|
||||
},
|
||||
} as Partial<UsageRecord>)])
|
||||
|
||||
expect(root.textContent).toContain('Google API')
|
||||
expect(root.textContent).toContain('所有候选均被跳过')
|
||||
expect(root.textContent).not.toContain('unknown')
|
||||
})
|
||||
|
||||
it('renders output TPS in the non-admin usage table', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord()], { isAdmin: false })
|
||||
|
||||
|
||||
@@ -515,7 +515,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
api_key_name: existing.api_key_name || record.api_key_name,
|
||||
provider_key_name: existing.provider_key_name || record.provider_key_name,
|
||||
rate_multiplier: existing.rate_multiplier ?? record.rate_multiplier,
|
||||
target_model: existing.target_model || record.target_model
|
||||
target_model: existing.target_model || record.target_model,
|
||||
scheduling_failure: existing.scheduling_failure ?? record.scheduling_failure
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +524,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
if (protectProvider) {
|
||||
return {
|
||||
...record,
|
||||
provider: existing.provider
|
||||
provider: existing.provider,
|
||||
scheduling_failure: existing.scheduling_failure ?? record.scheduling_failure
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ImageProgress } from '@/api/requestTrace'
|
||||
import type { RequestSchedulingFailure } from '@/api/dashboard'
|
||||
|
||||
// 统计数据状态
|
||||
export interface UsageStatsState {
|
||||
@@ -120,6 +121,7 @@ export interface UsageRecord {
|
||||
status_code?: number
|
||||
error_message?: string
|
||||
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
|
||||
scheduling_failure?: RequestSchedulingFailure | null
|
||||
created_at: string
|
||||
has_fallback?: boolean
|
||||
has_retry?: boolean
|
||||
|
||||
@@ -73,6 +73,43 @@ describe('request failure notice', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('includes scheduling failure provider and endpoint hints in metadata', () => {
|
||||
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||
failure_summary: {
|
||||
status_code: 503,
|
||||
message: '没有可用提供商支持模型 gemma-4-31b-it 的同步请求',
|
||||
},
|
||||
scheduling_failure: {
|
||||
source: 'local_execution_runtime_miss',
|
||||
reason: 'all_candidates_skipped',
|
||||
reason_label: '所有候选均被跳过',
|
||||
title: '本地调度失败:所有候选均被跳过',
|
||||
message: '没有可用提供商支持模型 gemma-4-31b-it 的同步请求',
|
||||
status_code: 503,
|
||||
no_upstream_attempt: true,
|
||||
requested_model: 'gemma-4-31b-it',
|
||||
provider_hint: {
|
||||
id: 'provider-google-api',
|
||||
name: 'Google API',
|
||||
},
|
||||
endpoint_hint: {
|
||||
id: 'endpoint-gemini',
|
||||
api_format: 'gemini:generate_content',
|
||||
},
|
||||
} as NonNullable<RequestDetail['scheduling_failure']>,
|
||||
}))
|
||||
|
||||
expect(notice?.meta).toEqual([
|
||||
'Google API',
|
||||
'gemini:generate_content',
|
||||
'gemma-4-31b-it',
|
||||
'所有候选均被跳过',
|
||||
'all_candidates_skipped',
|
||||
'HTTP 503',
|
||||
'未进入上游执行',
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the failure summary for upstream failures', () => {
|
||||
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||
failure_summary: {
|
||||
|
||||
@@ -25,6 +25,11 @@ 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 isUnknownProviderHint(value: string | null): boolean {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
return !normalized || ['unknown', 'unknow', 'pending'].includes(normalized)
|
||||
}
|
||||
|
||||
function schedulingFailureMessage(
|
||||
failure: RequestSchedulingFailure,
|
||||
fallbackDomain: RequestErrorDomain | null,
|
||||
@@ -37,6 +42,21 @@ function schedulingFailureMessage(
|
||||
?? nonEmptyString(failure.reason)
|
||||
}
|
||||
|
||||
function schedulingFailureProviderHint(failure: RequestSchedulingFailure): string | null {
|
||||
const name = nonEmptyString(failure.provider_hint?.name)
|
||||
if (name && !isUnknownProviderHint(name)) return name
|
||||
|
||||
const id = nonEmptyString(failure.provider_hint?.id)
|
||||
if (id && !isUnknownProviderHint(id)) return id
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function schedulingFailureEndpointHint(failure: RequestSchedulingFailure): string | null {
|
||||
return nonEmptyString(failure.endpoint_hint?.api_format)
|
||||
?? nonEmptyString(failure.endpoint_hint?.id)
|
||||
}
|
||||
|
||||
export function resolveRequestFailureNotice(detail: RequestDetail | null | undefined): RequestFailureNotice | null {
|
||||
if (!detail) return null
|
||||
|
||||
@@ -55,6 +75,9 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef
|
||||
message,
|
||||
isSchedulingFailure: true,
|
||||
meta: uniqueMeta([
|
||||
schedulingFailureProviderHint(schedulingFailure),
|
||||
schedulingFailureEndpointHint(schedulingFailure),
|
||||
nonEmptyString(schedulingFailure.requested_model),
|
||||
nonEmptyString(schedulingFailure.reason_summary),
|
||||
nonEmptyString(schedulingFailure.reason_label),
|
||||
nonEmptyString(schedulingFailure.reason),
|
||||
|
||||
Reference in New Issue
Block a user