diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs index e7541e523..574ea095a 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs @@ -77,21 +77,30 @@ fn apply_admin_usage_status_filter(query: &mut UsageAuditListQuery, status: Opti "pending" | "streaming" | "completed" | "cancelled" => { query.statuses = Some(vec![status]); } - "has_fallback" | "has_retry" => {} + "has_fallback" | "has_retry" | "has_skipped_candidate" => {} _ => {} } } -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Debug, Default)] struct AdminUsageAttemptFlags { has_fallback: bool, has_retry: bool, + /// 是否存在"被调度跳过"的候选(调度阶段判定本次不可用,从未向上游发起请求)。 + /// + /// 这是与 has_fallback 正交的信号:has_fallback 表示"更靠前的候选真的失败并被换掉", + /// 而本字段表示"更靠前的候选压根没被发出去"。两者在日志列表里观感都是"换了提供商", + /// 但用户拿不到 has_fallback 小图标时容易误判为调度错误,故单独暴露。 + has_skipped_candidate: bool, + /// 跳过原因(去重、保持出现顺序),用于前端 tooltip 直接说明"为什么没用它"。 + skipped_candidate_reasons: Vec, } fn admin_usage_attempt_status_filter(status: Option<&str>) -> Option<&'static str> { match status?.trim().to_ascii_lowercase().as_str() { "has_fallback" => Some("has_fallback"), "has_retry" => Some("has_retry"), + "has_skipped_candidate" => Some("has_skipped_candidate"), _ => None, } } @@ -146,25 +155,52 @@ fn admin_usage_attempt_flags_from_candidates( }) }); let has_retry = candidates.iter().any(admin_usage_candidate_was_retried); + let skipped_candidate_reasons = admin_usage_skipped_candidate_reasons(candidates); AdminUsageAttemptFlags { has_fallback, has_retry, + has_skipped_candidate: !skipped_candidate_reasons.is_empty(), + skipped_candidate_reasons, } } +/// 收集被跳过候选的原因,去重并保持候选顺序(决定性的在前,便于阅读)。 +fn admin_usage_skipped_candidate_reasons(candidates: &[StoredRequestCandidate]) -> Vec { + let mut reasons = Vec::new(); + for candidate in candidates + .iter() + .filter(|candidate| candidate.status == RequestCandidateStatus::Skipped) + { + let Some(reason) = candidate + .skip_reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + else { + continue; + }; + if !reasons.iter().any(|existing| existing == reason) { + reasons.push(reason.to_string()); + } + } + reasons +} + fn admin_usage_attempt_flags_for_item( item: &StoredRequestUsageAudit, flags_by_usage_id: &BTreeMap, request_candidate_reader_available: bool, ) -> AdminUsageAttemptFlags { - flags_by_usage_id.get(&item.id).copied().unwrap_or_else(|| { + flags_by_usage_id.get(&item.id).cloned().unwrap_or_else(|| { if request_candidate_reader_available { AdminUsageAttemptFlags::default() } else { AdminUsageAttemptFlags { has_fallback: admin_usage_has_fallback(item), has_retry: false, + has_skipped_candidate: false, + skipped_candidate_reasons: Vec::new(), } } }) @@ -433,6 +469,8 @@ fn admin_usage_matches_attempt_status( match status { "has_fallback" => flags.has_fallback, "has_retry" => flags.has_retry, + // 与 has_fallback 区分:这里是"更靠前的候选被调度跳过、根本没发出去" + "has_skipped_candidate" => flags.has_skipped_candidate, _ => true, } } @@ -504,6 +542,9 @@ fn build_admin_usage_records_response_with_attempt_flags( ); record["has_fallback"] = json!(flags.has_fallback); record["has_retry"] = json!(flags.has_retry); + // 被跳过的候选:前端据此提示"这次没用某个提供商,是因为它在调度阶段就被排除了"。 + record["has_skipped_candidate"] = json!(flags.has_skipped_candidate); + record["skipped_candidate_reasons"] = json!(flags.skipped_candidate_reasons); record }) .collect(); @@ -1037,9 +1078,11 @@ mod tests { use aether_data_contracts::repository::candidates::{ RequestCandidateStatus, StoredRequestCandidate, }; + use aether_data_contracts::repository::usage::StoredRequestUsageAudit; use serde_json::json; use super::{ + admin_usage_attempt_flags_from_candidates, admin_usage_skipped_candidate_reasons, admin_usage_terminal_candidate_state_override, build_admin_usage_keyword_search_query, build_admin_usage_records_query, latest_admin_usage_image_progress, AdminUsageSearchContext, @@ -1081,6 +1124,144 @@ mod tests { .expect("candidate should build") } + /// 构造一条"被调度跳过"的候选(从未向上游发起请求)。 + fn skipped_candidate(candidate_index: i32, reason: &str) -> StoredRequestCandidate { + let mut candidate = sample_candidate( + candidate_index, + RequestCandidateStatus::Skipped, + None, + None, + None, + ); + candidate.skip_reason = Some(reason.to_string()); + // 跳过候选没有开始时间,is_attempted 因此为 false + candidate.started_at_unix_ms = None; + candidate + } + + #[test] + fn skipped_candidate_reasons_are_deduplicated_in_candidate_order() { + let reasons = admin_usage_skipped_candidate_reasons(&[ + skipped_candidate(0, "key_rpm_exhausted"), + skipped_candidate(1, "provider_inactive"), + skipped_candidate(2, "key_rpm_exhausted"), + ]); + + assert_eq!( + reasons, + vec![ + "key_rpm_exhausted".to_string(), + "provider_inactive".to_string() + ] + ); + } + + #[test] + fn skipped_candidate_reasons_ignore_attempted_candidates() { + // 真正发起过请求的失败候选不属于"被跳过",避免与 has_fallback 语义混淆 + let failed = sample_candidate( + 0, + RequestCandidateStatus::Failed, + Some(503), + Some(1_000), + Some("upstream exploded"), + ); + assert!(admin_usage_skipped_candidate_reasons(&[failed]).is_empty()); + } + + #[test] + fn attempt_flags_report_skipped_candidates_without_fallback() { + let candidates = vec![ + skipped_candidate(0, "key_rpm_exhausted"), + sample_candidate( + 1, + RequestCandidateStatus::Success, + Some(200), + Some(900), + None, + ), + ]; + + let flags = admin_usage_attempt_flags_from_candidates(&sample_usage_audit(), &candidates); + + // 这正是用户遇到的场景:换了提供商,但没有任何候选失败过 + assert!(flags.has_skipped_candidate); + assert!(!flags.has_fallback); + assert_eq!( + flags.skipped_candidate_reasons, + vec!["key_rpm_exhausted".to_string()] + ); + } + + #[test] + fn attempt_flags_keep_fallback_and_skipped_candidate_independent() { + let candidates = vec![ + skipped_candidate(0, "provider_inactive"), + sample_candidate( + 1, + RequestCandidateStatus::Failed, + Some(503), + Some(500), + None, + ), + sample_candidate( + 2, + RequestCandidateStatus::Success, + Some(200), + Some(700), + None, + ), + ]; + + let flags = admin_usage_attempt_flags_from_candidates(&sample_usage_audit(), &candidates); + + assert!(flags.has_skipped_candidate); + assert!(flags.has_fallback); + } + + /// 最小可用的用量审计行,仅用于驱动 flags 计算(其中候选 id 为空即可)。 + fn sample_usage_audit() -> StoredRequestUsageAudit { + StoredRequestUsageAudit::new( + "usage-1".to_string(), + "req-1".to_string(), + Some("user-1".to_string()), + Some("api-key-1".to_string()), + Some("alice".to_string()), + Some("default".to_string()), + "OpenAI".to_string(), + "gpt-4.1".to_string(), + None, + None, + None, + None, + None, + Some("openai:chat".to_string()), + Some("openai".to_string()), + Some("chat".to_string()), + Some("openai:chat".to_string()), + Some("openai".to_string()), + Some("chat".to_string()), + false, + false, + 10, + 20, + 30, + 0.0, + 0.0, + Some(200), + None, + None, + None, + None, + "completed".to_string(), + "settled".to_string(), + 1_000, + 1_001, + None, + ) + .expect("usage should build") + } + #[test] fn admin_usage_active_override_uses_current_terminal_candidate_latency() { let candidate = sample_candidate( diff --git a/frontend/src/api/usageRecords.ts b/frontend/src/api/usageRecords.ts index 8ef0b4150..c17f382d2 100644 --- a/frontend/src/api/usageRecords.ts +++ b/frontend/src/api/usageRecords.ts @@ -65,5 +65,13 @@ export interface UsageRecord { response_time_updated_at?: string | null has_fallback?: boolean has_retry?: boolean + /** + * 是否存在被调度跳过的候选(候选在调度阶段即被判定不可用,从未向上游发起请求)。 + * 与 has_fallback 的区别:has_fallback 代表"更靠前的候选真的失败了", + * 本字段代表"更靠前的候选压根没被发出去",用于解释"无报错却换了提供商"。 + */ + has_skipped_candidate?: boolean + /** 被跳过候选的原因列表(后端已按候选顺序去重) */ + skipped_candidate_reasons?: string[] image_progress?: ImageProgress | null } diff --git a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue index ea86686e7..18f2b3c99 100644 --- a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue +++ b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue @@ -36,6 +36,21 @@ {{ getFinalStatusLabel(computedFinalStatus) }} + +
{{ formatLatency(totalTraceLatency) }} @@ -449,7 +464,7 @@ v-if="currentAttemptSkipReasonDisplay" class="skip-reason" > - 跳过原因 + {{ currentAttemptSkipReasonLabel }} {{ currentAttemptSkipReasonDisplay }} (() => { +const allTraceCandidates = computed(() => { if (!trace.value) return [] return [...trace.value.candidates] .filter(c => TIMELINE_STATUS.includes(c.status)) .sort(compareBySchedulingOrder) }) +/** + * 被跳过的候选:调度阶段就判定"这次不能用",从未真正向上游发起请求。 + * + * 默认展示:它是"为什么这次没用优先级更高的提供商"的直接答案, + * 隐藏后容易让人误以为该提供商从未参与调度。开关供长时间排查时收起。 + */ +const showSkippedCandidates = ref(true) + +const skippedTraceCandidates = computed( + () => allTraceCandidates.value.filter(c => c.status === 'skipped'), +) + +const rawTimeline = computed(() => { + if (showSkippedCandidates.value) return allTraceCandidates.value + return allTraceCandidates.value.filter(c => c.status !== 'skipped') +}) + const schedulingAudit = computed | null>(() => { const metadata = props.requestMetadata @@ -1233,7 +1266,12 @@ const latestTraceAttemptForState = computed(() => { const candidates = rawTimeline.value for (let index = candidates.length - 1; index >= 0; index -= 1) { const candidate = candidates[index] - if (candidate.status !== 'available' && candidate.status !== 'unused') { + // 跳过/未使用候选没有状态码与错误信息,不能代表本次请求的最终结果。 + if ( + candidate.status !== 'available' && + candidate.status !== 'unused' && + candidate.status !== 'skipped' + ) { return candidate } } @@ -1418,35 +1456,30 @@ const currentAttemptKeyFormatsDisplay = computed(() => { .map(format => formatApiFormat(format)) .join(' / ') }) -const SKIP_REASON_LABELS: Record = { - auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限', - api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限', - pool_key_lease_busy: '池内账号正被其他请求占用', - provider_concurrency_limit_reached: '上游提供商并发已达上限', - provider_key_concurrency_limit_reached: '上游账号并发已达上限', - provider_request_body_build_failed: '上游请求体转换失败', - provider_request_body_missing: '无法构建上游请求体', -} const currentAttemptSkipReasonDisplay = computed(() => { const attempt = currentAttempt.value if (!attempt?.skip_reason) return '' - const skipReasonLabel = SKIP_REASON_LABELS[attempt.skip_reason] - if (skipReasonLabel) { - return skipReasonLabel + // transport_unsupported 这类"泛化原因"优先展示后端采集到的具体细节,便于排查。 + if (attempt.skip_reason === 'transport_unsupported') { + const transportDiagnostics = resolveTransportDiagnostics(attempt) + const requestPair = extractObject(transportDiagnostics?.request_pair) + const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string' + ? requestPair.transport_unsupported_reason.trim() + : '' + return detailedReason || formatCandidateSkipReason(attempt.skip_reason) } - if (attempt.skip_reason !== 'transport_unsupported') { - return attempt.skip_reason - } + return formatCandidateSkipReason(attempt.skip_reason) +}) - const transportDiagnostics = resolveTransportDiagnostics(attempt) - const requestPair = extractObject(transportDiagnostics?.request_pair) - const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string' - ? requestPair.transport_unsupported_reason.trim() - : '' - - return detailedReason || attempt.skip_reason +/** + * 区分"跳过"与"尝试失败":被跳过的候选从未发到上游, + * 不写清楚容易被误读成"上游返回了错误"。 + */ +const currentAttemptSkipReasonLabel = computed(() => { + const status = currentAttempt.value?.status + return status === 'skipped' ? '跳过原因(未向上游发起请求)' : '跳过原因' }) const currentAttemptFailureDiagnostic = computed<{ @@ -2212,7 +2245,9 @@ const loadTrace = async (silent = false) => { error.value = null try { - internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: true }) + // 始终拉取全部候选:被跳过的候选是"为什么没用某个提供商"的关键证据, + // 是否显示由前端开关控制,避免再发一次请求。 + internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: false }) } catch (err: unknown) { if (isAxiosError(err) && err.response?.status === 404) { internalTrace.value = null @@ -2798,6 +2833,30 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string { cursor: not-allowed; } +/* 「显示/隐藏被跳过候选」开关:低调的描边小按钮,不抢主状态徽标的视觉重心 */ +.skipped-toggle { + padding: 0.125rem 0.5rem; + border: 1px dashed hsl(var(--border)); + border-radius: 9999px; + background: transparent; + color: hsl(var(--muted-foreground)); + font-size: 0.75rem; + line-height: 1.5; + cursor: pointer; + transition: all 0.15s ease; +} + +.skipped-toggle:hover { + color: hsl(var(--foreground)); + border-color: hsl(var(--muted-foreground) / 0.5); +} + +.skipped-toggle.active { + border-style: solid; + border-color: hsl(var(--primary) / 0.5); + color: hsl(var(--primary)); +} + .nav-info { font-size: 0.8rem; font-weight: 500; diff --git a/frontend/src/features/usage/components/UsageRecordsTable.vue b/frontend/src/features/usage/components/UsageRecordsTable.vue index 63d0b3988..9bf624bf9 100644 --- a/frontend/src/features/usage/components/UsageRecordsTable.vue +++ b/frontend/src/features/usage/components/UsageRecordsTable.vue @@ -149,32 +149,14 @@ - - 全部类型 - - - HTTP 流式 - - - HTTP 标准 - - - WebSocket (WS) - - - 活跃 - - - 失败 - - - 已取消 - - - 发生重试 - - - 发生转移 + + + {{ option.label }} @@ -362,6 +344,28 @@ · {{ formatRecordProviderSegment(record) }} + + + +
@@ -791,6 +795,18 @@ title="此请求发生了 Provider 故障转移" aria-label="发生 Provider 故障转移" /> + + reason && all.indexOf(reason) === index) + + const header = '本次有候选在调度阶段被跳过,请求未发往该候选(因此不会有上游报错)' + if (!reasons.length) return header + return `${header}\n跳过原因:${reasons.join(';')}` +} + +function skippedCandidateAriaLabel(record: UsageRecord): string { + const reasons = (record.skipped_candidate_reasons ?? []).map(formatCandidateSkipReason) + return reasons.length ? `有候选被调度跳过:${reasons.join(';')}` : '有候选被调度跳过' +} + // 获取 API 格式的 tooltip(包含转换信息) function getApiFormatTooltip(record: UsageRecord): string { if (!record.api_format) { diff --git a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts index 8a933cc78..2715b1af9 100644 --- a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts +++ b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts @@ -425,6 +425,56 @@ describe('HorizontalRequestTimeline', () => { expect(nodeDots[2].classList.contains('status-pending')).toBe(true) }) + it('shows a skipped candidate with a Chinese reason and can collapse it', async () => { + const trace = buildTrace([ + buildCandidate({ + id: 'cand-skipped-rpm', + provider_id: 'provider-1', + provider_name: 'Provider 1', + key_id: 'key-1', + key_name: 'Key 1', + candidate_index: 0, + status: 'skipped', + skip_reason: 'key_rpm_exhausted', + started_at: undefined, + finished_at: undefined, + }), + buildCandidate({ + id: 'cand-success-2', + provider_id: 'provider-2', + provider_name: 'Provider 2', + key_id: 'key-2', + key_name: 'Key 2', + candidate_index: 1, + status: 'success', + }), + ]) + + const root = mountTimeline(trace) + await nextTick() + + // 被跳过的候选默认可见:它是"为什么没用这个提供商"的答案 + expect([...root.querySelectorAll('.node-label')] + .map(label => label.textContent?.trim())) + .toEqual(['Provider 1', 'Provider 2']) + + // 点击第一个节点查看详情,应看到中文跳过原因 + root.querySelector('.minimal-node-group')?.click() + await nextTick() + expect(root.textContent).toContain('密钥本分钟请求数已达上限') + // 必须点明"没有向上游发起请求",否则会被误读成上游报错 + expect(root.textContent).toContain('未向上游发起请求') + + // 开关可把跳过候选整体收起 + const toggle = root.querySelector('.skipped-toggle') + expect(toggle).not.toBeNull() + toggle?.click() + await nextTick() + expect([...root.querySelectorAll('.node-label')] + .map(label => label.textContent?.trim())) + .toEqual(['Provider 2']) + }) + it('keeps successful runtime pool key visible when only pool_key_index is recorded', async () => { const trace = buildTrace([ buildCandidate({ diff --git a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts index 58a8d5d1e..b3dfe1896 100644 --- a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts +++ b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts @@ -92,6 +92,7 @@ vi.mock('lucide-vue-next', async () => { EyeOff: Icon, Search: Icon, Shuffle: Icon, + Ban: Icon, ChevronDown: Icon, Check: Icon, } @@ -679,4 +680,37 @@ describe('UsageRecordsTable', () => { expect(root.querySelector('[data-usage-attempt-marker="fallback"]')).not.toBeNull() expect(root.querySelector('[data-usage-attempt-marker="retry"]')).not.toBeNull() }) + + it('shows the skipped-candidate marker when a candidate was skipped by scheduling', () => { + const root = mountUsageRecordsTable([buildRecord({ + has_skipped_candidate: true, + skipped_candidate_reasons: ['key_rpm_exhausted'], + })]) + + const marker = root.querySelector('[data-usage-attempt-marker="skipped-candidate"]') + expect(marker).not.toBeNull() + // tooltip 必须说明"请求未发往该候选",否则用户会以为上游报了错 + const title = marker?.getAttribute('title') ?? '' + expect(title).toContain('调度阶段被跳过') + expect(title).toContain('不会有上游报错') + expect(title).toContain('密钥本分钟请求数已达上限') + }) + + it('prefers the fallback marker over the skipped-candidate marker', () => { + // 真正发生过故障转移时,琥珀色转移图标信息量更大,不再叠加灰色角标 + const root = mountUsageRecordsTable([buildRecord({ + has_fallback: true, + has_skipped_candidate: true, + skipped_candidate_reasons: ['key_rpm_exhausted'], + })]) + + expect(root.querySelector('[data-usage-attempt-marker="fallback"]')).not.toBeNull() + expect(root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')).toBeNull() + }) + + it('hides the skipped-candidate marker when no candidate was skipped', () => { + const root = mountUsageRecordsTable([buildRecord({ has_skipped_candidate: false })]) + + expect(root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')).toBeNull() + }) }) diff --git a/frontend/src/features/usage/composables/useUsageFilters.ts b/frontend/src/features/usage/composables/useUsageFilters.ts index 465b8dba1..3e712d9e0 100644 --- a/frontend/src/features/usage/composables/useUsageFilters.ts +++ b/frontend/src/features/usage/composables/useUsageFilters.ts @@ -3,6 +3,7 @@ import type { UsageRecord, FilterStatusValue } from '../types' import { hasUsageFallback, hasUsageRetry, + hasUsageSkippedCandidate, isUsageRecordFailed, isUsageUpstreamStream, isUsageWebSocket, @@ -97,6 +98,8 @@ export function useUsageFilters(options: UseUsageFiltersOptions) { records = records.filter(record => hasUsageFallback(record)) } else if (filterStatus.value === 'has_retry') { records = records.filter(record => hasUsageRetry(record)) + } else if (filterStatus.value === 'has_skipped_candidate') { + records = records.filter(record => hasUsageSkippedCandidate(record)) } } diff --git a/frontend/src/features/usage/types.ts b/frontend/src/features/usage/types.ts index 668d14efb..cb6a12b70 100644 --- a/frontend/src/features/usage/types.ts +++ b/frontend/src/features/usage/types.ts @@ -100,7 +100,8 @@ export type FilterStatusValue = 'failed' | 'cancelled' | 'has_fallback' | - 'has_retry' + 'has_retry' | + 'has_skipped_candidate' // 默认统计状态 export function createDefaultStats(): UsageStatsState { diff --git a/frontend/src/features/usage/utils/__tests__/skipReason.spec.ts b/frontend/src/features/usage/utils/__tests__/skipReason.spec.ts new file mode 100644 index 000000000..d84c0a22a --- /dev/null +++ b/frontend/src/features/usage/utils/__tests__/skipReason.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import { + CANDIDATE_SKIP_REASON_LABELS, + formatCandidateSkipReason, + isNonAttemptedCandidateStatus, + isSkippedCandidateStatus, +} from '../skipReason' + +describe('candidate skip reason formatting', () => { + it('translates known skip reasons into Chinese labels', () => { + expect(formatCandidateSkipReason('key_rpm_exhausted')).toBe('密钥本分钟请求数已达上限') + expect(formatCandidateSkipReason('provider_concurrency_limit_reached')).toBe('上游提供商并发已达上限') + expect(formatCandidateSkipReason('key_circuit_open')).toBe('密钥熔断中(连续失败后暂停)') + }) + + it('trims surrounding whitespace before lookup', () => { + expect(formatCandidateSkipReason(' key_rpm_exhausted ')).toBe('密钥本分钟请求数已达上限') + }) + + it('falls back to the raw reason so new backend reasons stay visible', () => { + // 后端新增白名单原因、前端还没补翻译时,不能丢信息。 + expect(formatCandidateSkipReason('brand_new_reason')).toBe('brand_new_reason') + }) + + it('returns an empty string for missing or blank reasons', () => { + expect(formatCandidateSkipReason(undefined)).toBe('') + expect(formatCandidateSkipReason(null)).toBe('') + expect(formatCandidateSkipReason(' ')).toBe('') + }) + + it('labels every backend allowlisted reason', () => { + // 与后端 REQUEST_CANDIDATE_SKIP_REASONS 白名单保持同步的关键集合抽查。 + const criticalReasons = [ + 'account_quota_exhausted', + 'api_key_concurrency_limit_reached', + 'auth_api_key_concurrency_limit_reached', + 'key_circuit_open', + 'key_health_score_zero', + 'key_rpm_exhausted', + 'pool_key_lease_busy', + 'provider_concurrency_limit_reached', + 'provider_key_concurrency_limit_reached', + 'provider_quota_blocked', + 'transport_unsupported', + ] + for (const reason of criticalReasons) { + expect(CANDIDATE_SKIP_REASON_LABELS[reason], `missing label for ${reason}`).toBeTruthy() + } + }) + + it('distinguishes skipped candidates from attempted ones', () => { + expect(isSkippedCandidateStatus('skipped')).toBe(true) + expect(isSkippedCandidateStatus('failed')).toBe(false) + expect(isSkippedCandidateStatus(undefined)).toBe(false) + + // available/unused 同样是"从未尝试",用于时间线是否展示的判定。 + expect(isNonAttemptedCandidateStatus('skipped')).toBe(true) + expect(isNonAttemptedCandidateStatus('available')).toBe(true) + expect(isNonAttemptedCandidateStatus('unused')).toBe(true) + expect(isNonAttemptedCandidateStatus('success')).toBe(false) + expect(isNonAttemptedCandidateStatus('failed')).toBe(false) + }) +}) diff --git a/frontend/src/features/usage/utils/__tests__/status.spec.ts b/frontend/src/features/usage/utils/__tests__/status.spec.ts index a94629b33..dbdbdadc5 100644 --- a/frontend/src/features/usage/utils/__tests__/status.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/status.spec.ts @@ -4,6 +4,7 @@ import { formatUsageStreamLabel, hasUsageFallback, hasUsageRetry, + hasUsageSkippedCandidate, isUsageRecordFailed, isUsageRecordSuccessful, isUsageWebSocket, @@ -178,6 +179,19 @@ describe('usage status helpers', () => { expect(hasUsageRetry(buildUsageRecord({ has_retry: undefined }))).toBe(false) }) + it('uses explicit has_skipped_candidate flag for scheduling-skip filtering', () => { + expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: true }))).toBe(true) + expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: false }))).toBe(false) + expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: undefined }))).toBe(false) + }) + + it('keeps skipped-candidate signal independent from fallback signal', () => { + // 被调度跳过 ≠ 故障转移:前者请求从未发出,因此不应被 hasUsageFallback 认领 + const skippedOnly = buildUsageRecord({ has_skipped_candidate: true, has_fallback: false }) + expect(hasUsageSkippedCandidate(skippedOnly)).toBe(true) + expect(hasUsageFallback(skippedOnly)).toBe(false) + }) + it('recognizes persisted WebSocket usage records', () => { expect(isUsageWebSocket(buildUsageRecord({ is_websocket: true }))).toBe(true) expect(isUsageWebSocket(buildUsageRecord({ is_websocket: false }))).toBe(false) diff --git a/frontend/src/features/usage/utils/recordFilterPolicy.ts b/frontend/src/features/usage/utils/recordFilterPolicy.ts index dd2966fb3..3065a02dc 100644 --- a/frontend/src/features/usage/utils/recordFilterPolicy.ts +++ b/frontend/src/features/usage/utils/recordFilterPolicy.ts @@ -1,7 +1,11 @@ import type { FilterStatusValue } from '../types' export function isUserLocalOnlyRecordStatus(status: FilterStatusValue): boolean { - return status === 'has_retry' || status === 'has_fallback' + // 这三个标记都由后端在列表响应里直接给出,但用户侧接口不支持作为服务端筛选条件, + // 因此统一走前端本地过滤。 + return status === 'has_retry' + || status === 'has_fallback' + || status === 'has_skipped_candidate' } export function shouldUseServerUserRecordFilters(input: { diff --git a/frontend/src/features/usage/utils/skipReason.ts b/frontend/src/features/usage/utils/skipReason.ts new file mode 100644 index 000000000..cdfe2e8f0 --- /dev/null +++ b/frontend/src/features/usage/utils/skipReason.ts @@ -0,0 +1,98 @@ +/** + * 候选"被跳过"原因的中文标签。 + * + * 这些字符串来自后端 `StoredRequestCandidate.skip_reason`,取值受 + * `crates/aether-data/contracts/src/repository/candidates/types.rs` 里的 + * `REQUEST_CANDIDATE_SKIP_REASONS` 白名单约束(未知原因会被后端统一清洗成 + * `unclassified_candidate_skip_reason`)。 + * + * 维护约定:后端新增白名单项时,这里补一条中文说明;缺失时前端会原样显示英文, + * 不会丢信息,所以可以安全地"先上线原因、后补翻译"。 + */ +export const CANDIDATE_SKIP_REASON_LABELS: Record = { + // —— 调度期运行时可选择性(不满足条件,从未真正发起请求)—— + account_quota_exhausted: '账号额度已耗尽', + oauth_invalid: 'OAuth 凭证已失效', + provider_quota_blocked: '上游提供商额度已用尽', + provider_concurrency_limit_reached: '上游提供商并发已达上限', + provider_key_concurrency_limit_reached: '上游账号并发已达上限', + provider_inactive: '提供商已停用', + key_inactive: '密钥已停用', + key_circuit_open: '密钥熔断中(连续失败后暂停)', + key_health_score_zero: '密钥健康分为 0', + key_rpm_exhausted: '密钥本分钟请求数已达上限', + key_model_disabled: '该密钥已停用此模型', + key_model_not_allowed: '该密钥不允许使用此模型', + key_api_format_disabled: '该密钥已停用此 API 格式', + api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限', + auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限', + + // —— 传输与路由策略门(在真正发请求前就被拦下)—— + auth_channel_mismatch: '鉴权通道不匹配', + auth_snapshot_missing: '缺少该密钥的鉴权快照', + endpoint_api_format_changed: '端点 API 格式已变更', + endpoint_inactive: '端点已停用', + format_conversion_disabled: '该提供商未开启格式转换', + mapped_model_missing: '缺少映射后的上游模型', + routing_profile_disallowed_key: '路由策略未允许该密钥', + routing_profile_disallowed_provider: '路由策略未允许该提供商', + upstream_url_missing: '缺少上游地址', + gemini_file_mapping_mismatch: 'Gemini 文件映射不匹配', + provider_request_body_build_failed: '上游请求体转换失败', + provider_request_body_missing: '无法构建上游请求体', + + // —— transport_* 系列:该提供商/端点不支持当前这种转发方式 —— + transport_unsupported: '该传输方式不受支持', + transport_api_format_mismatch: 'API 格式与端点不匹配', + transport_api_format_unsupported: '不支持该 API 格式', + transport_auth_unavailable: '无法获取可用鉴权', + transport_body_rules_apply_failed: '请求体改写规则执行失败', + transport_body_rules_unsupported: '不支持请求体改写规则', + transport_body_rules_unsupported_for_binary_upload: '二进制上传不支持请求体改写规则', + transport_custom_path_unsupported: '不支持自定义路径', + transport_endpoint_kind_unsupported: '不支持该端点类型', + transport_header_rules_apply_failed: '请求头改写规则执行失败', + transport_header_rules_unsupported: '不支持请求头改写规则', + transport_oauth_resolution_unsupported: '不支持该 OAuth 解析方式', + transport_operation_unsupported: '不支持该操作类型', + transport_profile_unsupported: '不支持该传输配置', + transport_provider_type_unsupported: '不支持该提供商类型', + transport_proxy_or_profile_unsupported: '不支持代理或传输配置', + transport_proxy_unsupported: '不支持该代理', + transport_snapshot_missing: '缺少传输快照', + + // —— 号池(pool)相关 —— + pool_group_exhausted: '号池已无可用账号', + pool_account_blocked: '号池账号已被封禁', + pool_account_exhausted: '号池账号额度已耗尽', + pool_active_probe_sealed: '号池探测中,暂不分配', + pool_cooldown: '号池账号冷却中', + pool_cost_limit_reached: '号池费用已达上限', + pool_key_lease_busy: '池内账号正被其他请求占用', + pool_score_member_missing: '号池评分成员缺失', +} + +/** 后端无法归类时使用的占位原因。 */ +export const UNCLASSIFIED_CANDIDATE_SKIP_REASON = 'unclassified_candidate_skip_reason' + +/** + * 把候选跳过原因转成中文展示文案。 + * + * 命中已知标签时返回中文;否则原样返回后端字符串(便于排查新原因), + * 空值返回空字符串,调用方据此判断是否展示。 + */ +export function formatCandidateSkipReason(reason?: string | null): string { + const normalized = typeof reason === 'string' ? reason.trim() : '' + if (!normalized) return '' + return CANDIDATE_SKIP_REASON_LABELS[normalized] ?? normalized +} + +/** 是否为"根本没有向上游发起请求"的跳过状态。 */ +export function isSkippedCandidateStatus(status?: string | null): boolean { + return status === 'skipped' +} + +/** 是否为"被枚举出来但从未尝试"的候选状态(含跳过与未使用)。 */ +export function isNonAttemptedCandidateStatus(status?: string | null): boolean { + return status === 'skipped' || status === 'available' || status === 'unused' +} diff --git a/frontend/src/features/usage/utils/status.ts b/frontend/src/features/usage/utils/status.ts index e93f2b948..c1aefc465 100644 --- a/frontend/src/features/usage/utils/status.ts +++ b/frontend/src/features/usage/utils/status.ts @@ -49,6 +49,16 @@ export function hasUsageRetry( return record.has_retry === true } +/** + * 是否有候选在调度阶段被跳过(请求从未发往该候选)。 + * 与 hasUsageFallback 互补:后者要求"更靠前的候选确实尝试并失败"。 + */ +export function hasUsageSkippedCandidate( + record: Pick +): boolean { + return record.has_skipped_candidate === true +} + export function isUsageWebSocket( record: Pick ): boolean { diff --git a/frontend/src/views/shared/Usage.vue b/frontend/src/views/shared/Usage.vue index 6c58617f2..fb7886b6e 100644 --- a/frontend/src/views/shared/Usage.vue +++ b/frontend/src/views/shared/Usage.vue @@ -441,6 +441,8 @@ const filteredRecords = computed(() => { records = records.filter(record => hasUsageFallback(record)) } else if (filterStatus.value === 'has_retry') { records = records.filter(record => record.has_retry === true) + } else if (filterStatus.value === 'has_skipped_candidate') { + records = records.filter(record => record.has_skipped_candidate === true) } }