mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 12:09:50 +08:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user