mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
feat(usage): 展示调度跳过候选及原因并补齐手机端提示
This commit is contained in:
@@ -76,21 +76,30 @@ fn apply_admin_usage_status_filter(query: &mut UsageAuditListQuery, status: Opti
|
|||||||
"pending" | "streaming" | "completed" | "cancelled" => {
|
"pending" | "streaming" | "completed" | "cancelled" => {
|
||||||
query.statuses = Some(vec![status]);
|
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 {
|
struct AdminUsageAttemptFlags {
|
||||||
has_fallback: bool,
|
has_fallback: bool,
|
||||||
has_retry: bool,
|
has_retry: bool,
|
||||||
|
/// 是否存在"被调度跳过"的候选(调度阶段判定本次不可用,从未向上游发起请求)。
|
||||||
|
///
|
||||||
|
/// 这是与 has_fallback 正交的信号:has_fallback 表示"更靠前的候选真的失败并被换掉",
|
||||||
|
/// 而本字段表示"更靠前的候选压根没被发出去"。两者在日志列表里观感都是"换了提供商",
|
||||||
|
/// 但用户拿不到 has_fallback 小图标时容易误判为调度错误,故单独暴露。
|
||||||
|
has_skipped_candidate: bool,
|
||||||
|
/// 跳过原因(去重、保持出现顺序),用于前端 tooltip 直接说明"为什么没用它"。
|
||||||
|
skipped_candidate_reasons: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_usage_attempt_status_filter(status: Option<&str>) -> Option<&'static str> {
|
fn admin_usage_attempt_status_filter(status: Option<&str>) -> Option<&'static str> {
|
||||||
match status?.trim().to_ascii_lowercase().as_str() {
|
match status?.trim().to_ascii_lowercase().as_str() {
|
||||||
"has_fallback" => Some("has_fallback"),
|
"has_fallback" => Some("has_fallback"),
|
||||||
"has_retry" => Some("has_retry"),
|
"has_retry" => Some("has_retry"),
|
||||||
|
"has_skipped_candidate" => Some("has_skipped_candidate"),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,25 +154,52 @@ fn admin_usage_attempt_flags_from_candidates(
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
let has_retry = candidates.iter().any(admin_usage_candidate_was_retried);
|
let has_retry = candidates.iter().any(admin_usage_candidate_was_retried);
|
||||||
|
let skipped_candidate_reasons = admin_usage_skipped_candidate_reasons(candidates);
|
||||||
|
|
||||||
AdminUsageAttemptFlags {
|
AdminUsageAttemptFlags {
|
||||||
has_fallback,
|
has_fallback,
|
||||||
has_retry,
|
has_retry,
|
||||||
|
has_skipped_candidate: !skipped_candidate_reasons.is_empty(),
|
||||||
|
skipped_candidate_reasons,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 收集被跳过候选的原因,去重并保持候选顺序(决定性的在前,便于阅读)。
|
||||||
|
fn admin_usage_skipped_candidate_reasons(candidates: &[StoredRequestCandidate]) -> Vec<String> {
|
||||||
|
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(
|
fn admin_usage_attempt_flags_for_item(
|
||||||
item: &StoredRequestUsageAudit,
|
item: &StoredRequestUsageAudit,
|
||||||
flags_by_usage_id: &BTreeMap<String, AdminUsageAttemptFlags>,
|
flags_by_usage_id: &BTreeMap<String, AdminUsageAttemptFlags>,
|
||||||
request_candidate_reader_available: bool,
|
request_candidate_reader_available: bool,
|
||||||
) -> AdminUsageAttemptFlags {
|
) -> 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 {
|
if request_candidate_reader_available {
|
||||||
AdminUsageAttemptFlags::default()
|
AdminUsageAttemptFlags::default()
|
||||||
} else {
|
} else {
|
||||||
AdminUsageAttemptFlags {
|
AdminUsageAttemptFlags {
|
||||||
has_fallback: admin_usage_has_fallback(item),
|
has_fallback: admin_usage_has_fallback(item),
|
||||||
has_retry: false,
|
has_retry: false,
|
||||||
|
has_skipped_candidate: false,
|
||||||
|
skipped_candidate_reasons: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -432,6 +468,8 @@ fn admin_usage_matches_attempt_status(
|
|||||||
match status {
|
match status {
|
||||||
"has_fallback" => flags.has_fallback,
|
"has_fallback" => flags.has_fallback,
|
||||||
"has_retry" => flags.has_retry,
|
"has_retry" => flags.has_retry,
|
||||||
|
// 与 has_fallback 区分:这里是"更靠前的候选被调度跳过、根本没发出去"
|
||||||
|
"has_skipped_candidate" => flags.has_skipped_candidate,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,6 +541,9 @@ fn build_admin_usage_records_response_with_attempt_flags(
|
|||||||
);
|
);
|
||||||
record["has_fallback"] = json!(flags.has_fallback);
|
record["has_fallback"] = json!(flags.has_fallback);
|
||||||
record["has_retry"] = json!(flags.has_retry);
|
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
|
record
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1031,9 +1072,11 @@ mod tests {
|
|||||||
use aether_data_contracts::repository::candidates::{
|
use aether_data_contracts::repository::candidates::{
|
||||||
RequestCandidateStatus, StoredRequestCandidate,
|
RequestCandidateStatus, StoredRequestCandidate,
|
||||||
};
|
};
|
||||||
|
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{
|
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,
|
admin_usage_terminal_candidate_state_override, build_admin_usage_keyword_search_query,
|
||||||
build_admin_usage_records_query, latest_admin_usage_image_progress,
|
build_admin_usage_records_query, latest_admin_usage_image_progress,
|
||||||
AdminUsageSearchContext,
|
AdminUsageSearchContext,
|
||||||
@@ -1075,6 +1118,144 @@ mod tests {
|
|||||||
.expect("candidate should build")
|
.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]
|
#[test]
|
||||||
fn admin_usage_active_override_uses_current_terminal_candidate_latency() {
|
fn admin_usage_active_override_uses_current_terminal_candidate_latency() {
|
||||||
let candidate = sample_candidate(
|
let candidate = sample_candidate(
|
||||||
|
|||||||
@@ -65,5 +65,13 @@ export interface UsageRecord {
|
|||||||
response_time_updated_at?: string | null
|
response_time_updated_at?: string | null
|
||||||
has_fallback?: boolean
|
has_fallback?: boolean
|
||||||
has_retry?: boolean
|
has_retry?: boolean
|
||||||
|
/**
|
||||||
|
* 是否存在被调度跳过的候选(候选在调度阶段即被判定不可用,从未向上游发起请求)。
|
||||||
|
* 与 has_fallback 的区别:has_fallback 代表"更靠前的候选真的失败了",
|
||||||
|
* 本字段代表"更靠前的候选压根没被发出去",用于解释"无报错却换了提供商"。
|
||||||
|
*/
|
||||||
|
has_skipped_candidate?: boolean
|
||||||
|
/** 被跳过候选的原因列表(后端已按候选顺序去重) */
|
||||||
|
skipped_candidate_reasons?: string[]
|
||||||
image_progress?: ImageProgress | null
|
image_progress?: ImageProgress | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,21 @@
|
|||||||
<Badge :variant="getFinalStatusBadgeVariant(computedFinalStatus)">
|
<Badge :variant="getFinalStatusBadgeVariant(computedFinalStatus)">
|
||||||
{{ getFinalStatusLabel(computedFinalStatus) }}
|
{{ getFinalStatusLabel(computedFinalStatus) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<!-- 被跳过的候选可以收起,避免长时间线上排查时干扰视线 -->
|
||||||
|
<button
|
||||||
|
v-if="skippedTraceCandidates.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="skipped-toggle"
|
||||||
|
:class="{ active: showSkippedCandidates }"
|
||||||
|
:aria-pressed="showSkippedCandidates"
|
||||||
|
:title="showSkippedCandidates
|
||||||
|
? '隐藏被跳过的候选'
|
||||||
|
: '被跳过的候选在调度阶段就被判定本次不可用,从未向上游发起请求'"
|
||||||
|
data-timeline-skipped-toggle
|
||||||
|
@click="showSkippedCandidates = !showSkippedCandidates"
|
||||||
|
>
|
||||||
|
{{ showSkippedCandidates ? '隐藏' : '显示' }}被跳过候选({{ skippedTraceCandidates.length }})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
{{ formatLatency(totalTraceLatency) }}
|
{{ formatLatency(totalTraceLatency) }}
|
||||||
@@ -449,7 +464,7 @@
|
|||||||
v-if="currentAttemptSkipReasonDisplay"
|
v-if="currentAttemptSkipReasonDisplay"
|
||||||
class="skip-reason"
|
class="skip-reason"
|
||||||
>
|
>
|
||||||
<span class="reason-label">跳过原因</span>
|
<span class="reason-label">{{ currentAttemptSkipReasonLabel }}</span>
|
||||||
<span class="reason-content">
|
<span class="reason-content">
|
||||||
<span class="reason-value">{{ currentAttemptSkipReasonDisplay }}</span>
|
<span class="reason-value">{{ currentAttemptSkipReasonDisplay }}</span>
|
||||||
<span
|
<span
|
||||||
@@ -586,6 +601,7 @@ import { resolveTimelineFinalStatus } from '../utils/status'
|
|||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { buildFailureDiagnosticBundle, diagnosticPathFromMessage, visibleFailureRecords } from '../utils/failureDiagnostic'
|
import { buildFailureDiagnosticBundle, diagnosticPathFromMessage, visibleFailureRecords } from '../utils/failureDiagnostic'
|
||||||
import { prepareDiagnosticExport, sanitizeDiagnostic } from '../utils/diagnosticExport'
|
import { prepareDiagnosticExport, sanitizeDiagnostic } from '../utils/diagnosticExport'
|
||||||
|
import { formatCandidateSkipReason } from '../utils/skipReason'
|
||||||
import {
|
import {
|
||||||
buildPoolGroupVisibleAttempts,
|
buildPoolGroupVisibleAttempts,
|
||||||
buildPoolParticipatedCandidates,
|
buildPoolParticipatedCandidates,
|
||||||
@@ -841,13 +857,30 @@ const compareBySchedulingOrder = (a: CandidateRecord, b: CandidateRecord): numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 候选时间线(按调度顺序排序;lazy 加载的跳过候选通常没有 started_at)
|
// 候选时间线(按调度顺序排序;lazy 加载的跳过候选通常没有 started_at)
|
||||||
const rawTimeline = computed<CandidateRecord[]>(() => {
|
const allTraceCandidates = computed<CandidateRecord[]>(() => {
|
||||||
if (!trace.value) return []
|
if (!trace.value) return []
|
||||||
return [...trace.value.candidates]
|
return [...trace.value.candidates]
|
||||||
.filter(c => TIMELINE_STATUS.includes(c.status))
|
.filter(c => TIMELINE_STATUS.includes(c.status))
|
||||||
.sort(compareBySchedulingOrder)
|
.sort(compareBySchedulingOrder)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 被跳过的候选:调度阶段就判定"这次不能用",从未真正向上游发起请求。
|
||||||
|
*
|
||||||
|
* 默认展示:它是"为什么这次没用优先级更高的提供商"的直接答案,
|
||||||
|
* 隐藏后容易让人误以为该提供商从未参与调度。开关供长时间排查时收起。
|
||||||
|
*/
|
||||||
|
const showSkippedCandidates = ref(true)
|
||||||
|
|
||||||
|
const skippedTraceCandidates = computed<CandidateRecord[]>(
|
||||||
|
() => allTraceCandidates.value.filter(c => c.status === 'skipped'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const rawTimeline = computed<CandidateRecord[]>(() => {
|
||||||
|
if (showSkippedCandidates.value) return allTraceCandidates.value
|
||||||
|
return allTraceCandidates.value.filter(c => c.status !== 'skipped')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
const schedulingAudit = computed<Record<string, unknown> | null>(() => {
|
const schedulingAudit = computed<Record<string, unknown> | null>(() => {
|
||||||
const metadata = props.requestMetadata
|
const metadata = props.requestMetadata
|
||||||
@@ -1233,7 +1266,12 @@ const latestTraceAttemptForState = computed<CandidateRecord | null>(() => {
|
|||||||
const candidates = rawTimeline.value
|
const candidates = rawTimeline.value
|
||||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||||
const candidate = candidates[index]
|
const candidate = candidates[index]
|
||||||
if (candidate.status !== 'available' && candidate.status !== 'unused') {
|
// 跳过/未使用候选没有状态码与错误信息,不能代表本次请求的最终结果。
|
||||||
|
if (
|
||||||
|
candidate.status !== 'available' &&
|
||||||
|
candidate.status !== 'unused' &&
|
||||||
|
candidate.status !== 'skipped'
|
||||||
|
) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1418,35 +1456,30 @@ const currentAttemptKeyFormatsDisplay = computed(() => {
|
|||||||
.map(format => formatApiFormat(format))
|
.map(format => formatApiFormat(format))
|
||||||
.join(' / ')
|
.join(' / ')
|
||||||
})
|
})
|
||||||
const SKIP_REASON_LABELS: Record<string, string> = {
|
|
||||||
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 currentAttemptSkipReasonDisplay = computed(() => {
|
||||||
const attempt = currentAttempt.value
|
const attempt = currentAttempt.value
|
||||||
if (!attempt?.skip_reason) return ''
|
if (!attempt?.skip_reason) return ''
|
||||||
|
|
||||||
const skipReasonLabel = SKIP_REASON_LABELS[attempt.skip_reason]
|
// transport_unsupported 这类"泛化原因"优先展示后端采集到的具体细节,便于排查。
|
||||||
if (skipReasonLabel) {
|
if (attempt.skip_reason === 'transport_unsupported') {
|
||||||
return skipReasonLabel
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt.skip_reason !== 'transport_unsupported') {
|
|
||||||
return attempt.skip_reason
|
|
||||||
}
|
|
||||||
|
|
||||||
const transportDiagnostics = resolveTransportDiagnostics(attempt)
|
const transportDiagnostics = resolveTransportDiagnostics(attempt)
|
||||||
const requestPair = extractObject(transportDiagnostics?.request_pair)
|
const requestPair = extractObject(transportDiagnostics?.request_pair)
|
||||||
const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string'
|
const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string'
|
||||||
? requestPair.transport_unsupported_reason.trim()
|
? requestPair.transport_unsupported_reason.trim()
|
||||||
: ''
|
: ''
|
||||||
|
return detailedReason || formatCandidateSkipReason(attempt.skip_reason)
|
||||||
|
}
|
||||||
|
|
||||||
return detailedReason || attempt.skip_reason
|
return formatCandidateSkipReason(attempt.skip_reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 区分"跳过"与"尝试失败":被跳过的候选从未发到上游,
|
||||||
|
* 不写清楚容易被误读成"上游返回了错误"。
|
||||||
|
*/
|
||||||
|
const currentAttemptSkipReasonLabel = computed(() => {
|
||||||
|
const status = currentAttempt.value?.status
|
||||||
|
return status === 'skipped' ? '跳过原因(未向上游发起请求)' : '跳过原因'
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentAttemptFailureDiagnostic = computed<{
|
const currentAttemptFailureDiagnostic = computed<{
|
||||||
@@ -2212,7 +2245,9 @@ const loadTrace = async (silent = false) => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: true })
|
// 始终拉取全部候选:被跳过的候选是"为什么没用某个提供商"的关键证据,
|
||||||
|
// 是否显示由前端开关控制,避免再发一次请求。
|
||||||
|
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: false })
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isAxiosError(err) && err.response?.status === 404) {
|
if (isAxiosError(err) && err.response?.status === 404) {
|
||||||
internalTrace.value = null
|
internalTrace.value = null
|
||||||
@@ -2798,6 +2833,30 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
|
|||||||
cursor: not-allowed;
|
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 {
|
.nav-info {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -149,32 +149,14 @@
|
|||||||
<SelectValue placeholder="状态" />
|
<SelectValue placeholder="状态" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__all__">
|
<!-- 手机与桌面共用状态选项,避免新增筛选时漏掉手机入口。 -->
|
||||||
全部类型
|
<SelectItem
|
||||||
</SelectItem>
|
v-for="option in statusFilterOptions"
|
||||||
<SelectItem value="stream">
|
:key="option.value"
|
||||||
HTTP 流式
|
:value="option.value"
|
||||||
</SelectItem>
|
:disabled="option.disabled"
|
||||||
<SelectItem value="standard">
|
>
|
||||||
HTTP 标准
|
{{ option.label }}
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="websocket">
|
|
||||||
WebSocket (WS)
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="active">
|
|
||||||
活跃
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="failed">
|
|
||||||
失败
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="cancelled">
|
|
||||||
已取消
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="has_retry">
|
|
||||||
发生重试
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="has_fallback">
|
|
||||||
发生转移
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -362,6 +344,28 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="shrink-0 text-muted-foreground/40">·</span>
|
<span class="shrink-0 text-muted-foreground/40">·</span>
|
||||||
<span class="min-w-0 truncate">{{ formatRecordProviderSegment(record) }}</span>
|
<span class="min-w-0 truncate">{{ formatRecordProviderSegment(record) }}</span>
|
||||||
|
<!-- 手机与桌面保持相同的标记优先级:发生故障转移时优先显示转移标记。 -->
|
||||||
|
<Shuffle
|
||||||
|
v-if="record.has_fallback"
|
||||||
|
data-usage-attempt-marker="fallback"
|
||||||
|
class="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0"
|
||||||
|
title="此请求发生了 Provider 故障转移"
|
||||||
|
aria-label="发生 Provider 故障转移"
|
||||||
|
/>
|
||||||
|
<Ban
|
||||||
|
v-if="record.has_skipped_candidate && !record.has_fallback"
|
||||||
|
data-usage-attempt-marker="skipped-candidate"
|
||||||
|
class="w-3.5 h-3.5 text-slate-500 dark:text-slate-400 flex-shrink-0"
|
||||||
|
:title="skippedCandidateTooltip(record)"
|
||||||
|
:aria-label="skippedCandidateAriaLabel(record)"
|
||||||
|
/>
|
||||||
|
<RefreshCcw
|
||||||
|
v-if="record.has_retry"
|
||||||
|
data-usage-attempt-marker="retry"
|
||||||
|
class="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 flex-shrink-0"
|
||||||
|
title="此请求发生了重试"
|
||||||
|
aria-label="发生重试"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第四行:性能指标 -->
|
<!-- 第四行:性能指标 -->
|
||||||
@@ -791,6 +795,18 @@
|
|||||||
title="此请求发生了 Provider 故障转移"
|
title="此请求发生了 Provider 故障转移"
|
||||||
aria-label="发生 Provider 故障转移"
|
aria-label="发生 Provider 故障转移"
|
||||||
/>
|
/>
|
||||||
|
<!--
|
||||||
|
被跳过的更高优先级候选:与"故障转移"区分开。
|
||||||
|
它表示候选在调度阶段就被排除、请求从未发出,因此不会有任何上游报错,
|
||||||
|
是"无报错却换了提供商"这一现象的直接解释。
|
||||||
|
-->
|
||||||
|
<Ban
|
||||||
|
v-if="record.has_skipped_candidate && !record.has_fallback"
|
||||||
|
data-usage-attempt-marker="skipped-candidate"
|
||||||
|
class="w-3.5 h-3.5 text-slate-500 dark:text-slate-400 flex-shrink-0"
|
||||||
|
:title="skippedCandidateTooltip(record)"
|
||||||
|
:aria-label="skippedCandidateAriaLabel(record)"
|
||||||
|
/>
|
||||||
<RefreshCcw
|
<RefreshCcw
|
||||||
v-if="record.has_retry"
|
v-if="record.has_retry"
|
||||||
data-usage-attempt-marker="retry"
|
data-usage-attempt-marker="retry"
|
||||||
@@ -1093,7 +1109,7 @@ import {
|
|||||||
SortableTableHead,
|
SortableTableHead,
|
||||||
TableFilterMenu,
|
TableFilterMenu,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
|
import { Ban, EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
|
||||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||||
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
|
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
|
||||||
import {
|
import {
|
||||||
@@ -1115,6 +1131,7 @@ import { useDarkMode } from '@/composables/useDarkMode'
|
|||||||
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
|
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
|
||||||
import { formatServiceTierFact } from '../utils/service-tier'
|
import { formatServiceTierFact } from '../utils/service-tier'
|
||||||
|
import { formatCandidateSkipReason } from '../utils/skipReason'
|
||||||
import { isCyberPolicyError } from '../utils/cyberError'
|
import { isCyberPolicyError } from '../utils/cyberError'
|
||||||
import { formatUsageWebSocketTransportTitle as getWebSocketTransportTitle } from '../utils/websocketTransport'
|
import { formatUsageWebSocketTransportTitle as getWebSocketTransportTitle } from '../utils/websocketTransport'
|
||||||
import type { DateRangeParams, UsageRecord } from '../types'
|
import type { DateRangeParams, UsageRecord } from '../types'
|
||||||
@@ -1370,6 +1387,8 @@ const statusFilterOptions: FilterOption[] = [
|
|||||||
{ value: 'cancelled', label: '已取消' },
|
{ value: 'cancelled', label: '已取消' },
|
||||||
{ value: 'has_retry', label: '发生重试' },
|
{ value: 'has_retry', label: '发生重试' },
|
||||||
{ value: 'has_fallback', label: '发生转移' },
|
{ value: 'has_fallback', label: '发生转移' },
|
||||||
|
// 与"发生转移"区分:候选在调度阶段就被排除,请求从未发出(无上游报错)
|
||||||
|
{ value: 'has_skipped_candidate', label: '有候选被调度跳过' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const timeRangeModel = computed({
|
const timeRangeModel = computed({
|
||||||
@@ -1602,6 +1621,23 @@ function shouldShowFormatConversion(record: UsageRecord): boolean {
|
|||||||
return record.api_format.trim().toLowerCase() !== record.endpoint_api_format.trim().toLowerCase()
|
return record.api_format.trim().toLowerCase() !== record.endpoint_api_format.trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 被跳过候选的提示文案:把"调度阶段排除"和"上游失败"讲清楚,
|
||||||
|
// 避免用户把"无报错换提供商"误判成调度 bug 或提供商静默出错。
|
||||||
|
function skippedCandidateTooltip(record: UsageRecord): string {
|
||||||
|
const reasons = (record.skipped_candidate_reasons ?? [])
|
||||||
|
.map(formatCandidateSkipReason)
|
||||||
|
.filter((reason, index, all) => 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(包含转换信息)
|
// 获取 API 格式的 tooltip(包含转换信息)
|
||||||
function getApiFormatTooltip(record: UsageRecord): string {
|
function getApiFormatTooltip(record: UsageRecord): string {
|
||||||
if (!record.api_format) {
|
if (!record.api_format) {
|
||||||
|
|||||||
@@ -425,6 +425,56 @@ describe('HorizontalRequestTimeline', () => {
|
|||||||
expect(nodeDots[2].classList.contains('status-pending')).toBe(true)
|
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<HTMLElement>('.node-label')]
|
||||||
|
.map(label => label.textContent?.trim()))
|
||||||
|
.toEqual(['Provider 1', 'Provider 2'])
|
||||||
|
|
||||||
|
// 点击第一个节点查看详情,应看到中文跳过原因
|
||||||
|
root.querySelector<HTMLElement>('.minimal-node-group')?.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(root.textContent).toContain('密钥本分钟请求数已达上限')
|
||||||
|
// 必须点明"没有向上游发起请求",否则会被误读成上游报错
|
||||||
|
expect(root.textContent).toContain('未向上游发起请求')
|
||||||
|
|
||||||
|
// 开关可把跳过候选整体收起
|
||||||
|
const toggle = root.querySelector<HTMLButtonElement>('.skipped-toggle')
|
||||||
|
expect(toggle).not.toBeNull()
|
||||||
|
toggle?.click()
|
||||||
|
await nextTick()
|
||||||
|
expect([...root.querySelectorAll<HTMLElement>('.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 () => {
|
it('keeps successful runtime pool key visible when only pool_key_index is recorded', async () => {
|
||||||
const trace = buildTrace([
|
const trace = buildTrace([
|
||||||
buildCandidate({
|
buildCandidate({
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ vi.mock('lucide-vue-next', async () => {
|
|||||||
EyeOff: Icon,
|
EyeOff: Icon,
|
||||||
Search: Icon,
|
Search: Icon,
|
||||||
Shuffle: Icon,
|
Shuffle: Icon,
|
||||||
|
Ban: Icon,
|
||||||
ChevronDown: Icon,
|
ChevronDown: Icon,
|
||||||
Check: 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="fallback"]')).not.toBeNull()
|
||||||
expect(root.querySelector('[data-usage-attempt-marker="retry"]')).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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { UsageRecord, FilterStatusValue } from '../types'
|
|||||||
import {
|
import {
|
||||||
hasUsageFallback,
|
hasUsageFallback,
|
||||||
hasUsageRetry,
|
hasUsageRetry,
|
||||||
|
hasUsageSkippedCandidate,
|
||||||
isUsageRecordFailed,
|
isUsageRecordFailed,
|
||||||
isUsageUpstreamStream,
|
isUsageUpstreamStream,
|
||||||
isUsageWebSocket,
|
isUsageWebSocket,
|
||||||
@@ -97,6 +98,8 @@ export function useUsageFilters(options: UseUsageFiltersOptions) {
|
|||||||
records = records.filter(record => hasUsageFallback(record))
|
records = records.filter(record => hasUsageFallback(record))
|
||||||
} else if (filterStatus.value === 'has_retry') {
|
} else if (filterStatus.value === 'has_retry') {
|
||||||
records = records.filter(record => hasUsageRetry(record))
|
records = records.filter(record => hasUsageRetry(record))
|
||||||
|
} else if (filterStatus.value === 'has_skipped_candidate') {
|
||||||
|
records = records.filter(record => hasUsageSkippedCandidate(record))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ export type FilterStatusValue =
|
|||||||
'failed' |
|
'failed' |
|
||||||
'cancelled' |
|
'cancelled' |
|
||||||
'has_fallback' |
|
'has_fallback' |
|
||||||
'has_retry'
|
'has_retry' |
|
||||||
|
'has_skipped_candidate'
|
||||||
|
|
||||||
// 默认统计状态
|
// 默认统计状态
|
||||||
export function createDefaultStats(): UsageStatsState {
|
export function createDefaultStats(): UsageStatsState {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
formatUsageStreamLabel,
|
formatUsageStreamLabel,
|
||||||
hasUsageFallback,
|
hasUsageFallback,
|
||||||
hasUsageRetry,
|
hasUsageRetry,
|
||||||
|
hasUsageSkippedCandidate,
|
||||||
isUsageRecordFailed,
|
isUsageRecordFailed,
|
||||||
isUsageRecordSuccessful,
|
isUsageRecordSuccessful,
|
||||||
isUsageWebSocket,
|
isUsageWebSocket,
|
||||||
@@ -178,6 +179,19 @@ describe('usage status helpers', () => {
|
|||||||
expect(hasUsageRetry(buildUsageRecord({ has_retry: undefined }))).toBe(false)
|
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', () => {
|
it('recognizes persisted WebSocket usage records', () => {
|
||||||
expect(isUsageWebSocket(buildUsageRecord({ is_websocket: true }))).toBe(true)
|
expect(isUsageWebSocket(buildUsageRecord({ is_websocket: true }))).toBe(true)
|
||||||
expect(isUsageWebSocket(buildUsageRecord({ is_websocket: false }))).toBe(false)
|
expect(isUsageWebSocket(buildUsageRecord({ is_websocket: false }))).toBe(false)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { FilterStatusValue } from '../types'
|
import type { FilterStatusValue } from '../types'
|
||||||
|
|
||||||
export function isUserLocalOnlyRecordStatus(status: FilterStatusValue): boolean {
|
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: {
|
export function shouldUseServerUserRecordFilters(input: {
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
// —— 调度期运行时可选择性(不满足条件,从未真正发起请求)——
|
||||||
|
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'
|
||||||
|
}
|
||||||
@@ -49,6 +49,16 @@ export function hasUsageRetry(
|
|||||||
return record.has_retry === true
|
return record.has_retry === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否有候选在调度阶段被跳过(请求从未发往该候选)。
|
||||||
|
* 与 hasUsageFallback 互补:后者要求"更靠前的候选确实尝试并失败"。
|
||||||
|
*/
|
||||||
|
export function hasUsageSkippedCandidate(
|
||||||
|
record: Pick<UsageRecord, 'has_skipped_candidate'>
|
||||||
|
): boolean {
|
||||||
|
return record.has_skipped_candidate === true
|
||||||
|
}
|
||||||
|
|
||||||
export function isUsageWebSocket(
|
export function isUsageWebSocket(
|
||||||
record: Pick<UsageRecord, 'is_websocket'>
|
record: Pick<UsageRecord, 'is_websocket'>
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
@@ -441,6 +441,8 @@ const filteredRecords = computed(() => {
|
|||||||
records = records.filter(record => hasUsageFallback(record))
|
records = records.filter(record => hasUsageFallback(record))
|
||||||
} else if (filterStatus.value === 'has_retry') {
|
} else if (filterStatus.value === 'has_retry') {
|
||||||
records = records.filter(record => record.has_retry === true)
|
records = records.filter(record => record.has_retry === true)
|
||||||
|
} else if (filterStatus.value === 'has_skipped_candidate') {
|
||||||
|
records = records.filter(record => record.has_skipped_candidate === true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user