Merge pull request #833 from AAEE86/feat/usage-skipped-candidates

feat(usage): 展示调度跳过候选及原因并补齐手机端提示
This commit is contained in:
ZheFox
2026-09-21 10:36:48 +08:00
committed by GitHub
14 changed files with 622 additions and 58 deletions
@@ -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<String>,
}
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<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(
item: &StoredRequestUsageAudit,
flags_by_usage_id: &BTreeMap<String, AdminUsageAttemptFlags>,
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(