Merge origin/main into main

Integrate upstream updates while preserving the local analytics dashboards and schema-only migration changes.

Combine user account analysis with upstream user/group usage statistics in separate tabs, retain all migration versions, and keep the deleted audit document removed.

Validation: gateway all-target cargo check, frontend type check and 57 focused tests, 48 migration tests, schema composition checks, and diff whitespace checks.
This commit is contained in:
elky
2026-10-02 11:57:18 +08:00
343 changed files with 27929 additions and 2549 deletions
@@ -12,3 +12,138 @@ pub(crate) use self::stats::{
};
pub(crate) use self::stats::{AdminStatsTimeRange, AdminStatsUsageFilter};
pub(crate) use self::usage::maybe_build_local_admin_usage_response;
pub(crate) async fn resolve_usage_user_group_scope(
state: &crate::handlers::admin::request::AdminAppState<'_>,
query: Option<&str>,
include_inactive: bool,
exclude_admin: bool,
) -> Result<Result<Option<Vec<String>>, String>, crate::GatewayError> {
let group_id = crate::handlers::admin::shared::query_param_value(query, "user_group_id");
let Some(group_id) = group_id else {
return Ok(Ok(None));
};
if crate::handlers::admin::shared::query_param_value(query, "user_id").is_some() {
return Ok(Err(
"user_id and user_group_id cannot be used together".to_string()
));
}
if !state.has_user_data_reader() {
return Ok(Err("user group data is unavailable".to_string()));
}
if group_id == UNGROUPED_USAGE_ID {
let ids = ungrouped_usage_users(state)
.await?
.into_iter()
.filter(|user| include_inactive || user.is_active)
.filter(|user| !exclude_admin || !user.role.eq_ignore_ascii_case("admin"))
.map(|user| user.id)
.collect();
return Ok(Ok(Some(ids)));
}
match state
.resolve_usage_user_group_member_ids(&group_id, include_inactive, exclude_admin)
.await?
{
Some(user_ids) => Ok(Ok(Some(user_ids))),
None => Ok(Err("user_group_id does not exist".to_string())),
}
}
/// Reserved statistics-only scope; never a permission group.
pub(crate) const UNGROUPED_USAGE_ID: &str = "__ungrouped__";
pub(crate) async fn ungrouped_usage_users(
state: &crate::handlers::admin::request::AdminAppState<'_>,
) -> Result<Vec<aether_data::repository::users::StoredUserSummary>, crate::GatewayError> {
use aether_data::repository::users::UserExportListQuery;
let mut users = Vec::new();
let mut skip = 0;
loop {
let page = state
.list_export_users_page(&UserExportListQuery {
skip,
limit: 500,
..Default::default()
})
.await?;
let count = page.len();
if count == 0 {
break;
}
let ids = page.into_iter().map(|user| user.id).collect::<Vec<_>>();
let grouped = state
.list_user_group_memberships_by_user_ids(&ids)
.await?
.into_iter()
.map(|membership| membership.user_id)
.collect::<std::collections::BTreeSet<_>>();
let ids = ids
.into_iter()
.filter(|id| !grouped.contains(id))
.collect::<Vec<_>>();
users.extend(
state
.list_users_by_ids(&ids)
.await?
.into_iter()
.filter(|user| !user.is_deleted),
);
skip += count;
if count < 500 {
break;
}
}
Ok(users)
}
/// Current group provider policy, resolved to the provider-name dimension used by usage rollups.
/// None is unrestricted; Some(empty) deliberately matches no usage.
pub(crate) async fn usage_group_provider_names(
state: &crate::handlers::admin::request::AdminAppState<'_>,
group: &aether_data::repository::users::StoredUserGroup,
) -> Result<Option<Vec<String>>, crate::GatewayError> {
if matches!(
group.allowed_providers_mode.as_str(),
"unrestricted" | "inherit"
) {
return Ok(None);
}
if group.allowed_providers_mode != "specific" {
return Ok(Some(Vec::new()));
}
let allowed = group.allowed_providers.as_deref().unwrap_or_default();
let providers = state.list_provider_catalog_providers(false).await?;
let mut names = providers
.into_iter()
.filter(|provider| {
allowed.iter().any(|value| {
let value = value.trim();
value.eq_ignore_ascii_case(&provider.id)
|| value.eq_ignore_ascii_case(&provider.name)
|| value.eq_ignore_ascii_case(&provider.provider_type)
})
})
.map(|provider| provider.name)
.collect::<Vec<_>>();
names.sort();
names.dedup();
Ok(Some(names))
}
pub(crate) async fn resolve_usage_group_provider_names(
state: &crate::handlers::admin::request::AdminAppState<'_>,
query: Option<&str>,
) -> Result<Option<Vec<String>>, crate::GatewayError> {
let Some(id) = crate::handlers::admin::shared::query_param_value(query, "user_group_id") else {
return Ok(None);
};
if id == UNGROUPED_USAGE_ID {
return Ok(None);
}
let Some(group) = state.find_user_group_by_id(&id).await? else {
return Ok(Some(Vec::new()));
};
usage_group_provider_names(state, &group).await
}
@@ -169,9 +169,11 @@ pub(super) async fn build_admin_monitoring_system_status_response(
let today_usage = state
.summarize_usage_audits(&UsageAuditSummaryQuery {
provider_names: None,
created_from_unix_secs: today_start.timestamp().max(0) as u64,
created_until_unix_secs: now_unix_secs.saturating_add(1),
user_id: None,
user_ids: None,
provider_name: None,
model: None,
})
@@ -1,3 +1,4 @@
use super::super::resolve_usage_user_group_scope;
use super::range::{build_comparison_range, parse_bounded_u32};
use super::resolve_admin_usage_time_range;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -109,6 +110,7 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
};
let current_summary = state
.summarize_usage_audits(&UsageAuditSummaryQuery {
provider_names: None,
created_from_unix_secs: current_from_unix_secs,
created_until_unix_secs: current_until_unix_secs,
..Default::default()
@@ -116,6 +118,7 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
.await?;
let comparison_summary = state
.summarize_usage_audits(&UsageAuditSummaryQuery {
provider_names: None,
created_from_unix_secs: comparison_from_unix_secs,
created_until_unix_secs: comparison_until_unix_secs,
..Default::default()
@@ -294,6 +297,17 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
}
let filters = AdminStatsUsageFilter::from_query(request_context.query_string());
let user_ids = match resolve_usage_user_group_scope(
state,
request_context.query_string(),
false,
false,
)
.await?
{
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let query_granularity = match granularity {
AdminStatsGranularity::Hour => UsageTimeSeriesGranularity::Hour,
AdminStatsGranularity::Day
@@ -306,11 +320,17 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
};
let buckets = state
.summarize_usage_time_series(&UsageTimeSeriesQuery {
provider_names: super::super::resolve_usage_group_provider_names(
state,
request_context.query_string(),
)
.await?,
created_from_unix_secs,
created_until_unix_secs,
granularity: query_granularity,
tz_offset_minutes: time_range.tz_offset_minutes,
user_id: filters.user_id,
user_ids,
provider_name: filters.provider_name,
model: filters.model,
})
@@ -72,11 +72,13 @@ pub(super) async fn maybe_build_local_admin_stats_cost_response(
};
let buckets = state
.summarize_usage_time_series(&UsageTimeSeriesQuery {
provider_names: None,
created_from_unix_secs,
created_until_unix_secs,
granularity: UsageTimeSeriesGranularity::Day,
tz_offset_minutes: time_range.tz_offset_minutes,
user_id: None,
user_ids: None,
provider_name: None,
model: None,
})
@@ -3,12 +3,13 @@ use crate::GatewayError;
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
pub(super) use aether_admin::observability::stats::{
build_admin_stats_leaderboard_response, build_api_key_leaderboard_items,
build_api_key_leaderboard_items_from_summaries, build_model_leaderboard_items,
build_model_leaderboard_items_from_summaries, build_user_leaderboard_items,
build_user_leaderboard_items_from_summaries, compare_leaderboard_items, compute_dense_rank,
AdminStatsLeaderboardItem, AdminStatsLeaderboardMetric, AdminStatsLeaderboardNameMode,
AdminStatsSortOrder, AdminStatsUserMetadata,
build_admin_stats_leaderboard_response, build_admin_stats_user_group_leaderboard_response,
build_api_key_leaderboard_items, build_api_key_leaderboard_items_from_summaries,
build_model_leaderboard_items, build_model_leaderboard_items_from_summaries,
build_user_leaderboard_items, build_user_leaderboard_items_from_summaries,
compare_leaderboard_items, compute_dense_rank, AdminStatsLeaderboardItem,
AdminStatsLeaderboardMetric, AdminStatsLeaderboardNameMode, AdminStatsSortOrder,
AdminStatsUserMetadata,
};
pub(super) async fn load_user_leaderboard_metadata(
@@ -1,7 +1,9 @@
use super::super::resolve_usage_user_group_scope;
use super::leaderboard::{
build_admin_stats_leaderboard_response, build_api_key_leaderboard_items_from_summaries,
build_model_leaderboard_items_from_summaries, build_user_leaderboard_items_from_summaries,
compare_leaderboard_items, load_user_leaderboard_metadata, AdminStatsLeaderboardNameMode,
build_admin_stats_leaderboard_response, build_admin_stats_user_group_leaderboard_response,
build_api_key_leaderboard_items_from_summaries, build_model_leaderboard_items_from_summaries,
build_user_leaderboard_items_from_summaries, compare_leaderboard_items,
load_user_leaderboard_metadata, AdminStatsLeaderboardItem, AdminStatsLeaderboardNameMode,
};
use super::range::{parse_bounded_u32, parse_nonnegative_usize};
use super::resolve_admin_usage_time_range;
@@ -14,6 +16,7 @@ use aether_admin::observability::stats::{
};
use aether_data_contracts::repository::usage::{UsageLeaderboardGroupBy, UsageLeaderboardQuery};
use axum::{body::Body, http, response::Response};
use std::collections::{BTreeMap, BTreeSet};
pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
state: &AdminAppState<'_>,
@@ -75,10 +78,12 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
};
let summaries = state
.summarize_usage_leaderboard(&UsageLeaderboardQuery {
provider_names: None,
created_from_unix_secs,
created_until_unix_secs,
group_by: UsageLeaderboardGroupBy::Model,
user_id: filters.user_id,
user_ids: None,
provider_name: filters.provider_name,
model: filters.model,
})
@@ -152,10 +157,12 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
};
let summaries = state
.summarize_usage_leaderboard(&UsageLeaderboardQuery {
provider_names: None,
created_from_unix_secs,
created_until_unix_secs,
group_by: UsageLeaderboardGroupBy::ApiKey,
user_id: filters.user_id,
user_ids: None,
provider_name: filters.provider_name,
model: filters.model,
})
@@ -206,6 +213,181 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
)));
}
if request_context
.decision()
.and_then(|decision| decision.route_kind.as_deref())
== Some("leaderboard_user_groups")
&& request_context.method() == http::Method::GET
&& matches!(
request_context.path(),
"/api/admin/stats/leaderboard/user-groups"
| "/api/admin/stats/leaderboard/user-groups/"
)
{
let time_range = match resolve_admin_usage_time_range(query) {
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let metric = match AdminStatsLeaderboardMetric::parse(query) {
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let order = match AdminStatsSortOrder::parse(query) {
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let limit = match query_param_value(query, "limit")
.map(|value| parse_bounded_u32("limit", &value, 1, 100))
.transpose()
{
Ok(Some(value)) => value as usize,
Ok(None) => 10,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let offset = match query_param_value(query, "offset")
.map(|value| parse_nonnegative_usize("offset", &value))
.transpose()
{
Ok(Some(value)) => value,
Ok(None) => 0,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let empty_counts = BTreeMap::new();
if !state.has_usage_data_reader() || !state.has_user_data_reader() {
return Ok(Some(build_admin_stats_user_group_leaderboard_response(
metric,
Some(&time_range),
&[],
&empty_counts,
&empty_counts,
offset,
limit,
)));
}
let include_inactive = query_param_bool(query, "include_inactive", false);
let exclude_admin = query_param_bool(query, "exclude_admin", false);
let filters = AdminStatsUsageFilter::from_query(query);
if filters.user_id.is_some() {
return Ok(Some(admin_stats_bad_request_response(
"user_id is not supported for the user group leaderboard".to_string(),
)));
}
let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds()
else {
return Ok(Some(build_admin_stats_user_group_leaderboard_response(
metric,
Some(&time_range),
&[],
&empty_counts,
&empty_counts,
offset,
limit,
)));
};
let mut leaderboard = Vec::new();
let mut member_counts = BTreeMap::new();
let mut active_member_counts = BTreeMap::new();
for group in state.list_user_groups().await? {
let members = state.list_user_group_members(&group.id).await?;
let member_count = members.iter().filter(|member| !member.is_deleted).count();
let active_member_count = members
.iter()
.filter(|member| !member.is_deleted && member.is_active)
.count();
let user_ids = members
.iter()
.filter(|member| !member.is_deleted)
.filter(|member| include_inactive || member.is_active)
.filter(|member| !exclude_admin || !member.role.eq_ignore_ascii_case("admin"))
.map(|member| member.user_id.clone())
.collect::<Vec<_>>();
let summaries = state
.summarize_usage_leaderboard(&UsageLeaderboardQuery {
created_from_unix_secs,
created_until_unix_secs,
group_by: UsageLeaderboardGroupBy::User,
user_id: None,
user_ids: Some(user_ids),
provider_names: super::super::usage_group_provider_names(state, &group).await?,
provider_name: filters.provider_name.clone(),
model: filters.model.clone(),
})
.await?;
let user_ids = summaries
.iter()
.map(|row| row.group_key.clone())
.collect::<Vec<_>>();
let metadata = load_user_leaderboard_metadata(state, &user_ids).await?;
let users = build_user_leaderboard_items_from_summaries(
&summaries,
&metadata,
state.has_auth_user_data_reader(),
state.has_user_data_reader(),
include_inactive,
exclude_admin,
);
let mut item = AdminStatsLeaderboardItem {
id: group.id.clone(),
name: group.name,
requests: 0,
tokens: 0,
cost: 0.0,
};
for user in users {
item.requests = item.requests.saturating_add(user.requests);
item.tokens = item.tokens.saturating_add(user.tokens);
item.cost += user.cost;
}
member_counts.insert(group.id.clone(), member_count);
active_member_counts.insert(group.id, active_member_count);
leaderboard.push(item);
}
let ungrouped = super::super::ungrouped_usage_users(state).await?;
let id = super::super::UNGROUPED_USAGE_ID.to_string();
member_counts.insert(id.clone(), ungrouped.len());
active_member_counts.insert(
id.clone(),
ungrouped.iter().filter(|user| user.is_active).count(),
);
let user_ids = ungrouped
.into_iter()
.filter(|user| include_inactive || user.is_active)
.filter(|user| !exclude_admin || !user.role.eq_ignore_ascii_case("admin"))
.map(|user| user.id)
.collect();
let rows = state
.summarize_usage_leaderboard(&UsageLeaderboardQuery {
created_from_unix_secs,
created_until_unix_secs,
group_by: UsageLeaderboardGroupBy::User,
user_id: None,
user_ids: Some(user_ids),
provider_names: None,
provider_name: filters.provider_name.clone(),
model: filters.model.clone(),
})
.await?;
leaderboard.push(AdminStatsLeaderboardItem {
id,
name: "Ungrouped".to_string(),
requests: rows.iter().map(|row| row.request_count).sum(),
tokens: rows.iter().map(|row| row.total_tokens).sum(),
cost: rows.iter().map(|row| row.total_cost_usd).sum(),
});
leaderboard.sort_by(|left, right| compare_leaderboard_items(metric, order, left, right));
return Ok(Some(build_admin_stats_user_group_leaderboard_response(
metric,
Some(&time_range),
&leaderboard,
&member_counts,
&active_member_counts,
offset,
limit,
)));
}
if request_context
.decision()
.and_then(|decision| decision.route_kind.as_deref())
@@ -253,6 +435,13 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
let include_inactive = query_param_bool(query, "include_inactive", false);
let exclude_admin = query_param_bool(query, "exclude_admin", false);
let filters = AdminStatsUsageFilter::from_query(query);
let scoped_user_ids =
match resolve_usage_user_group_scope(state, query, include_inactive, exclude_admin)
.await?
{
Ok(value) => value,
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
};
let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds()
else {
return Ok(Some(admin_stats_leaderboard_empty_response(
@@ -262,10 +451,13 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
};
let summaries = state
.summarize_usage_leaderboard(&UsageLeaderboardQuery {
provider_names: super::super::resolve_usage_group_provider_names(state, query)
.await?,
created_from_unix_secs,
created_until_unix_secs,
group_by: UsageLeaderboardGroupBy::User,
user_id: filters.user_id,
user_ids: scoped_user_ids,
provider_name: filters.provider_name,
model: filters.model,
})
@@ -1,3 +1,4 @@
use super::super::resolve_usage_user_group_scope;
use super::super::stats::resolve_admin_usage_time_range;
use super::analytics::admin_usage_api_key_names;
use super::analytics::admin_usage_provider_key_names;
@@ -121,21 +122,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,
}
}
@@ -190,25 +200,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(),
}
}
})
@@ -477,6 +514,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,
}
}
@@ -548,6 +587,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();
@@ -799,11 +841,18 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
&Default::default(),
)));
};
let user_ids = match resolve_usage_user_group_scope(state, query, false, false).await? {
Ok(value) => value,
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
};
let summary = state
.summarize_usage_audits(&UsageAuditSummaryQuery {
provider_names: super::super::resolve_usage_group_provider_names(state, query)
.await?,
created_from_unix_secs,
created_until_unix_secs,
user_id: query_param_value(query, "user_id"),
user_ids,
provider_name: query_param_value(query, "provider"),
model: query_param_value(query, "model"),
})
@@ -1202,9 +1251,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,
@@ -1246,6 +1297,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(