mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
refactor: 大规模模块拆分与重组,新增 aether-admin crate
- 新建独立 aether-admin crate 承载 admin 相关共享契约与纯辅助函数 - 拆分 ai_pipeline 下 kiro/private_envelope/conversion/planner 等大文件为子模块目录 - 重组 admin handlers 各业务域(billing/oauth/provider/system/users 等)为目录结构,移除 shared.rs/builders.rs 等反模式 - 移除 ai_pipeline runtime adapters 旧实现(claude/openai/gemini/kiro/vertex/antigravity 等),改由 provider transport 统一承载 - 移除 control_facade/execution_facade/auth_snapshot_facade 等冗余 facade 层 - 拆分 query/billing 与 query/monitoring 模块、state/runtime/payments 与 security 模块 - 扩展架构测试覆盖 admin_billing/admin_model/admin_users 等新模块 - 删除 docs/architecture/refactor-execution-plan.md 已完成的执行计划文档
This commit is contained in:
@@ -1,777 +0,0 @@
|
||||
use super::super::stats::{parse_bounded_u32, round_to};
|
||||
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(super) fn admin_usage_total_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
||||
item.input_tokens
|
||||
.saturating_add(item.output_tokens)
|
||||
.saturating_add(item.cache_creation_input_tokens)
|
||||
.saturating_add(item.cache_read_input_tokens)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_parse_limit(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "limit") {
|
||||
None => Ok(100),
|
||||
Some(value) => {
|
||||
let parsed = value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "limit must be a positive integer".to_string())?;
|
||||
if parsed == 0 || parsed > 500 {
|
||||
return Err("limit must be between 1 and 500".to_string());
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_parse_offset(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "offset") {
|
||||
None => Ok(0),
|
||||
Some(value) => value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "offset must be a non-negative integer".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_parse_ids(query: Option<&str>) -> Option<BTreeSet<String>> {
|
||||
let ids = query_param_value(query, "ids")?;
|
||||
let parsed: BTreeSet<String> = ids
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
Some(parsed)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_parse_recent_hours(
|
||||
query: Option<&str>,
|
||||
default: u32,
|
||||
) -> Result<u32, String> {
|
||||
match query_param_value(query, "hours") {
|
||||
Some(value) => parse_bounded_u32("hours", &value, 1, 720),
|
||||
None => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_parse_timeline_limit(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "limit") {
|
||||
Some(value) => {
|
||||
let parsed = value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "limit must be an integer between 100 and 50000".to_string())?;
|
||||
if (100..=50_000).contains(&parsed) {
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err("limit must be an integer between 100 and 50000".to_string())
|
||||
}
|
||||
}
|
||||
None => Ok(3_000),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_parse_aggregation_limit(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "limit") {
|
||||
Some(value) => {
|
||||
let parsed = value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "limit must be an integer between 1 and 100".to_string())?;
|
||||
if (1..=100).contains(&parsed) {
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err("limit must be an integer between 1 and 100".to_string())
|
||||
}
|
||||
}
|
||||
None => Ok(20),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_token_cache_hit_rate(input_tokens: u64, cache_read_tokens: u64) -> f64 {
|
||||
let total_input_context = input_tokens.saturating_add(cache_read_tokens);
|
||||
if total_input_context == 0 {
|
||||
0.0
|
||||
} else {
|
||||
round_to(
|
||||
cache_read_tokens as f64 / total_input_context as f64 * 100.0,
|
||||
2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_aggregation_by_model_json(
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
limit: usize,
|
||||
) -> serde_json::Value {
|
||||
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64)> = BTreeMap::new();
|
||||
for item in usage {
|
||||
let key = item.model.clone();
|
||||
let entry = grouped.entry(key).or_insert((0, 0, 0, 0, 0.0, 0.0));
|
||||
entry.0 = entry.0.saturating_add(1);
|
||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||
entry.2 = entry.2.saturating_add(item.input_tokens);
|
||||
entry.3 = entry.3.saturating_add(item.cache_read_input_tokens);
|
||||
entry.4 += item.total_cost_usd;
|
||||
entry.5 += item.actual_total_cost_usd;
|
||||
}
|
||||
|
||||
let mut items: Vec<serde_json::Value> = grouped
|
||||
.into_iter()
|
||||
.map(
|
||||
|(model, (request_count, total_tokens, input_tokens, cache_read_tokens, total_cost, actual_cost))| {
|
||||
json!({
|
||||
"model": model,
|
||||
"request_count": request_count,
|
||||
"total_tokens": total_tokens,
|
||||
"total_input_context": input_tokens.saturating_add(cache_read_tokens),
|
||||
"output_tokens": total_tokens.saturating_sub(input_tokens),
|
||||
"total_cost": round_to(total_cost, 6),
|
||||
"actual_cost": round_to(actual_cost, 6),
|
||||
"cache_read_tokens": cache_read_tokens,
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(input_tokens, cache_read_tokens),
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
items.sort_by(|left, right| {
|
||||
right["request_count"]
|
||||
.as_u64()
|
||||
.unwrap_or_default()
|
||||
.cmp(&left["request_count"].as_u64().unwrap_or_default())
|
||||
.then_with(|| {
|
||||
left["model"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["model"].as_str().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
items.truncate(limit);
|
||||
json!(items)
|
||||
}
|
||||
|
||||
pub(super) async fn admin_usage_aggregation_by_user_json(
|
||||
state: &AppState,
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
limit: usize,
|
||||
) -> Result<serde_json::Value, GatewayError> {
|
||||
let mut grouped: BTreeMap<String, (u64, u64, f64)> = BTreeMap::new();
|
||||
for item in usage {
|
||||
let Some(user_id) = item.user_id.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let entry = grouped.entry(user_id.clone()).or_insert((0, 0, 0.0));
|
||||
entry.0 = entry.0.saturating_add(1);
|
||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||
entry.2 += item.total_cost_usd;
|
||||
}
|
||||
|
||||
let usernames = if state.has_user_data_reader() && !grouped.is_empty() {
|
||||
state
|
||||
.list_users_by_ids(&grouped.keys().cloned().collect::<Vec<_>>())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.id, (user.email, user.username)))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
|
||||
let mut items: Vec<serde_json::Value> = grouped
|
||||
.into_iter()
|
||||
.map(|(user_id, (request_count, total_tokens, total_cost))| {
|
||||
let (email, username) = usernames
|
||||
.get(&user_id)
|
||||
.cloned()
|
||||
.unwrap_or((None, String::new()));
|
||||
json!({
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"username": if username.is_empty() { serde_json::Value::Null } else { json!(username) },
|
||||
"request_count": request_count,
|
||||
"total_tokens": total_tokens,
|
||||
"total_cost": round_to(total_cost, 6),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
items.sort_by(|left, right| {
|
||||
right["request_count"]
|
||||
.as_u64()
|
||||
.unwrap_or_default()
|
||||
.cmp(&left["request_count"].as_u64().unwrap_or_default())
|
||||
.then_with(|| {
|
||||
left["user_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["user_id"].as_str().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
items.truncate(limit);
|
||||
Ok(json!(items))
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_aggregation_by_provider_json(
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
limit: usize,
|
||||
) -> serde_json::Value {
|
||||
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64, u64)> = BTreeMap::new();
|
||||
for item in usage {
|
||||
let key = item
|
||||
.provider_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let entry = grouped.entry(key).or_insert((0, 0, 0, 0, 0.0, 0.0, 0, 0));
|
||||
entry.0 = entry.0.saturating_add(1);
|
||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||
entry.2 = entry.2.saturating_add(item.input_tokens);
|
||||
entry.3 = entry.3.saturating_add(item.cache_read_input_tokens);
|
||||
entry.4 += item.total_cost_usd;
|
||||
entry.5 += item.actual_total_cost_usd;
|
||||
entry.6 = entry
|
||||
.6
|
||||
.saturating_add(item.response_time_ms.unwrap_or_default());
|
||||
entry.7 = entry
|
||||
.7
|
||||
.saturating_add(if admin_usage_is_success(item) { 1 } else { 0 });
|
||||
}
|
||||
|
||||
let mut items: Vec<serde_json::Value> = grouped
|
||||
.into_iter()
|
||||
.map(
|
||||
|(provider_id, (request_count, total_tokens, input_tokens, cache_read_tokens, total_cost, actual_cost, response_time_ms_sum, success_count))| {
|
||||
let avg_response_time_ms = if request_count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
round_to(response_time_ms_sum as f64 / request_count as f64, 2)
|
||||
};
|
||||
let error_count = request_count.saturating_sub(success_count);
|
||||
let success_rate = if request_count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
round_to(success_count as f64 / request_count as f64 * 100.0, 2)
|
||||
};
|
||||
json!({
|
||||
"provider_id": provider_id,
|
||||
"provider": serde_json::Value::Null,
|
||||
"request_count": request_count,
|
||||
"total_tokens": total_tokens,
|
||||
"total_input_context": input_tokens.saturating_add(cache_read_tokens),
|
||||
"output_tokens": total_tokens.saturating_sub(input_tokens),
|
||||
"total_cost": round_to(total_cost, 6),
|
||||
"actual_cost": round_to(actual_cost, 6),
|
||||
"avg_response_time_ms": avg_response_time_ms,
|
||||
"success_rate": success_rate,
|
||||
"error_count": error_count,
|
||||
"cache_read_tokens": cache_read_tokens,
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(input_tokens, cache_read_tokens),
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
items.sort_by(|left, right| {
|
||||
right["request_count"]
|
||||
.as_u64()
|
||||
.unwrap_or_default()
|
||||
.cmp(&left["request_count"].as_u64().unwrap_or_default())
|
||||
.then_with(|| {
|
||||
left["provider_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["provider_id"].as_str().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
items.truncate(limit);
|
||||
json!(items)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_aggregation_by_api_format_json(
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
limit: usize,
|
||||
) -> serde_json::Value {
|
||||
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64)> = BTreeMap::new();
|
||||
for item in usage {
|
||||
let key = item
|
||||
.api_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let entry = grouped.entry(key).or_insert((0, 0, 0, 0, 0.0, 0.0, 0));
|
||||
entry.0 = entry.0.saturating_add(1);
|
||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||
entry.2 = entry.2.saturating_add(item.input_tokens);
|
||||
entry.3 = entry.3.saturating_add(item.cache_read_input_tokens);
|
||||
entry.4 += item.total_cost_usd;
|
||||
entry.5 += item.actual_total_cost_usd;
|
||||
entry.6 = entry
|
||||
.6
|
||||
.saturating_add(item.response_time_ms.unwrap_or_default());
|
||||
}
|
||||
|
||||
let mut items: Vec<serde_json::Value> = grouped
|
||||
.into_iter()
|
||||
.map(
|
||||
|(api_format, (request_count, total_tokens, input_tokens, cache_read_tokens, total_cost, actual_cost, response_time_ms_sum))| {
|
||||
let avg_response_time_ms = if request_count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
round_to(response_time_ms_sum as f64 / request_count as f64, 2)
|
||||
};
|
||||
json!({
|
||||
"api_format": api_format,
|
||||
"request_count": request_count,
|
||||
"total_tokens": total_tokens,
|
||||
"total_input_context": input_tokens.saturating_add(cache_read_tokens),
|
||||
"output_tokens": total_tokens.saturating_sub(input_tokens),
|
||||
"total_cost": round_to(total_cost, 6),
|
||||
"actual_cost": round_to(actual_cost, 6),
|
||||
"avg_response_time_ms": avg_response_time_ms,
|
||||
"cache_read_tokens": cache_read_tokens,
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(input_tokens, cache_read_tokens),
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
items.sort_by(|left, right| {
|
||||
right["request_count"]
|
||||
.as_u64()
|
||||
.unwrap_or_default()
|
||||
.cmp(&left["request_count"].as_u64().unwrap_or_default())
|
||||
.then_with(|| {
|
||||
left["api_format"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["api_format"].as_str().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
items.truncate(limit);
|
||||
json!(items)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_heatmap_json(usage: &[StoredRequestUsageAudit]) -> serde_json::Value {
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let start_date = today
|
||||
.checked_sub_signed(chrono::Duration::days(364))
|
||||
.unwrap_or(today);
|
||||
let mut grouped: BTreeMap<chrono::NaiveDate, (u64, u64, f64, f64)> = BTreeMap::new();
|
||||
for item in usage {
|
||||
let Ok(created_at_unix_secs) = i64::try_from(item.created_at_unix_secs) else {
|
||||
continue;
|
||||
};
|
||||
let Some(created_at) =
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(created_at_unix_secs, 0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let date_key = created_at.date_naive();
|
||||
if date_key < start_date || date_key > today {
|
||||
continue;
|
||||
}
|
||||
let entry = grouped.entry(date_key).or_insert((0, 0, 0.0, 0.0));
|
||||
entry.0 = entry.0.saturating_add(1);
|
||||
entry.1 = entry.1.saturating_add(admin_usage_total_tokens(item));
|
||||
entry.2 += item.total_cost_usd;
|
||||
entry.3 += item.actual_total_cost_usd;
|
||||
}
|
||||
|
||||
let mut max_requests = 0_u64;
|
||||
let mut cursor = start_date;
|
||||
let mut days = Vec::new();
|
||||
while cursor <= today {
|
||||
let (requests, total_tokens, total_cost, actual_total_cost) =
|
||||
grouped.get(&cursor).copied().unwrap_or((0, 0, 0.0, 0.0));
|
||||
max_requests = max_requests.max(requests);
|
||||
days.push(json!({
|
||||
"date": cursor.to_string(),
|
||||
"requests": requests,
|
||||
"total_tokens": total_tokens,
|
||||
"total_cost": round_to(total_cost, 6),
|
||||
"actual_total_cost": round_to(actual_total_cost, 6),
|
||||
}));
|
||||
cursor = cursor
|
||||
.checked_add_signed(chrono::Duration::days(1))
|
||||
.unwrap_or(today + chrono::Duration::days(1));
|
||||
}
|
||||
|
||||
json!({
|
||||
"start_date": start_date.to_string(),
|
||||
"end_date": today.to_string(),
|
||||
"total_days": days.len(),
|
||||
"max_requests": max_requests,
|
||||
"days": days,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_is_success(item: &StoredRequestUsageAudit) -> bool {
|
||||
matches!(
|
||||
item.status.as_str(),
|
||||
"completed" | "success" | "ok" | "billed" | "settled"
|
||||
) && item.status_code.is_none_or(|code| code < 400)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_matches_optional_id(value: Option<&str>, expected: Option<&str>) -> bool {
|
||||
let Some(expected) = expected.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
value.is_some_and(|candidate| candidate == expected)
|
||||
}
|
||||
|
||||
pub(super) async fn list_recent_completed_usage_for_cache_affinity(
|
||||
state: &AppState,
|
||||
hours: u32,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<Vec<StoredRequestUsageAudit>, GatewayError> {
|
||||
let now_unix_secs = u64::try_from(chrono::Utc::now().timestamp()).unwrap_or_default();
|
||||
let created_from_unix_secs = now_unix_secs.saturating_sub(u64::from(hours) * 3600);
|
||||
let mut items = state
|
||||
.list_usage_audits(&UsageAuditListQuery {
|
||||
created_from_unix_secs: Some(created_from_unix_secs),
|
||||
created_until_unix_secs: None,
|
||||
user_id: user_id.map(ToOwned::to_owned),
|
||||
provider_name: None,
|
||||
model: None,
|
||||
})
|
||||
.await?;
|
||||
items.retain(|item| item.status == "completed");
|
||||
items.sort_by(|left, right| {
|
||||
left.created_at_unix_secs
|
||||
.cmp(&right.created_at_unix_secs)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_group_completed_by_user(
|
||||
items: &[StoredRequestUsageAudit],
|
||||
) -> BTreeMap<String, Vec<StoredRequestUsageAudit>> {
|
||||
let mut grouped = BTreeMap::new();
|
||||
for item in items.iter().filter(|item| item.user_id.is_some()) {
|
||||
grouped
|
||||
.entry(item.user_id.clone().unwrap_or_default())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(item.clone());
|
||||
}
|
||||
grouped
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_group_completed_by_api_key(
|
||||
items: &[StoredRequestUsageAudit],
|
||||
api_key_id: Option<&str>,
|
||||
) -> BTreeMap<String, Vec<StoredRequestUsageAudit>> {
|
||||
let mut grouped = BTreeMap::new();
|
||||
for item in items.iter().filter(|item| item.api_key_id.is_some()) {
|
||||
if !admin_usage_matches_optional_id(item.api_key_id.as_deref(), api_key_id) {
|
||||
continue;
|
||||
}
|
||||
grouped
|
||||
.entry(item.api_key_id.clone().unwrap_or_default())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(item.clone());
|
||||
}
|
||||
grouped
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_collect_request_intervals_minutes(
|
||||
items: &[StoredRequestUsageAudit],
|
||||
) -> Vec<f64> {
|
||||
let mut previous_created_at_unix_secs = None;
|
||||
let mut intervals = Vec::new();
|
||||
for item in items {
|
||||
if let Some(previous) = previous_created_at_unix_secs {
|
||||
intervals.push(item.created_at_unix_secs.saturating_sub(previous) as f64 / 60.0);
|
||||
}
|
||||
previous_created_at_unix_secs = Some(item.created_at_unix_secs);
|
||||
}
|
||||
intervals
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_percentile_cont(values: &[f64], percentile: f64) -> Option<f64> {
|
||||
if values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if values.len() == 1 {
|
||||
return Some(values[0]);
|
||||
}
|
||||
let position = percentile.clamp(0.0, 1.0) * (values.len() - 1) as f64;
|
||||
let lower_index = position.floor() as usize;
|
||||
let upper_index = position.ceil() as usize;
|
||||
let lower = values[lower_index];
|
||||
let upper = values[upper_index];
|
||||
Some(lower + (upper - lower) * (position - lower_index as f64))
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_calculate_recommended_ttl(
|
||||
p75_interval: Option<f64>,
|
||||
p90_interval: Option<f64>,
|
||||
) -> u64 {
|
||||
let Some(p75_interval) = p75_interval else {
|
||||
return 5;
|
||||
};
|
||||
let Some(p90_interval) = p90_interval else {
|
||||
return 5;
|
||||
};
|
||||
|
||||
if p90_interval <= 5.0 {
|
||||
5
|
||||
} else if p75_interval <= 15.0 {
|
||||
15
|
||||
} else if p75_interval <= 30.0 {
|
||||
30
|
||||
} else {
|
||||
60
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_ttl_recommendation_reason(
|
||||
ttl: u64,
|
||||
p75_interval: Option<f64>,
|
||||
p90_interval: Option<f64>,
|
||||
) -> String {
|
||||
let Some(p75_interval) = p75_interval else {
|
||||
return "数据不足,使用默认值".to_string();
|
||||
};
|
||||
let Some(p90_interval) = p90_interval else {
|
||||
return "数据不足,使用默认值".to_string();
|
||||
};
|
||||
|
||||
match ttl {
|
||||
5 => format!("高频用户:90% 的请求间隔在 {:.1} 分钟内", p90_interval),
|
||||
15 => format!("中高频用户:75% 的请求间隔在 {:.1} 分钟内", p75_interval),
|
||||
30 => format!("中频用户:75% 的请求间隔在 {:.1} 分钟内", p75_interval),
|
||||
_ => format!(
|
||||
"低频用户:75% 的请求间隔为 {:.1} 分钟,建议使用长 TTL",
|
||||
p75_interval
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_proportional_limits(
|
||||
grouped: &BTreeMap<String, Vec<serde_json::Value>>,
|
||||
limit: usize,
|
||||
total_points: usize,
|
||||
) -> BTreeMap<String, usize> {
|
||||
let mut limits = BTreeMap::new();
|
||||
for (group_id, items) in grouped {
|
||||
let computed = if total_points <= limit || total_points == 0 {
|
||||
items.len()
|
||||
} else {
|
||||
let scaled =
|
||||
((items.len() as f64 * limit as f64) / total_points as f64).ceil() as usize;
|
||||
std::cmp::max(scaled, 1)
|
||||
};
|
||||
limits.insert(group_id.clone(), computed);
|
||||
}
|
||||
limits
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_point_sort_key(
|
||||
left: &serde_json::Value,
|
||||
right: &serde_json::Value,
|
||||
) -> std::cmp::Ordering {
|
||||
left["x"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["x"].as_str().unwrap_or_default())
|
||||
.then_with(|| {
|
||||
left["user_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["user_id"].as_str().unwrap_or_default())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_matches_search(
|
||||
item: &StoredRequestUsageAudit,
|
||||
search: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(search) = search.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
let haystack = [
|
||||
item.username.as_deref(),
|
||||
item.api_key_name.as_deref(),
|
||||
Some(item.model.as_str()),
|
||||
Some(item.provider_name.as_str()),
|
||||
];
|
||||
search.split_whitespace().all(|keyword| {
|
||||
let keyword = keyword.to_ascii_lowercase();
|
||||
haystack
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|value| value.to_ascii_lowercase().contains(keyword.as_str()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_matches_username(
|
||||
item: &StoredRequestUsageAudit,
|
||||
username: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(username) = username.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
item.username
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.contains(username.to_ascii_lowercase().as_str())
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_matches_eq(value: &str, query: Option<&str>) -> bool {
|
||||
let Some(query) = query
|
||||
.map(str::trim)
|
||||
.filter(|candidate| !candidate.is_empty())
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
value.eq_ignore_ascii_case(query)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_matches_api_format(
|
||||
item: &StoredRequestUsageAudit,
|
||||
api_format: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(api_format) = api_format.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
item.api_format
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(api_format))
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_matches_status(
|
||||
item: &StoredRequestUsageAudit,
|
||||
status: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(status) = status.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
match status {
|
||||
"stream" => item.is_stream,
|
||||
"standard" => !item.is_stream,
|
||||
"error" => {
|
||||
item.status_code.is_some_and(|value| value >= 400) || item.error_message.is_some()
|
||||
}
|
||||
"pending" | "streaming" | "completed" | "cancelled" => item.status == status,
|
||||
"failed" => {
|
||||
item.status == "failed"
|
||||
|| item.status_code.is_some_and(|value| value >= 400)
|
||||
|| item.error_message.is_some()
|
||||
}
|
||||
"active" => matches!(item.status.as_str(), "pending" | "streaming"),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn admin_usage_provider_key_names(
|
||||
state: &AppState,
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
) -> Result<BTreeMap<String, String>, GatewayError> {
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
|
||||
let key_ids = usage
|
||||
.iter()
|
||||
.filter_map(|item| item.provider_api_key_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
if key_ids.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
|
||||
Ok(state
|
||||
.list_provider_catalog_keys_by_ids(&key_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|key| (key.id, key.name))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn admin_usage_request_metadata_string(
|
||||
item: &StoredRequestUsageAudit,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
item.request_metadata
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get(key))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_provider_key_name(
|
||||
item: &StoredRequestUsageAudit,
|
||||
provider_key_names: &BTreeMap<String, String>,
|
||||
) -> Option<String> {
|
||||
item.provider_api_key_id
|
||||
.as_ref()
|
||||
.and_then(|key_id| provider_key_names.get(key_id))
|
||||
.cloned()
|
||||
.or_else(|| admin_usage_request_metadata_string(item, "key_name"))
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_record_json(
|
||||
item: &StoredRequestUsageAudit,
|
||||
users_by_id: &BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
|
||||
provider_key_name: Option<&str>,
|
||||
) -> Value {
|
||||
let user = item
|
||||
.user_id
|
||||
.as_ref()
|
||||
.and_then(|user_id| users_by_id.get(user_id));
|
||||
let username = user
|
||||
.map(|value| value.username.clone())
|
||||
.or_else(|| item.username.clone())
|
||||
.unwrap_or_else(|| "已删除用户".to_string());
|
||||
let user_email = user
|
||||
.and_then(|value| value.email.clone())
|
||||
.unwrap_or_else(|| "已删除用户".to_string());
|
||||
|
||||
json!({
|
||||
"id": item.id,
|
||||
"user_id": item.user_id,
|
||||
"user_email": user_email,
|
||||
"username": username,
|
||||
"api_key": item.api_key_id.as_ref().map(|api_key_id| json!({
|
||||
"id": api_key_id,
|
||||
"name": item.api_key_name.clone(),
|
||||
"display": item.api_key_name.clone().unwrap_or_else(|| api_key_id.clone()),
|
||||
})),
|
||||
"provider": item.provider_name,
|
||||
"model": item.model,
|
||||
"target_model": item.target_model,
|
||||
"input_tokens": item.input_tokens,
|
||||
"output_tokens": item.output_tokens,
|
||||
"cache_creation_input_tokens": item.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||
"total_tokens": admin_usage_total_tokens(item),
|
||||
"cost": round_to(item.total_cost_usd, 6),
|
||||
"actual_cost": round_to(item.actual_total_cost_usd, 6),
|
||||
"rate_multiplier": Value::Null,
|
||||
"response_time_ms": item.response_time_ms,
|
||||
"first_byte_time_ms": item.first_byte_time_ms,
|
||||
"created_at": unix_secs_to_rfc3339(item.created_at_unix_secs),
|
||||
"is_stream": item.is_stream,
|
||||
"input_price_per_1m": Value::Null,
|
||||
"output_price_per_1m": item.output_price_per_1m,
|
||||
"cache_creation_price_per_1m": Value::Null,
|
||||
"cache_read_price_per_1m": Value::Null,
|
||||
"status_code": item.status_code,
|
||||
"error_message": item.error_message,
|
||||
"status": item.status,
|
||||
"has_fallback": false,
|
||||
"has_retry": false,
|
||||
"has_rectified": false,
|
||||
"api_format": item.api_format,
|
||||
"endpoint_api_format": item.endpoint_api_format,
|
||||
"has_format_conversion": item.has_format_conversion,
|
||||
"api_key_name": item.api_key_name,
|
||||
"provider_key_name": provider_key_name,
|
||||
"model_version": Value::Null,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::GatewayError;
|
||||
use aether_admin::observability::stats::round_to;
|
||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(in super::super) async fn admin_usage_aggregation_by_user_json(
|
||||
state: &AdminAppState<'_>,
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
limit: usize,
|
||||
) -> Result<serde_json::Value, GatewayError> {
|
||||
let mut grouped: BTreeMap<String, (u64, u64, f64)> = BTreeMap::new();
|
||||
for item in usage {
|
||||
let Some(user_id) = item.user_id.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let entry = grouped.entry(user_id.clone()).or_insert((0, 0, 0.0));
|
||||
entry.0 = entry.0.saturating_add(1);
|
||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||
entry.2 += item.total_cost_usd;
|
||||
}
|
||||
|
||||
let usernames = if state.has_user_data_reader() && !grouped.is_empty() {
|
||||
state
|
||||
.list_users_by_ids(&grouped.keys().cloned().collect::<Vec<_>>())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.id, (user.email, user.username)))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
|
||||
let mut items: Vec<serde_json::Value> = grouped
|
||||
.into_iter()
|
||||
.map(|(user_id, (request_count, total_tokens, total_cost))| {
|
||||
let (email, username) = usernames
|
||||
.get(&user_id)
|
||||
.cloned()
|
||||
.unwrap_or((None, String::new()));
|
||||
json!({
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"username": if username.is_empty() { serde_json::Value::Null } else { json!(username) },
|
||||
"request_count": request_count,
|
||||
"total_tokens": total_tokens,
|
||||
"total_cost": round_to(total_cost, 6),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
items.sort_by(|left, right| {
|
||||
right["request_count"]
|
||||
.as_u64()
|
||||
.unwrap_or_default()
|
||||
.cmp(&left["request_count"].as_u64().unwrap_or_default())
|
||||
.then_with(|| {
|
||||
left["user_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["user_id"].as_str().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
items.truncate(limit);
|
||||
Ok(json!(items))
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::GatewayError;
|
||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery};
|
||||
|
||||
pub(in super::super) async fn list_recent_completed_usage_for_cache_affinity(
|
||||
state: &AdminAppState<'_>,
|
||||
hours: u32,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<Vec<StoredRequestUsageAudit>, GatewayError> {
|
||||
let now_unix_secs = u64::try_from(chrono::Utc::now().timestamp()).unwrap_or_default();
|
||||
let created_from_unix_secs = now_unix_secs.saturating_sub(u64::from(hours) * 3600);
|
||||
let mut items = state
|
||||
.list_usage_audits(&UsageAuditListQuery {
|
||||
created_from_unix_secs: Some(created_from_unix_secs),
|
||||
created_until_unix_secs: None,
|
||||
user_id: user_id.map(ToOwned::to_owned),
|
||||
provider_name: None,
|
||||
model: None,
|
||||
})
|
||||
.await?;
|
||||
items.retain(|item| item.status == "completed");
|
||||
items.sort_by(|left, right| {
|
||||
left.created_at_unix_secs
|
||||
.cmp(&right.created_at_unix_secs)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
Ok(items)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::GatewayError;
|
||||
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(in super::super) async fn admin_usage_provider_key_names(
|
||||
state: &AdminAppState<'_>,
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
) -> Result<BTreeMap<String, String>, GatewayError> {
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
|
||||
let key_ids = usage
|
||||
.iter()
|
||||
.filter_map(|item| item.provider_api_key_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
if key_ids.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
|
||||
Ok(state
|
||||
.list_provider_catalog_keys_by_ids(&key_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|key| (key.id, key.name))
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod aggregations;
|
||||
mod cache_affinity;
|
||||
mod filters;
|
||||
|
||||
pub(super) use aggregations::admin_usage_aggregation_by_user_json;
|
||||
pub(super) use cache_affinity::list_recent_completed_usage_for_cache_affinity;
|
||||
pub(super) use filters::admin_usage_provider_key_names;
|
||||
@@ -1,515 +0,0 @@
|
||||
use super::super::stats::{
|
||||
list_usage_for_optional_range, round_to, AdminStatsTimeRange, AdminStatsUsageFilter,
|
||||
};
|
||||
use super::analytics::{
|
||||
admin_usage_aggregation_by_api_format_json, admin_usage_aggregation_by_model_json,
|
||||
admin_usage_aggregation_by_provider_json, admin_usage_aggregation_by_user_json,
|
||||
admin_usage_calculate_recommended_ttl, admin_usage_collect_request_intervals_minutes,
|
||||
admin_usage_group_completed_by_api_key, admin_usage_group_completed_by_user,
|
||||
admin_usage_heatmap_json, admin_usage_matches_optional_id, admin_usage_parse_aggregation_limit,
|
||||
admin_usage_parse_recent_hours, admin_usage_parse_timeline_limit, admin_usage_percentile_cont,
|
||||
admin_usage_point_sort_key, admin_usage_proportional_limits,
|
||||
admin_usage_ttl_recommendation_reason, list_recent_completed_usage_for_cache_affinity,
|
||||
};
|
||||
use super::helpers::{
|
||||
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::{query_param_bool, query_param_value, unix_secs_to_rfc3339};
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::usage::UsageAuditListQuery;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_usage_analytics_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let route_kind = request_context
|
||||
.control_decision
|
||||
.as_ref()
|
||||
.and_then(|decision| decision.route_kind.as_deref());
|
||||
|
||||
match route_kind {
|
||||
Some("aggregation_stats")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/aggregation/stats" | "/api/admin/usage/aggregation/stats/"
|
||||
) =>
|
||||
{
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(Some(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
|
||||
let query = request_context.request_query_string.as_deref();
|
||||
let group_by = query_param_value(query, "group_by")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !matches!(
|
||||
group_by.as_str(),
|
||||
"model" | "user" | "provider" | "api_format"
|
||||
) {
|
||||
return Ok(Some(admin_usage_bad_request_response(
|
||||
"Invalid group_by value: must be one of model, user, provider, api_format",
|
||||
)));
|
||||
}
|
||||
let limit = match admin_usage_parse_aggregation_limit(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
|
||||
};
|
||||
let time_range = match AdminStatsTimeRange::resolve_optional(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
|
||||
};
|
||||
|
||||
let mut usage = list_usage_for_optional_range(
|
||||
state,
|
||||
time_range.as_ref(),
|
||||
&AdminStatsUsageFilter::default(),
|
||||
)
|
||||
.await?;
|
||||
usage.retain(|item| item.status != "pending" && item.status != "streaming");
|
||||
|
||||
let response = match group_by.as_str() {
|
||||
"model" => admin_usage_aggregation_by_model_json(&usage, limit),
|
||||
"user" => admin_usage_aggregation_by_user_json(state, &usage, limit).await?,
|
||||
"provider" => admin_usage_aggregation_by_provider_json(&usage, limit),
|
||||
"api_format" => admin_usage_aggregation_by_api_format_json(&usage, limit),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
return Ok(Some(Json(response).into_response()));
|
||||
}
|
||||
Some("heatmap")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/heatmap" | "/api/admin/usage/heatmap/"
|
||||
) =>
|
||||
{
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(Some(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
let now_unix_secs = u64::try_from(chrono::Utc::now().timestamp()).unwrap_or_default();
|
||||
let created_from_unix_secs = now_unix_secs.saturating_sub(365 * 24 * 3600);
|
||||
let mut usage = state
|
||||
.list_usage_audits(&UsageAuditListQuery {
|
||||
created_from_unix_secs: Some(created_from_unix_secs),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
usage.retain(|item| item.status != "pending" && item.status != "streaming");
|
||||
return Ok(Some(Json(admin_usage_heatmap_json(&usage)).into_response()));
|
||||
}
|
||||
Some("cache_affinity_hit_analysis")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/cache-affinity/hit-analysis"
|
||||
| "/api/admin/usage/cache-affinity/hit-analysis/"
|
||||
) =>
|
||||
{
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(Some(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
|
||||
let query = request_context.request_query_string.as_deref();
|
||||
let hours = match admin_usage_parse_recent_hours(query, 168) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
|
||||
};
|
||||
let user_id = query_param_value(query, "user_id");
|
||||
let api_key_id = query_param_value(query, "api_key_id");
|
||||
let usage =
|
||||
list_recent_completed_usage_for_cache_affinity(state, hours, user_id.as_deref())
|
||||
.await?;
|
||||
let filtered: Vec<_> = usage
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
admin_usage_matches_optional_id(item.user_id.as_deref(), user_id.as_deref())
|
||||
&& admin_usage_matches_optional_id(
|
||||
item.api_key_id.as_deref(),
|
||||
api_key_id.as_deref(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let total_requests = filtered.len();
|
||||
let total_input_tokens: u64 = filtered.iter().map(|item| item.input_tokens).sum();
|
||||
let total_cache_read_tokens: u64 = filtered
|
||||
.iter()
|
||||
.map(|item| item.cache_read_input_tokens)
|
||||
.sum();
|
||||
let total_cache_creation_tokens: u64 = filtered
|
||||
.iter()
|
||||
.map(|item| item.cache_creation_input_tokens)
|
||||
.sum();
|
||||
let total_cache_read_cost: f64 =
|
||||
filtered.iter().map(|item| item.cache_read_cost_usd).sum();
|
||||
let total_cache_creation_cost: f64 = filtered
|
||||
.iter()
|
||||
.map(|item| item.cache_creation_cost_usd)
|
||||
.sum();
|
||||
let requests_with_cache_hit = filtered
|
||||
.iter()
|
||||
.filter(|item| item.cache_read_input_tokens > 0)
|
||||
.count();
|
||||
let total_context_tokens = total_input_tokens.saturating_add(total_cache_read_tokens);
|
||||
let token_cache_hit_rate = if total_context_tokens == 0 {
|
||||
0.0
|
||||
} else {
|
||||
round_to(
|
||||
total_cache_read_tokens as f64 / total_context_tokens as f64 * 100.0,
|
||||
2,
|
||||
)
|
||||
};
|
||||
let request_cache_hit_rate = if total_requests == 0 {
|
||||
0.0
|
||||
} else {
|
||||
round_to(
|
||||
requests_with_cache_hit as f64 / total_requests as f64 * 100.0,
|
||||
2,
|
||||
)
|
||||
};
|
||||
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"analysis_period_hours": hours,
|
||||
"total_requests": total_requests,
|
||||
"requests_with_cache_hit": requests_with_cache_hit,
|
||||
"request_cache_hit_rate": request_cache_hit_rate,
|
||||
"total_input_tokens": total_input_tokens,
|
||||
"total_cache_read_tokens": total_cache_read_tokens,
|
||||
"total_cache_creation_tokens": total_cache_creation_tokens,
|
||||
"token_cache_hit_rate": token_cache_hit_rate,
|
||||
"total_cache_read_cost_usd": round_to(total_cache_read_cost, 4),
|
||||
"total_cache_creation_cost_usd": round_to(total_cache_creation_cost, 4),
|
||||
"estimated_savings_usd": round_to(total_cache_read_cost * 9.0, 4),
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
Some("cache_affinity_interval_timeline")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/cache-affinity/interval-timeline"
|
||||
| "/api/admin/usage/cache-affinity/interval-timeline/"
|
||||
) =>
|
||||
{
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(Some(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
|
||||
let query = request_context.request_query_string.as_deref();
|
||||
let hours = match admin_usage_parse_recent_hours(query, 24) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
|
||||
};
|
||||
let limit = match admin_usage_parse_timeline_limit(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
|
||||
};
|
||||
let user_id = query_param_value(query, "user_id");
|
||||
let include_user_info = query_param_bool(query, "include_user_info", false);
|
||||
let usage =
|
||||
list_recent_completed_usage_for_cache_affinity(state, hours, user_id.as_deref())
|
||||
.await?;
|
||||
let mut grouped: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
|
||||
let mut models = BTreeSet::new();
|
||||
let mut usernames_by_user_id = BTreeMap::new();
|
||||
|
||||
for (group_user_id, items) in admin_usage_group_completed_by_user(&usage) {
|
||||
if let Some(ref requested_user_id) = user_id {
|
||||
if &group_user_id != requested_user_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let mut previous_created_at_unix_secs = None;
|
||||
for item in items {
|
||||
if let Some(previous) = previous_created_at_unix_secs {
|
||||
let interval_minutes =
|
||||
item.created_at_unix_secs.saturating_sub(previous) as f64 / 60.0;
|
||||
if interval_minutes <= 120.0 {
|
||||
let mut point = json!({
|
||||
"x": unix_secs_to_rfc3339(item.created_at_unix_secs),
|
||||
"y": round_to(interval_minutes, 2),
|
||||
});
|
||||
if !item.model.trim().is_empty() {
|
||||
point["model"] = json!(item.model.clone());
|
||||
models.insert(item.model.clone());
|
||||
}
|
||||
if include_user_info && user_id.is_none() {
|
||||
point["user_id"] = json!(group_user_id.clone());
|
||||
if let Some(username) = item.username.clone() {
|
||||
usernames_by_user_id
|
||||
.entry(group_user_id.clone())
|
||||
.or_insert(username);
|
||||
}
|
||||
}
|
||||
grouped
|
||||
.entry(group_user_id.clone())
|
||||
.or_default()
|
||||
.push(point);
|
||||
}
|
||||
}
|
||||
previous_created_at_unix_secs = Some(item.created_at_unix_secs);
|
||||
}
|
||||
}
|
||||
|
||||
if include_user_info && user_id.is_none() && state.has_user_data_reader() {
|
||||
let user_ids: Vec<_> = grouped.keys().cloned().collect();
|
||||
let user_map: BTreeMap<_, _> = state
|
||||
.list_users_by_ids(&user_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.id, user.username))
|
||||
.collect();
|
||||
for (user_id, username) in user_map {
|
||||
usernames_by_user_id.insert(user_id, username);
|
||||
}
|
||||
}
|
||||
|
||||
let total_points_before_limit: usize = grouped.values().map(Vec::len).sum();
|
||||
let points: Vec<serde_json::Value> = if include_user_info && user_id.is_none() {
|
||||
let user_limits =
|
||||
admin_usage_proportional_limits(&grouped, limit, total_points_before_limit);
|
||||
let mut selected = Vec::new();
|
||||
for (group_user_id, mut items) in grouped {
|
||||
let take = user_limits
|
||||
.get(&group_user_id)
|
||||
.copied()
|
||||
.unwrap_or(items.len());
|
||||
selected.extend(items.drain(..std::cmp::min(take, items.len())));
|
||||
}
|
||||
selected.sort_by(admin_usage_point_sort_key);
|
||||
selected
|
||||
} else {
|
||||
let mut selected = grouped
|
||||
.into_values()
|
||||
.flatten()
|
||||
.collect::<Vec<serde_json::Value>>();
|
||||
selected.sort_by(admin_usage_point_sort_key);
|
||||
if selected.len() > limit {
|
||||
selected.truncate(limit);
|
||||
}
|
||||
selected
|
||||
};
|
||||
|
||||
let mut response = json!({
|
||||
"analysis_period_hours": hours,
|
||||
"total_points": points.len(),
|
||||
"points": points,
|
||||
});
|
||||
if include_user_info && user_id.is_none() {
|
||||
response["users"] = json!(usernames_by_user_id);
|
||||
}
|
||||
if !models.is_empty() {
|
||||
response["models"] = json!(models.into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
return Ok(Some(Json(response).into_response()));
|
||||
}
|
||||
Some("cache_affinity_ttl_analysis")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/cache-affinity/ttl-analysis"
|
||||
| "/api/admin/usage/cache-affinity/ttl-analysis/"
|
||||
) =>
|
||||
{
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(Some(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
|
||||
let query = request_context.request_query_string.as_deref();
|
||||
let hours = match admin_usage_parse_recent_hours(query, 168) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
|
||||
};
|
||||
let user_id = query_param_value(query, "user_id");
|
||||
let api_key_id = query_param_value(query, "api_key_id");
|
||||
let group_by_api_key = api_key_id.is_some();
|
||||
let usage =
|
||||
list_recent_completed_usage_for_cache_affinity(state, hours, user_id.as_deref())
|
||||
.await?;
|
||||
|
||||
let grouped = if group_by_api_key {
|
||||
admin_usage_group_completed_by_api_key(&usage, api_key_id.as_deref())
|
||||
} else {
|
||||
admin_usage_group_completed_by_user(&usage)
|
||||
.into_iter()
|
||||
.filter(|(group_user_id, _)| {
|
||||
admin_usage_matches_optional_id(
|
||||
Some(group_user_id.as_str()),
|
||||
user_id.as_deref(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let user_map: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
|
||||
if !group_by_api_key && state.has_user_data_reader() {
|
||||
let user_ids = grouped.keys().cloned().collect::<Vec<_>>();
|
||||
state
|
||||
.list_users_by_ids(&user_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.id.clone(), user))
|
||||
.collect()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
|
||||
let mut ttl_distribution = json!({
|
||||
"5min": 0_u64,
|
||||
"15min": 0_u64,
|
||||
"30min": 0_u64,
|
||||
"60min": 0_u64,
|
||||
});
|
||||
let mut users = Vec::new();
|
||||
|
||||
for (group_id, items) in grouped {
|
||||
let intervals = admin_usage_collect_request_intervals_minutes(&items);
|
||||
if intervals.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let within_5min = intervals.iter().filter(|value| **value <= 5.0).count() as u64;
|
||||
let within_15min = intervals
|
||||
.iter()
|
||||
.filter(|value| **value > 5.0 && **value <= 15.0)
|
||||
.count() as u64;
|
||||
let within_30min = intervals
|
||||
.iter()
|
||||
.filter(|value| **value > 15.0 && **value <= 30.0)
|
||||
.count() as u64;
|
||||
let within_60min = intervals
|
||||
.iter()
|
||||
.filter(|value| **value > 30.0 && **value <= 60.0)
|
||||
.count() as u64;
|
||||
let over_60min = intervals.iter().filter(|value| **value > 60.0).count() as u64;
|
||||
let request_count = intervals.len() as u64;
|
||||
let p50 = admin_usage_percentile_cont(&intervals, 0.5);
|
||||
let p75 = admin_usage_percentile_cont(&intervals, 0.75);
|
||||
let p90 = admin_usage_percentile_cont(&intervals, 0.90);
|
||||
let avg_interval = intervals.iter().copied().sum::<f64>() / intervals.len() as f64;
|
||||
let min_interval = intervals.iter().copied().reduce(f64::min);
|
||||
let max_interval = intervals.iter().copied().reduce(f64::max);
|
||||
let recommended_ttl = admin_usage_calculate_recommended_ttl(p75, p90);
|
||||
match recommended_ttl {
|
||||
0..=5 => {
|
||||
ttl_distribution["5min"] = json!(ttl_distribution["5min"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1))
|
||||
}
|
||||
6..=15 => {
|
||||
ttl_distribution["15min"] = json!(ttl_distribution["15min"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1))
|
||||
}
|
||||
16..=30 => {
|
||||
ttl_distribution["30min"] = json!(ttl_distribution["30min"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1))
|
||||
}
|
||||
_ => {
|
||||
ttl_distribution["60min"] = json!(ttl_distribution["60min"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1))
|
||||
}
|
||||
}
|
||||
|
||||
let (username, email) = if group_by_api_key {
|
||||
(Value::Null, Value::Null)
|
||||
} else if let Some(user) = user_map.get(&group_id) {
|
||||
(
|
||||
json!(user.username.clone()),
|
||||
json!(user.email.clone().unwrap_or_default()),
|
||||
)
|
||||
} else {
|
||||
(Value::Null, Value::Null)
|
||||
};
|
||||
|
||||
users.push(json!({
|
||||
"group_id": group_id,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"request_count": request_count,
|
||||
"interval_distribution": {
|
||||
"within_5min": within_5min,
|
||||
"within_15min": within_15min,
|
||||
"within_30min": within_30min,
|
||||
"within_60min": within_60min,
|
||||
"over_60min": over_60min,
|
||||
},
|
||||
"interval_percentages": {
|
||||
"within_5min": round_to(within_5min as f64 / request_count as f64 * 100.0, 1),
|
||||
"within_15min": round_to(within_15min as f64 / request_count as f64 * 100.0, 1),
|
||||
"within_30min": round_to(within_30min as f64 / request_count as f64 * 100.0, 1),
|
||||
"within_60min": round_to(within_60min as f64 / request_count as f64 * 100.0, 1),
|
||||
"over_60min": round_to(over_60min as f64 / request_count as f64 * 100.0, 1),
|
||||
},
|
||||
"percentiles": {
|
||||
"p50": p50.map(|value| round_to(value, 2)),
|
||||
"p75": p75.map(|value| round_to(value, 2)),
|
||||
"p90": p90.map(|value| round_to(value, 2)),
|
||||
},
|
||||
"avg_interval_minutes": round_to(avg_interval, 2),
|
||||
"min_interval_minutes": min_interval.map(|value| round_to(value, 2)),
|
||||
"max_interval_minutes": max_interval.map(|value| round_to(value, 2)),
|
||||
"recommended_ttl_minutes": recommended_ttl,
|
||||
"recommendation_reason": admin_usage_ttl_recommendation_reason(recommended_ttl, p75, p90),
|
||||
}));
|
||||
}
|
||||
|
||||
users.sort_by(|left, right| {
|
||||
right["request_count"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.cmp(&left["request_count"].as_u64().unwrap_or(0))
|
||||
.then_with(|| {
|
||||
left["group_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["group_id"].as_str().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"analysis_period_hours": hours,
|
||||
"total_users_analyzed": users.len(),
|
||||
"ttl_distribution": ttl_distribution,
|
||||
"users": users,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
use super::super::super::stats::{AdminStatsTimeRange, AdminStatsUsageFilter};
|
||||
use super::super::analytics::admin_usage_aggregation_by_user_json;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::GatewayError;
|
||||
use aether_admin::observability::usage::{
|
||||
admin_usage_aggregation_by_api_format_json, admin_usage_aggregation_by_model_json,
|
||||
admin_usage_aggregation_by_provider_json, admin_usage_bad_request_response,
|
||||
admin_usage_data_unavailable_response, admin_usage_parse_aggregation_limit,
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) async fn build_admin_usage_aggregation_stats_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
));
|
||||
}
|
||||
|
||||
let query = request_context.request_query_string.as_deref();
|
||||
let group_by = query_param_value(query, "group_by")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !matches!(
|
||||
group_by.as_str(),
|
||||
"model" | "user" | "provider" | "api_format"
|
||||
) {
|
||||
return Ok(admin_usage_bad_request_response(
|
||||
"Invalid group_by value: must be one of model, user, provider, api_format",
|
||||
));
|
||||
}
|
||||
let limit = match admin_usage_parse_aggregation_limit(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(admin_usage_bad_request_response(detail)),
|
||||
};
|
||||
let time_range = match AdminStatsTimeRange::resolve_optional(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(admin_usage_bad_request_response(detail)),
|
||||
};
|
||||
|
||||
let mut usage = state
|
||||
.list_admin_usage_for_optional_range(time_range.as_ref(), &AdminStatsUsageFilter::default())
|
||||
.await?;
|
||||
usage.retain(|item| item.status != "pending" && item.status != "streaming");
|
||||
|
||||
let response = match group_by.as_str() {
|
||||
"model" => admin_usage_aggregation_by_model_json(&usage, limit),
|
||||
"user" => admin_usage_aggregation_by_user_json(state, &usage, limit).await?,
|
||||
"provider" => admin_usage_aggregation_by_provider_json(&usage, limit),
|
||||
"api_format" => admin_usage_aggregation_by_api_format_json(&usage, limit),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
Ok(Json(response).into_response())
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
use super::super::super::stats::round_to;
|
||||
use super::super::analytics::list_recent_completed_usage_for_cache_affinity;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::GatewayError;
|
||||
use aether_admin::observability::usage::{
|
||||
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
|
||||
admin_usage_matches_optional_id, admin_usage_parse_recent_hours,
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) async fn build_admin_usage_cache_affinity_hit_analysis_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
));
|
||||
}
|
||||
|
||||
let query = request_context.request_query_string.as_deref();
|
||||
let hours = match admin_usage_parse_recent_hours(query, 168) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(admin_usage_bad_request_response(detail)),
|
||||
};
|
||||
let user_id = query_param_value(query, "user_id");
|
||||
let api_key_id = query_param_value(query, "api_key_id");
|
||||
let usage =
|
||||
list_recent_completed_usage_for_cache_affinity(state, hours, user_id.as_deref()).await?;
|
||||
let filtered: Vec<_> = usage
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
admin_usage_matches_optional_id(item.user_id.as_deref(), user_id.as_deref())
|
||||
&& admin_usage_matches_optional_id(
|
||||
item.api_key_id.as_deref(),
|
||||
api_key_id.as_deref(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let total_requests = filtered.len();
|
||||
let total_input_tokens: u64 = filtered.iter().map(|item| item.input_tokens).sum();
|
||||
let total_cache_read_tokens: u64 = filtered
|
||||
.iter()
|
||||
.map(|item| item.cache_read_input_tokens)
|
||||
.sum();
|
||||
let total_cache_creation_tokens: u64 = filtered
|
||||
.iter()
|
||||
.map(|item| item.cache_creation_input_tokens)
|
||||
.sum();
|
||||
let total_cache_read_cost: f64 = filtered.iter().map(|item| item.cache_read_cost_usd).sum();
|
||||
let total_cache_creation_cost: f64 = filtered
|
||||
.iter()
|
||||
.map(|item| item.cache_creation_cost_usd)
|
||||
.sum();
|
||||
let requests_with_cache_hit = filtered
|
||||
.iter()
|
||||
.filter(|item| item.cache_read_input_tokens > 0)
|
||||
.count();
|
||||
let total_context_tokens = total_input_tokens.saturating_add(total_cache_read_tokens);
|
||||
let token_cache_hit_rate = if total_context_tokens == 0 {
|
||||
0.0
|
||||
} else {
|
||||
round_to(
|
||||
total_cache_read_tokens as f64 / total_context_tokens as f64 * 100.0,
|
||||
2,
|
||||
)
|
||||
};
|
||||
let request_cache_hit_rate = if total_requests == 0 {
|
||||
0.0
|
||||
} else {
|
||||
round_to(
|
||||
requests_with_cache_hit as f64 / total_requests as f64 * 100.0,
|
||||
2,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"analysis_period_hours": hours,
|
||||
"total_requests": total_requests,
|
||||
"requests_with_cache_hit": requests_with_cache_hit,
|
||||
"request_cache_hit_rate": request_cache_hit_rate,
|
||||
"total_input_tokens": total_input_tokens,
|
||||
"total_cache_read_tokens": total_cache_read_tokens,
|
||||
"total_cache_creation_tokens": total_cache_creation_tokens,
|
||||
"token_cache_hit_rate": token_cache_hit_rate,
|
||||
"total_cache_read_cost_usd": round_to(total_cache_read_cost, 4),
|
||||
"total_cache_creation_cost_usd": round_to(total_cache_creation_cost, 4),
|
||||
"estimated_savings_usd": round_to(total_cache_read_cost * 9.0, 4),
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
use super::super::analytics::list_recent_completed_usage_for_cache_affinity;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::{query_param_bool, query_param_value, unix_secs_to_rfc3339};
|
||||
use crate::GatewayError;
|
||||
use aether_admin::observability::usage::{
|
||||
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
|
||||
admin_usage_group_completed_by_user, admin_usage_parse_recent_hours,
|
||||
admin_usage_parse_timeline_limit, admin_usage_point_sort_key, admin_usage_proportional_limits,
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
));
|
||||
}
|
||||
|
||||
let query = request_context.request_query_string.as_deref();
|
||||
let hours = match admin_usage_parse_recent_hours(query, 24) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(admin_usage_bad_request_response(detail)),
|
||||
};
|
||||
let limit = match admin_usage_parse_timeline_limit(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(admin_usage_bad_request_response(detail)),
|
||||
};
|
||||
let user_id = query_param_value(query, "user_id");
|
||||
let include_user_info = query_param_bool(query, "include_user_info", false);
|
||||
let usage =
|
||||
list_recent_completed_usage_for_cache_affinity(state, hours, user_id.as_deref()).await?;
|
||||
let mut grouped: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
|
||||
let mut models = BTreeSet::new();
|
||||
let mut usernames_by_user_id = BTreeMap::new();
|
||||
|
||||
for (group_user_id, items) in admin_usage_group_completed_by_user(&usage) {
|
||||
if let Some(ref requested_user_id) = user_id {
|
||||
if &group_user_id != requested_user_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let mut previous_created_at_unix_secs = None;
|
||||
for item in items {
|
||||
if let Some(previous) = previous_created_at_unix_secs {
|
||||
let interval_minutes =
|
||||
item.created_at_unix_secs.saturating_sub(previous) as f64 / 60.0;
|
||||
if interval_minutes <= 120.0 {
|
||||
let mut point = json!({
|
||||
"x": unix_secs_to_rfc3339(item.created_at_unix_secs),
|
||||
"y": ((interval_minutes * 100.0).round()) / 100.0,
|
||||
});
|
||||
if !item.model.trim().is_empty() {
|
||||
point["model"] = json!(item.model.clone());
|
||||
models.insert(item.model.clone());
|
||||
}
|
||||
if include_user_info && user_id.is_none() {
|
||||
point["user_id"] = json!(group_user_id.clone());
|
||||
if let Some(username) = item.username.clone() {
|
||||
usernames_by_user_id
|
||||
.entry(group_user_id.clone())
|
||||
.or_insert(username);
|
||||
}
|
||||
}
|
||||
grouped
|
||||
.entry(group_user_id.clone())
|
||||
.or_default()
|
||||
.push(point);
|
||||
}
|
||||
}
|
||||
previous_created_at_unix_secs = Some(item.created_at_unix_secs);
|
||||
}
|
||||
}
|
||||
|
||||
if include_user_info && user_id.is_none() && state.has_user_data_reader() {
|
||||
let user_ids: Vec<_> = grouped.keys().cloned().collect();
|
||||
let user_map: BTreeMap<_, _> = state
|
||||
.list_users_by_ids(&user_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.id, user.username))
|
||||
.collect();
|
||||
for (user_id, username) in user_map {
|
||||
usernames_by_user_id.insert(user_id, username);
|
||||
}
|
||||
}
|
||||
|
||||
let total_points_before_limit: usize = grouped.values().map(Vec::len).sum();
|
||||
let points: Vec<serde_json::Value> = if include_user_info && user_id.is_none() {
|
||||
let user_limits =
|
||||
admin_usage_proportional_limits(&grouped, limit, total_points_before_limit);
|
||||
let mut selected = Vec::new();
|
||||
for (group_user_id, mut items) in grouped {
|
||||
let take = user_limits
|
||||
.get(&group_user_id)
|
||||
.copied()
|
||||
.unwrap_or(items.len());
|
||||
selected.extend(items.drain(..std::cmp::min(take, items.len())));
|
||||
}
|
||||
selected.sort_by(admin_usage_point_sort_key);
|
||||
selected
|
||||
} else {
|
||||
let mut selected = grouped.into_values().flatten().collect::<Vec<_>>();
|
||||
selected.sort_by(admin_usage_point_sort_key);
|
||||
if selected.len() > limit {
|
||||
selected.truncate(limit);
|
||||
}
|
||||
selected
|
||||
};
|
||||
|
||||
let mut response = json!({
|
||||
"analysis_period_hours": hours,
|
||||
"total_points": points.len(),
|
||||
"points": points,
|
||||
});
|
||||
if include_user_info && user_id.is_none() {
|
||||
response["users"] = json!(usernames_by_user_id);
|
||||
}
|
||||
if !models.is_empty() {
|
||||
response["models"] = json!(models.into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
Ok(Json(response).into_response())
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
use super::super::super::stats::round_to;
|
||||
use super::super::analytics::list_recent_completed_usage_for_cache_affinity;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::GatewayError;
|
||||
use aether_admin::observability::usage::{
|
||||
admin_usage_bad_request_response, admin_usage_calculate_recommended_ttl,
|
||||
admin_usage_collect_request_intervals_minutes, admin_usage_data_unavailable_response,
|
||||
admin_usage_group_completed_by_api_key, admin_usage_group_completed_by_user,
|
||||
admin_usage_matches_optional_id, admin_usage_parse_recent_hours, admin_usage_percentile_cont,
|
||||
admin_usage_ttl_recommendation_reason, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) async fn build_admin_usage_cache_affinity_ttl_analysis_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
));
|
||||
}
|
||||
|
||||
let query = request_context.request_query_string.as_deref();
|
||||
let hours = match admin_usage_parse_recent_hours(query, 168) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(admin_usage_bad_request_response(detail)),
|
||||
};
|
||||
let user_id = query_param_value(query, "user_id");
|
||||
let api_key_id = query_param_value(query, "api_key_id");
|
||||
let group_by_api_key = api_key_id.is_some();
|
||||
let usage =
|
||||
list_recent_completed_usage_for_cache_affinity(state, hours, user_id.as_deref()).await?;
|
||||
|
||||
let grouped = if group_by_api_key {
|
||||
admin_usage_group_completed_by_api_key(&usage, api_key_id.as_deref())
|
||||
} else {
|
||||
admin_usage_group_completed_by_user(&usage)
|
||||
.into_iter()
|
||||
.filter(|(group_user_id, _)| {
|
||||
admin_usage_matches_optional_id(Some(group_user_id.as_str()), user_id.as_deref())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let user_map: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
|
||||
if !group_by_api_key && state.has_user_data_reader() {
|
||||
let user_ids = grouped.keys().cloned().collect::<Vec<_>>();
|
||||
state
|
||||
.list_users_by_ids(&user_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.id.clone(), user))
|
||||
.collect()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
|
||||
let mut ttl_distribution = json!({
|
||||
"5min": 0_u64,
|
||||
"15min": 0_u64,
|
||||
"30min": 0_u64,
|
||||
"60min": 0_u64,
|
||||
});
|
||||
let mut users = Vec::new();
|
||||
|
||||
for (group_id, items) in grouped {
|
||||
let intervals = admin_usage_collect_request_intervals_minutes(&items);
|
||||
if intervals.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let within_5min = intervals.iter().filter(|value| **value <= 5.0).count() as u64;
|
||||
let within_15min = intervals
|
||||
.iter()
|
||||
.filter(|value| **value > 5.0 && **value <= 15.0)
|
||||
.count() as u64;
|
||||
let within_30min = intervals
|
||||
.iter()
|
||||
.filter(|value| **value > 15.0 && **value <= 30.0)
|
||||
.count() as u64;
|
||||
let within_60min = intervals
|
||||
.iter()
|
||||
.filter(|value| **value > 30.0 && **value <= 60.0)
|
||||
.count() as u64;
|
||||
let over_60min = intervals.iter().filter(|value| **value > 60.0).count() as u64;
|
||||
let request_count = intervals.len() as u64;
|
||||
let p50 = admin_usage_percentile_cont(&intervals, 0.5);
|
||||
let p75 = admin_usage_percentile_cont(&intervals, 0.75);
|
||||
let p90 = admin_usage_percentile_cont(&intervals, 0.90);
|
||||
let avg_interval = intervals.iter().copied().sum::<f64>() / intervals.len() as f64;
|
||||
let min_interval = intervals.iter().copied().reduce(f64::min);
|
||||
let max_interval = intervals.iter().copied().reduce(f64::max);
|
||||
let recommended_ttl = admin_usage_calculate_recommended_ttl(p75, p90);
|
||||
match recommended_ttl {
|
||||
0..=5 => {
|
||||
ttl_distribution["5min"] = json!(ttl_distribution["5min"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1))
|
||||
}
|
||||
6..=15 => {
|
||||
ttl_distribution["15min"] = json!(ttl_distribution["15min"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1))
|
||||
}
|
||||
16..=30 => {
|
||||
ttl_distribution["30min"] = json!(ttl_distribution["30min"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1))
|
||||
}
|
||||
_ => {
|
||||
ttl_distribution["60min"] = json!(ttl_distribution["60min"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1))
|
||||
}
|
||||
}
|
||||
|
||||
let (username, email) = if group_by_api_key {
|
||||
(Value::Null, Value::Null)
|
||||
} else if let Some(user) = user_map.get(&group_id) {
|
||||
(
|
||||
json!(user.username.clone()),
|
||||
json!(user.email.clone().unwrap_or_default()),
|
||||
)
|
||||
} else {
|
||||
(Value::Null, Value::Null)
|
||||
};
|
||||
|
||||
users.push(json!({
|
||||
"group_id": group_id,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"request_count": request_count,
|
||||
"interval_distribution": {
|
||||
"within_5min": within_5min,
|
||||
"within_15min": within_15min,
|
||||
"within_30min": within_30min,
|
||||
"within_60min": within_60min,
|
||||
"over_60min": over_60min,
|
||||
},
|
||||
"interval_percentages": {
|
||||
"within_5min": round_to(within_5min as f64 / request_count as f64 * 100.0, 1),
|
||||
"within_15min": round_to(within_15min as f64 / request_count as f64 * 100.0, 1),
|
||||
"within_30min": round_to(within_30min as f64 / request_count as f64 * 100.0, 1),
|
||||
"within_60min": round_to(within_60min as f64 / request_count as f64 * 100.0, 1),
|
||||
"over_60min": round_to(over_60min as f64 / request_count as f64 * 100.0, 1),
|
||||
},
|
||||
"percentiles": {
|
||||
"p50": p50.map(|value| round_to(value, 2)),
|
||||
"p75": p75.map(|value| round_to(value, 2)),
|
||||
"p90": p90.map(|value| round_to(value, 2)),
|
||||
},
|
||||
"avg_interval_minutes": round_to(avg_interval, 2),
|
||||
"min_interval_minutes": min_interval.map(|value| round_to(value, 2)),
|
||||
"max_interval_minutes": max_interval.map(|value| round_to(value, 2)),
|
||||
"recommended_ttl_minutes": recommended_ttl,
|
||||
"recommendation_reason": admin_usage_ttl_recommendation_reason(recommended_ttl, p75, p90),
|
||||
}));
|
||||
}
|
||||
|
||||
users.sort_by(|left, right| {
|
||||
right["request_count"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.cmp(&left["request_count"].as_u64().unwrap_or(0))
|
||||
.then_with(|| {
|
||||
left["group_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["group_id"].as_str().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
|
||||
Ok(Json(json!({
|
||||
"analysis_period_hours": hours,
|
||||
"total_users_analyzed": users.len(),
|
||||
"ttl_distribution": ttl_distribution,
|
||||
"users": users,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::GatewayError;
|
||||
use aether_admin::observability::usage::{
|
||||
admin_usage_data_unavailable_response, admin_usage_heatmap_json,
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use aether_data_contracts::repository::usage::UsageAuditListQuery;
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
|
||||
pub(super) async fn build_admin_usage_heatmap_response(
|
||||
state: &AdminAppState<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(admin_usage_data_unavailable_response(
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
));
|
||||
}
|
||||
let now_unix_secs = u64::try_from(chrono::Utc::now().timestamp()).unwrap_or_default();
|
||||
let created_from_unix_secs = now_unix_secs.saturating_sub(365 * 24 * 3600);
|
||||
let mut usage = state
|
||||
.list_usage_audits(&UsageAuditListQuery {
|
||||
created_from_unix_secs: Some(created_from_unix_secs),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
usage.retain(|item| item.status != "pending" && item.status != "streaming");
|
||||
Ok(Json(admin_usage_heatmap_json(&usage)).into_response())
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
mod aggregation;
|
||||
mod cache_affinity_hit_analysis;
|
||||
mod cache_affinity_interval_timeline;
|
||||
mod cache_affinity_ttl_analysis;
|
||||
mod heatmap;
|
||||
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::GatewayError;
|
||||
use axum::{body::Body, http, response::Response};
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_usage_analytics_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let route_kind = request_context
|
||||
.control_decision
|
||||
.as_ref()
|
||||
.and_then(|decision| decision.route_kind.as_deref());
|
||||
|
||||
match route_kind {
|
||||
Some("aggregation_stats")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/aggregation/stats" | "/api/admin/usage/aggregation/stats/"
|
||||
) =>
|
||||
{
|
||||
return aggregation::build_admin_usage_aggregation_stats_response(
|
||||
state,
|
||||
request_context,
|
||||
)
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
Some("heatmap")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/heatmap" | "/api/admin/usage/heatmap/"
|
||||
) =>
|
||||
{
|
||||
return heatmap::build_admin_usage_heatmap_response(state)
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
Some("cache_affinity_hit_analysis")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/cache-affinity/hit-analysis"
|
||||
| "/api/admin/usage/cache-affinity/hit-analysis/"
|
||||
) =>
|
||||
{
|
||||
return cache_affinity_hit_analysis::build_admin_usage_cache_affinity_hit_analysis_response(
|
||||
state,
|
||||
request_context,
|
||||
)
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
Some("cache_affinity_interval_timeline")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/cache-affinity/interval-timeline"
|
||||
| "/api/admin/usage/cache-affinity/interval-timeline/"
|
||||
) =>
|
||||
{
|
||||
return cache_affinity_interval_timeline::build_admin_usage_cache_affinity_interval_timeline_response(
|
||||
state,
|
||||
request_context,
|
||||
)
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
Some("cache_affinity_ttl_analysis")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/usage/cache-affinity/ttl-analysis"
|
||||
| "/api/admin/usage/cache-affinity/ttl-analysis/"
|
||||
) =>
|
||||
{
|
||||
return cache_affinity_ttl_analysis::build_admin_usage_cache_affinity_ttl_analysis_response(
|
||||
state,
|
||||
request_context,
|
||||
)
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -1,32 +1,29 @@
|
||||
use super::super::stats::round_to;
|
||||
use super::analytics::{
|
||||
admin_usage_provider_key_name, admin_usage_provider_key_names, admin_usage_record_json,
|
||||
};
|
||||
use super::helpers::{
|
||||
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use super::analytics::admin_usage_provider_key_names;
|
||||
use super::replay::{
|
||||
admin_usage_build_curl_command, admin_usage_curl_headers, admin_usage_curl_url,
|
||||
admin_usage_headers_from_value, admin_usage_id_from_action_path,
|
||||
admin_usage_id_from_detail_path, admin_usage_resolve_request_preview_body,
|
||||
build_admin_usage_replay_response,
|
||||
admin_usage_curl_headers, admin_usage_curl_url, admin_usage_headers_from_value,
|
||||
admin_usage_id_from_action_path, admin_usage_id_from_detail_path,
|
||||
admin_usage_resolve_request_preview_body, build_admin_usage_curl_response,
|
||||
build_admin_usage_detail_payload, build_admin_usage_replay_response,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_bool};
|
||||
use crate::{AppState, GatewayError};
|
||||
use crate::GatewayError;
|
||||
use aether_admin::observability::usage::{
|
||||
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
|
||||
admin_usage_provider_key_name, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let route_kind = request_context
|
||||
@@ -54,12 +51,7 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
||||
return Ok(Some(admin_usage_bad_request_response("usage_id 无效")));
|
||||
};
|
||||
|
||||
let Some(item) = state
|
||||
.data
|
||||
.find_request_usage_by_id(&usage_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
else {
|
||||
let Some(item) = state.find_request_usage_by_id(&usage_id).await? else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
@@ -80,7 +72,7 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
||||
};
|
||||
let url = endpoint
|
||||
.as_ref()
|
||||
.map(|endpoint| admin_usage_curl_url(endpoint, &item));
|
||||
.map(|endpoint| admin_usage_curl_url(state, endpoint, &item));
|
||||
let headers_json = item
|
||||
.provider_request_headers
|
||||
.clone()
|
||||
@@ -95,18 +87,8 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
||||
.clone()
|
||||
.or_else(|| item.request_body.clone())
|
||||
.unwrap_or_else(|| admin_usage_resolve_request_preview_body(&item, None));
|
||||
let curl = admin_usage_build_curl_command(url.as_deref(), &headers, Some(&body));
|
||||
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
"url": url,
|
||||
"method": "POST",
|
||||
"headers": headers_json.unwrap_or_else(|| json!(headers.clone())),
|
||||
"body": body,
|
||||
"curl": curl,
|
||||
"original_request_body_available": item.request_body.is_some() || item.provider_request_body.is_some(),
|
||||
}))
|
||||
.into_response(),
|
||||
build_admin_usage_curl_response(&item, url, headers_json, &headers, &body),
|
||||
"admin_usage_curl_viewed",
|
||||
"view_usage_curl_replay",
|
||||
"usage_record",
|
||||
@@ -153,12 +135,7 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
||||
true,
|
||||
);
|
||||
|
||||
let Some(item) = state
|
||||
.data
|
||||
.find_request_usage_by_id(&usage_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
else {
|
||||
let Some(item) = state.find_request_usage_by_id(&usage_id).await? else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
@@ -187,84 +164,19 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
||||
admin_usage_provider_key_names(state, std::slice::from_ref(&item)).await?;
|
||||
let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names);
|
||||
|
||||
let mut payload =
|
||||
admin_usage_record_json(&item, &users_by_id, provider_key_name.as_deref());
|
||||
let request_body = item
|
||||
.request_body
|
||||
.clone()
|
||||
.unwrap_or_else(|| admin_usage_resolve_request_preview_body(&item, None));
|
||||
let request_preview_source = if item.request_body.is_some() {
|
||||
"stored_original"
|
||||
} else {
|
||||
"local_reconstruction"
|
||||
};
|
||||
let mut metadata = match item.request_metadata.clone() {
|
||||
Some(serde_json::Value::Object(object)) => serde_json::Value::Object(object),
|
||||
Some(value) => json!({ "request_metadata": value }),
|
||||
None => json!({}),
|
||||
};
|
||||
if let Some(object) = metadata.as_object_mut() {
|
||||
object.insert(
|
||||
"request_preview_source".to_string(),
|
||||
json!(request_preview_source),
|
||||
);
|
||||
object.insert(
|
||||
"original_request_body_available".to_string(),
|
||||
json!(item.request_body.is_some()),
|
||||
);
|
||||
object.insert(
|
||||
"original_response_body_available".to_string(),
|
||||
json!(item.response_body.is_some() || item.client_response_body.is_some()),
|
||||
);
|
||||
}
|
||||
payload["user"] = match item.user_id.as_ref() {
|
||||
Some(user_id) => json!({
|
||||
"id": user_id,
|
||||
"email": payload["user_email"].clone(),
|
||||
"username": payload["username"].clone(),
|
||||
}),
|
||||
None => Value::Null,
|
||||
};
|
||||
payload["request_id"] = json!(item.request_id);
|
||||
payload["billing_status"] = json!(item.billing_status);
|
||||
payload["request_type"] = json!(item.request_type);
|
||||
payload["provider_id"] = json!(item.provider_id);
|
||||
payload["provider_endpoint_id"] = json!(item.provider_endpoint_id);
|
||||
payload["provider_api_key_id"] = json!(item.provider_api_key_id);
|
||||
payload["error_category"] = json!(item.error_category);
|
||||
payload["cache_creation_cost"] = json!(round_to(item.cache_creation_cost_usd, 6));
|
||||
payload["cache_read_cost"] = json!(round_to(item.cache_read_cost_usd, 6));
|
||||
payload["request_cost"] = json!(round_to(item.total_cost_usd, 6));
|
||||
payload["request_headers"] = item
|
||||
.request_headers
|
||||
.clone()
|
||||
.unwrap_or_else(|| json!(admin_usage_curl_headers()));
|
||||
payload["provider_request_headers"] = item
|
||||
.provider_request_headers
|
||||
.clone()
|
||||
.unwrap_or_else(|| json!(admin_usage_curl_headers()));
|
||||
payload["response_headers"] = item.response_headers.clone().unwrap_or(Value::Null);
|
||||
payload["client_response_headers"] =
|
||||
item.client_response_headers.clone().unwrap_or(Value::Null);
|
||||
payload["metadata"] = metadata;
|
||||
payload["has_request_body"] = json!(true);
|
||||
payload["has_provider_request_body"] = json!(item.provider_request_body.is_some());
|
||||
payload["has_response_body"] = json!(item.response_body.is_some());
|
||||
payload["has_client_response_body"] = json!(item.client_response_body.is_some());
|
||||
payload["tiered_pricing"] = serde_json::Value::Null;
|
||||
if include_bodies {
|
||||
payload["request_body"] = request_body;
|
||||
payload["provider_request_body"] =
|
||||
item.provider_request_body.clone().unwrap_or(Value::Null);
|
||||
payload["response_body"] = item.response_body.clone().unwrap_or(Value::Null);
|
||||
payload["client_response_body"] =
|
||||
item.client_response_body.clone().unwrap_or(Value::Null);
|
||||
} else {
|
||||
payload["request_body"] = serde_json::Value::Null;
|
||||
payload["provider_request_body"] = serde_json::Value::Null;
|
||||
payload["response_body"] = serde_json::Value::Null;
|
||||
payload["client_response_body"] = serde_json::Value::Null;
|
||||
}
|
||||
let default_headers = admin_usage_curl_headers();
|
||||
let payload = build_admin_usage_detail_payload(
|
||||
&item,
|
||||
&users_by_id,
|
||||
provider_key_name.as_deref(),
|
||||
include_bodies,
|
||||
request_body,
|
||||
&default_headers,
|
||||
);
|
||||
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub(crate) const ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL: &str = "Admin usage data unavailable";
|
||||
|
||||
pub(crate) fn admin_usage_data_unavailable_response(detail: &'static str) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) fn admin_usage_bad_request_response(detail: impl Into<String>) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail.into() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::GatewayError;
|
||||
use axum::{body::Body, response::Response};
|
||||
|
||||
mod analytics;
|
||||
mod analytics_routes;
|
||||
mod detail_routes;
|
||||
mod helpers;
|
||||
mod replay;
|
||||
mod summary_routes;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_usage_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
let Some(decision) = request_context.decision() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::helpers::{
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::GatewayError;
|
||||
use aether_admin::observability::usage::{
|
||||
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::{
|
||||
provider_catalog::StoredProviderCatalogEndpoint, usage::StoredRequestUsageAudit,
|
||||
};
|
||||
@@ -16,28 +16,12 @@ use axum::{
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn admin_usage_id_from_path_suffix(request_path: &str, suffix: Option<&str>) -> Option<String> {
|
||||
let mut value = request_path
|
||||
.strip_prefix("/api/admin/usage/")?
|
||||
.trim()
|
||||
.trim_matches('/')
|
||||
.to_string();
|
||||
if let Some(suffix) = suffix {
|
||||
value = value.strip_suffix(suffix)?.trim_matches('/').to_string();
|
||||
}
|
||||
if value.is_empty() || value.contains('/') {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_id_from_detail_path(request_path: &str) -> Option<String> {
|
||||
admin_usage_id_from_path_suffix(request_path, None)
|
||||
aether_admin::observability::usage::admin_usage_id_from_detail_path(request_path)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_id_from_action_path(request_path: &str, action: &str) -> Option<String> {
|
||||
admin_usage_id_from_path_suffix(request_path, Some(action))
|
||||
aether_admin::observability::usage::admin_usage_id_from_action_path(request_path, action)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
@@ -52,66 +36,77 @@ struct AdminUsageReplayRequest {
|
||||
body_override: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn admin_usage_resolve_replay_mode(same_provider: bool, same_endpoint: bool) -> &'static str {
|
||||
if same_provider && same_endpoint {
|
||||
"same_endpoint_reuse"
|
||||
} else if same_provider {
|
||||
"same_provider_remap"
|
||||
} else {
|
||||
"cross_provider_remap"
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_resolve_request_preview_body(
|
||||
item: &StoredRequestUsageAudit,
|
||||
body_override: Option<serde_json::Value>,
|
||||
) -> serde_json::Value {
|
||||
let resolved_model = item.model.clone();
|
||||
let mut request_body = body_override
|
||||
.or_else(|| item.request_body.clone())
|
||||
.unwrap_or_else(|| {
|
||||
json!({
|
||||
"model": resolved_model,
|
||||
"stream": item.is_stream,
|
||||
})
|
||||
});
|
||||
if let Some(body) = request_body.as_object_mut() {
|
||||
body.entry("model".to_string())
|
||||
.or_insert_with(|| json!(resolved_model));
|
||||
if !body.contains_key("stream") {
|
||||
body.insert("stream".to_string(), json!(item.is_stream));
|
||||
}
|
||||
if let Some(target_model) = item
|
||||
.target_model
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
body.entry("target_model".to_string())
|
||||
.or_insert_with(|| json!(target_model));
|
||||
}
|
||||
if let Some(request_type) = item
|
||||
.request_type
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
body.entry("request_type".to_string())
|
||||
.or_insert_with(|| json!(request_type));
|
||||
}
|
||||
if let Some(api_format) = item
|
||||
.api_format
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
body.entry("api_format".to_string())
|
||||
.or_insert_with(|| json!(api_format));
|
||||
}
|
||||
}
|
||||
request_body
|
||||
aether_admin::observability::usage::admin_usage_resolve_request_preview_body(
|
||||
item,
|
||||
body_override,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_usage_curl_response(
|
||||
item: &StoredRequestUsageAudit,
|
||||
url: Option<String>,
|
||||
headers_json: Option<Value>,
|
||||
headers: &BTreeMap<String, String>,
|
||||
body: &Value,
|
||||
) -> Response<Body> {
|
||||
aether_admin::observability::usage::build_admin_usage_curl_response(
|
||||
item,
|
||||
url,
|
||||
headers_json,
|
||||
headers,
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_usage_detail_payload(
|
||||
item: &StoredRequestUsageAudit,
|
||||
users_by_id: &BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
|
||||
provider_key_name: Option<&str>,
|
||||
include_bodies: bool,
|
||||
request_body: Value,
|
||||
default_headers: &BTreeMap<String, String>,
|
||||
) -> Value {
|
||||
aether_admin::observability::usage::build_admin_usage_detail_payload(
|
||||
item,
|
||||
users_by_id,
|
||||
provider_key_name,
|
||||
include_bodies,
|
||||
request_body,
|
||||
default_headers,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_usage_replay_plan_response(
|
||||
item: &StoredRequestUsageAudit,
|
||||
target_provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
|
||||
target_endpoint: &StoredProviderCatalogEndpoint,
|
||||
target_api_key_id: Option<String>,
|
||||
request_body: Value,
|
||||
url: &str,
|
||||
headers: &BTreeMap<String, String>,
|
||||
same_provider: bool,
|
||||
same_endpoint: bool,
|
||||
) -> Response<Body> {
|
||||
aether_admin::observability::usage::build_admin_usage_replay_plan_response(
|
||||
item,
|
||||
target_provider,
|
||||
target_endpoint,
|
||||
target_api_key_id,
|
||||
request_body,
|
||||
url,
|
||||
headers,
|
||||
same_provider,
|
||||
same_endpoint,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_usage_replay_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
if !state.has_usage_data_reader() || !state.has_provider_catalog_data_reader() {
|
||||
@@ -132,12 +127,7 @@ pub(super) async fn build_admin_usage_replay_response(
|
||||
_ => AdminUsageReplayRequest::default(),
|
||||
};
|
||||
|
||||
let Some(item) = state
|
||||
.data
|
||||
.find_request_usage_by_id(&usage_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
else {
|
||||
let Some(item) = state.find_request_usage_by_id(&usage_id).await? else {
|
||||
return Ok((
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Usage record not found" })),
|
||||
@@ -264,59 +254,35 @@ pub(super) async fn build_admin_usage_replay_response(
|
||||
|
||||
let same_provider = item.provider_id.as_deref() == Some(target_provider.id.as_str());
|
||||
let same_endpoint = item.provider_endpoint_id.as_deref() == Some(target_endpoint.id.as_str());
|
||||
let resolved_model = item.model.clone();
|
||||
let mapping_source = "none";
|
||||
let request_body = admin_usage_resolve_request_preview_body(&item, payload.body_override);
|
||||
|
||||
let url = admin_usage_curl_url(&target_endpoint, &item);
|
||||
let url = admin_usage_curl_url(state, &target_endpoint, &item);
|
||||
let headers = admin_usage_curl_headers();
|
||||
let curl = admin_usage_build_curl_command(Some(&url), &headers, Some(&request_body));
|
||||
Ok(Json(json!({
|
||||
"dry_run": true,
|
||||
"usage_id": item.id,
|
||||
"request_id": item.request_id,
|
||||
"mode": admin_usage_resolve_replay_mode(same_provider, same_endpoint),
|
||||
"target_provider_id": target_provider.id,
|
||||
"target_provider_name": target_provider.name,
|
||||
"target_endpoint_id": target_endpoint.id,
|
||||
"target_api_key_id": payload.api_key_id.or(item.provider_api_key_id.clone()),
|
||||
"target_api_format": target_endpoint.api_format,
|
||||
"resolved_model": resolved_model,
|
||||
"mapping_source": mapping_source,
|
||||
"method": "POST",
|
||||
"url": url,
|
||||
"request_headers": headers,
|
||||
"request_body": request_body,
|
||||
"original_request_body_available": item.request_body.is_some(),
|
||||
"note": "Rust local replay currently exposes a dry-run plan and does not dispatch upstream",
|
||||
"curl": curl,
|
||||
}))
|
||||
.into_response())
|
||||
Ok(build_admin_usage_replay_plan_response(
|
||||
&item,
|
||||
&target_provider,
|
||||
&target_endpoint,
|
||||
payload.api_key_id.or(item.provider_api_key_id.clone()),
|
||||
request_body,
|
||||
&url,
|
||||
&headers,
|
||||
same_provider,
|
||||
same_endpoint,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_headers_from_value(
|
||||
value: &serde_json::Value,
|
||||
) -> Option<BTreeMap<String, String>> {
|
||||
let object = value.as_object()?;
|
||||
Some(BTreeMap::from_iter(object.iter().filter_map(
|
||||
|(key, value)| {
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
let value = value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| value.to_string());
|
||||
Some((key.clone(), value))
|
||||
},
|
||||
)))
|
||||
aether_admin::observability::usage::admin_usage_headers_from_value(value)
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_curl_headers() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([("Content-Type".to_string(), "application/json".to_string())])
|
||||
aether_admin::observability::usage::admin_usage_curl_headers()
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_curl_url(
|
||||
state: &AdminAppState<'_>,
|
||||
endpoint: &StoredProviderCatalogEndpoint,
|
||||
item: &StoredRequestUsageAudit,
|
||||
) -> String {
|
||||
@@ -331,60 +297,34 @@ pub(super) fn admin_usage_curl_url(
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return crate::provider_transport::url::build_passthrough_path_url(
|
||||
&endpoint.base_url,
|
||||
custom_path,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_else(|| endpoint.base_url.clone());
|
||||
return state
|
||||
.build_passthrough_path_url(&endpoint.base_url, custom_path, None, &[])
|
||||
.unwrap_or_else(|| endpoint.base_url.clone());
|
||||
}
|
||||
|
||||
match api_format {
|
||||
value if value.starts_with("claude:") => {
|
||||
crate::provider_transport::url::build_claude_messages_url(&endpoint.base_url, None)
|
||||
state.build_claude_messages_url(&endpoint.base_url, None)
|
||||
}
|
||||
value if value.starts_with("gemini:") => {
|
||||
crate::provider_transport::url::build_gemini_content_url(
|
||||
value if value.starts_with("gemini:") => state
|
||||
.build_gemini_content_url(
|
||||
&endpoint.base_url,
|
||||
item.target_model.as_deref().unwrap_or(item.model.as_str()),
|
||||
item.is_stream,
|
||||
None,
|
||||
)
|
||||
.unwrap_or_else(|| endpoint.base_url.clone())
|
||||
}
|
||||
.unwrap_or_else(|| endpoint.base_url.clone()),
|
||||
value if value.starts_with("openai:") => {
|
||||
crate::provider_transport::url::build_openai_chat_url(&endpoint.base_url, None)
|
||||
state.build_openai_chat_url(&endpoint.base_url, None)
|
||||
}
|
||||
_ => endpoint.base_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_usage_curl_shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
pub(super) fn admin_usage_build_curl_command(
|
||||
url: Option<&str>,
|
||||
headers: &BTreeMap<String, String>,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> String {
|
||||
let mut parts = vec!["curl".to_string()];
|
||||
if let Some(url) = url {
|
||||
parts.push(admin_usage_curl_shell_quote(url));
|
||||
}
|
||||
parts.push("-X POST".to_string());
|
||||
for (key, value) in headers {
|
||||
parts.push(format!(
|
||||
"-H {}",
|
||||
admin_usage_curl_shell_quote(&format!("{key}: {value}"))
|
||||
));
|
||||
}
|
||||
if let Some(body) = body {
|
||||
parts.push(format!(
|
||||
"-d {}",
|
||||
admin_usage_curl_shell_quote(&body.to_string())
|
||||
));
|
||||
}
|
||||
parts.join(" \\\n ")
|
||||
aether_admin::observability::usage::admin_usage_build_curl_command(url, headers, body)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
use super::super::stats::{
|
||||
aggregate_usage_stats, list_usage_for_optional_range, round_to, AdminStatsTimeRange,
|
||||
AdminStatsUsageFilter,
|
||||
};
|
||||
use super::analytics::{
|
||||
use super::super::stats::{AdminStatsTimeRange, AdminStatsUsageFilter};
|
||||
use super::analytics::admin_usage_provider_key_names;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::GatewayError;
|
||||
use aether_admin::observability::usage::{
|
||||
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
|
||||
admin_usage_matches_api_format, admin_usage_matches_eq, admin_usage_matches_search,
|
||||
admin_usage_matches_status, admin_usage_matches_username, admin_usage_parse_ids,
|
||||
admin_usage_parse_limit, admin_usage_parse_offset, admin_usage_provider_key_name,
|
||||
admin_usage_provider_key_names, admin_usage_record_json, admin_usage_total_tokens,
|
||||
};
|
||||
use super::helpers::{
|
||||
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
|
||||
admin_usage_parse_limit, admin_usage_parse_offset, build_admin_usage_active_requests_response,
|
||||
build_admin_usage_records_response, build_admin_usage_summary_stats_response,
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::usage::UsageAuditListQuery;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use axum::{body::Body, http, response::Response};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let route_kind = request_context
|
||||
.control_decision
|
||||
@@ -53,51 +43,13 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
|
||||
};
|
||||
let usage = list_usage_for_optional_range(
|
||||
state,
|
||||
time_range.as_ref(),
|
||||
&AdminStatsUsageFilter::default(),
|
||||
)
|
||||
.await?;
|
||||
let aggregate = aggregate_usage_stats(&usage);
|
||||
let cache_creation_tokens: u64 = usage
|
||||
.iter()
|
||||
.map(|item| item.cache_creation_input_tokens)
|
||||
.sum();
|
||||
let cache_read_tokens: u64 =
|
||||
usage.iter().map(|item| item.cache_read_input_tokens).sum();
|
||||
let cache_creation_cost: f64 =
|
||||
usage.iter().map(|item| item.cache_creation_cost_usd).sum();
|
||||
let cache_read_cost: f64 = usage.iter().map(|item| item.cache_read_cost_usd).sum();
|
||||
let total_tokens: u64 = usage.iter().map(admin_usage_total_tokens).sum();
|
||||
let avg_response_time = round_to(aggregate.avg_response_time_ms() / 1000.0, 2);
|
||||
let error_rate = if aggregate.total_requests == 0 {
|
||||
0.0
|
||||
} else {
|
||||
round_to(
|
||||
(aggregate.error_requests as f64 / aggregate.total_requests as f64) * 100.0,
|
||||
2,
|
||||
let usage = state
|
||||
.list_admin_usage_for_optional_range(
|
||||
time_range.as_ref(),
|
||||
&AdminStatsUsageFilter::default(),
|
||||
)
|
||||
};
|
||||
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"total_requests": aggregate.total_requests,
|
||||
"total_tokens": total_tokens,
|
||||
"total_cost": round_to(aggregate.total_cost, 6),
|
||||
"total_actual_cost": round_to(aggregate.actual_total_cost, 6),
|
||||
"avg_response_time": avg_response_time,
|
||||
"error_count": aggregate.error_requests,
|
||||
"error_rate": error_rate,
|
||||
"cache_stats": {
|
||||
"cache_creation_tokens": cache_creation_tokens,
|
||||
"cache_read_tokens": cache_read_tokens,
|
||||
"cache_creation_cost": round_to(cache_creation_cost, 6),
|
||||
"cache_read_cost": round_to(cache_read_cost, 6),
|
||||
}
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
.await?;
|
||||
return Ok(Some(build_admin_usage_summary_stats_response(&usage)));
|
||||
}
|
||||
Some("active")
|
||||
if request_context.request_method == http::Method::GET
|
||||
@@ -135,41 +87,10 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
}
|
||||
let provider_key_names = admin_usage_provider_key_names(state, &items).await?;
|
||||
|
||||
let payload: Vec<_> = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let provider_key_name =
|
||||
admin_usage_provider_key_name(&item, &provider_key_names);
|
||||
let mut value = json!({
|
||||
"id": item.id,
|
||||
"status": item.status,
|
||||
"input_tokens": item.input_tokens,
|
||||
"output_tokens": item.output_tokens,
|
||||
"cache_creation_input_tokens": item.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||
"cost": round_to(item.total_cost_usd, 6),
|
||||
"actual_cost": round_to(item.actual_total_cost_usd, 6),
|
||||
"response_time_ms": item.response_time_ms,
|
||||
"first_byte_time_ms": item.first_byte_time_ms,
|
||||
"provider": item.provider_name,
|
||||
"api_key_name": item.api_key_name,
|
||||
"provider_key_name": provider_key_name,
|
||||
});
|
||||
if let Some(api_format) = item.api_format {
|
||||
value["api_format"] = json!(api_format);
|
||||
}
|
||||
if let Some(endpoint_api_format) = item.endpoint_api_format {
|
||||
value["endpoint_api_format"] = json!(endpoint_api_format);
|
||||
}
|
||||
value["has_format_conversion"] = json!(item.has_format_conversion);
|
||||
if let Some(target_model) = item.target_model {
|
||||
value["target_model"] = json!(target_model);
|
||||
}
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(Some(Json(json!({ "requests": payload })).into_response()));
|
||||
return Ok(Some(build_admin_usage_active_requests_response(
|
||||
&items,
|
||||
&provider_key_names,
|
||||
)));
|
||||
}
|
||||
Some("records")
|
||||
if request_context.request_method == http::Method::GET
|
||||
@@ -194,8 +115,9 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
provider_name: None,
|
||||
model: None,
|
||||
};
|
||||
let mut usage =
|
||||
list_usage_for_optional_range(state, time_range.as_ref(), &filters).await?;
|
||||
let mut usage = state
|
||||
.list_admin_usage_for_optional_range(time_range.as_ref(), &filters)
|
||||
.await?;
|
||||
|
||||
let search = query_param_value(query, "search");
|
||||
let username_filter = query_param_value(query, "username");
|
||||
@@ -251,26 +173,20 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
|
||||
let provider_key_names = admin_usage_provider_key_names(state, &usage).await?;
|
||||
|
||||
let records: Vec<_> = usage
|
||||
let records = usage
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|item| {
|
||||
let provider_key_name =
|
||||
admin_usage_provider_key_name(&item, &provider_key_names);
|
||||
admin_usage_record_json(&item, &users_by_id, provider_key_name.as_deref())
|
||||
})
|
||||
.collect();
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"records": records,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
return Ok(Some(build_admin_usage_records_response(
|
||||
&records,
|
||||
&users_by_id,
|
||||
&provider_key_names,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user