mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
Merge pull request #833 from AAEE86/feat/usage-skipped-candidates
feat(usage): 展示调度跳过候选及原因并补齐手机端提示
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user