mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 22:20:19 +08:00
refactor(health): add dashboard overview and related drill-down
- Replace health monitor tabs with a dashboard layout - Add related health drill-down for endpoint, model, and provider cards - Render provider health as cards and hide empty monitors
This commit is contained in:
@@ -21,6 +21,7 @@ pub(crate) fn mount_public_support_routes(router: Router<AppState>) -> Router<Ap
|
||||
.route("/api/public/global-models", get(proxy_request))
|
||||
.route("/api/public/health/api-formats", get(proxy_request))
|
||||
.route("/api/public/health/models", get(proxy_request))
|
||||
.route("/api/public/health/related", get(proxy_request))
|
||||
.route("/api/modules/auth-status", get(proxy_request))
|
||||
.route("/api/capabilities", get(proxy_request))
|
||||
.route("/api/capabilities/user-configurable", get(proxy_request))
|
||||
|
||||
@@ -94,6 +94,7 @@ pub(crate) const RUST_FRONTDOOR_OWNED_ROUTE_PATTERNS: &[&str] = &[
|
||||
"/api/public/global-models",
|
||||
"/api/public/health/api-formats",
|
||||
"/api/public/health/models",
|
||||
"/api/public/health/related",
|
||||
"/api/oauth/providers",
|
||||
"/api/oauth/{provider_type}/authorize",
|
||||
"/api/oauth/{provider_type}/callback",
|
||||
|
||||
@@ -81,6 +81,16 @@ pub(super) fn classify_admin_endpoints_family_route(
|
||||
"admin:endpoints_health",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path == "/api/admin/endpoints/health/related"
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"endpoints_health",
|
||||
"health_related",
|
||||
"admin:endpoints_health",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path.starts_with("/api/admin/endpoints/rpm/key/")
|
||||
{
|
||||
|
||||
@@ -157,6 +157,7 @@ pub(super) fn classify_public_support_route(
|
||||
| "/api/public/global-models"
|
||||
| "/api/public/health/api-formats"
|
||||
| "/api/public/health/models"
|
||||
| "/api/public/health/related"
|
||||
)
|
||||
{
|
||||
let route_kind = match normalized_path {
|
||||
@@ -168,6 +169,7 @@ pub(super) fn classify_public_support_route(
|
||||
"/api/public/global-models" => "global_models",
|
||||
"/api/public/health/api-formats" => "health_api_formats",
|
||||
"/api/public/health/models" => "health_models",
|
||||
"/api/public/health/related" => "health_related",
|
||||
_ => "site_info",
|
||||
};
|
||||
Some(classified(
|
||||
|
||||
@@ -140,6 +140,25 @@ fn classifies_admin_endpoint_health_providers_as_admin_proxy_route() {
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_endpoint_health_related_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
let uri: Uri = "/api/admin/endpoints/health/related?dimension=model&value=gpt-5"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("endpoints_health"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some("health_related"));
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:endpoints_health")
|
||||
);
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_endpoint_key_rpm_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -757,6 +757,25 @@ fn classifies_public_catalog_health_models_as_public_support_route() {
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_public_catalog_health_related_as_public_support_route() {
|
||||
let headers = headers(&[]);
|
||||
let uri: Uri = "/api/public/health/related?dimension=endpoint&value=openai%3Achat"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("public_catalog"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some("health_related"));
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("public:catalog")
|
||||
);
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_auth_registration_settings_as_public_support_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::extractors::{admin_health_key_id, admin_recover_key_id};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::handlers::public::{ApiFormatHealthMonitorOptions, ModelHealthMonitorOptions};
|
||||
use crate::handlers::public::{
|
||||
ApiFormatHealthMonitorOptions, HealthMonitorRelationDimension, ModelHealthMonitorOptions,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use axum::{
|
||||
body::Body,
|
||||
@@ -22,6 +24,14 @@ fn build_admin_endpoint_health_data_unavailable_response() -> Response<Body> {
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn build_admin_endpoint_health_bad_request_response(detail: &str) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_endpoints_health_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -231,6 +241,55 @@ pub(super) async fn maybe_build_local_admin_endpoints_health_response(
|
||||
return Ok(Some(Json(payload).into_response()));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_health")
|
||||
&& decision.route_kind.as_deref() == Some("health_related")
|
||||
&& request_context.path() == "/api/admin/endpoints/health/related"
|
||||
{
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
|
||||
}
|
||||
let Some(dimension) = query_param_value(request_context.query_string(), "dimension")
|
||||
.and_then(|value| HealthMonitorRelationDimension::parse(&value))
|
||||
else {
|
||||
return Ok(Some(build_admin_endpoint_health_bad_request_response(
|
||||
"dimension 必须是 endpoint、model 或 provider",
|
||||
)));
|
||||
};
|
||||
let Some(value) = query_param_value(request_context.query_string(), "value")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(Some(build_admin_endpoint_health_bad_request_response(
|
||||
"value 不能为空",
|
||||
)));
|
||||
};
|
||||
let lookback_hours = query_param_value(request_context.query_string(), "lookback_hours")
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| (1..=72).contains(value))
|
||||
.unwrap_or(6);
|
||||
let related_limit = query_param_value(request_context.query_string(), "related_limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| (1..=50).contains(value))
|
||||
.unwrap_or(8);
|
||||
let per_item_limit = query_param_value(request_context.query_string(), "per_item_limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| (10..=200).contains(value))
|
||||
.unwrap_or(100);
|
||||
let Some(payload) = state
|
||||
.build_related_health_monitor_payload(
|
||||
lookback_hours,
|
||||
dimension,
|
||||
&value,
|
||||
related_limit,
|
||||
per_item_limit,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
|
||||
};
|
||||
return Ok(Some(Json(payload).into_response()));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_health")
|
||||
&& decision.route_kind.as_deref() == Some("health_status")
|
||||
&& request_context.path() == "/api/admin/endpoints/health/status"
|
||||
|
||||
@@ -276,6 +276,26 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_related_health_monitor_payload(
|
||||
&self,
|
||||
lookback_hours: u64,
|
||||
dimension: crate::handlers::public::HealthMonitorRelationDimension,
|
||||
value: &str,
|
||||
related_limit: usize,
|
||||
per_item_limit: usize,
|
||||
) -> Option<serde_json::Value> {
|
||||
crate::handlers::public::build_related_health_monitor_payload(
|
||||
self.app,
|
||||
lookback_hours,
|
||||
dimension,
|
||||
value,
|
||||
related_limit,
|
||||
per_item_limit,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_execution_runtime_sync_plan(
|
||||
&self,
|
||||
trace_id: Option<&str>,
|
||||
|
||||
@@ -337,6 +337,8 @@ pub(crate) async fn build_api_format_health_monitor_payload(
|
||||
created_until_unix_secs: now_unix_secs,
|
||||
user_id: None,
|
||||
provider_name: None,
|
||||
model: None,
|
||||
api_format: None,
|
||||
exclude_status_codes: vec![USER_CANCELLED_STATUS_CODE],
|
||||
group_by: UsageBreakdownGroupBy::ApiFormat,
|
||||
})
|
||||
@@ -628,6 +630,8 @@ pub(crate) async fn build_model_health_monitor_payload(
|
||||
created_until_unix_secs: now_unix_secs,
|
||||
user_id: None,
|
||||
provider_name: None,
|
||||
model: None,
|
||||
api_format: None,
|
||||
exclude_status_codes: vec![USER_CANCELLED_STATUS_CODE],
|
||||
group_by: UsageBreakdownGroupBy::Model,
|
||||
})
|
||||
@@ -746,6 +750,8 @@ pub(crate) async fn build_provider_health_monitor_payload(
|
||||
created_until_unix_secs: now_unix_secs,
|
||||
user_id: None,
|
||||
provider_name: None,
|
||||
model: None,
|
||||
api_format: None,
|
||||
exclude_status_codes: vec![USER_CANCELLED_STATUS_CODE],
|
||||
group_by: UsageBreakdownGroupBy::Provider,
|
||||
})
|
||||
@@ -795,6 +801,465 @@ pub(crate) async fn build_provider_health_monitor_payload(
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum HealthMonitorRelationDimension {
|
||||
Endpoint,
|
||||
Model,
|
||||
Provider,
|
||||
}
|
||||
|
||||
impl HealthMonitorRelationDimension {
|
||||
pub(crate) fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"endpoint" | "api_format" | "api-format" => Some(Self::Endpoint),
|
||||
"model" => Some(Self::Model),
|
||||
"provider" => Some(Self::Provider),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Endpoint => "endpoint",
|
||||
Self::Model => "model",
|
||||
Self::Provider => "provider",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn build_related_health_monitor_payload(
|
||||
state: &AppState,
|
||||
lookback_hours: u64,
|
||||
dimension: HealthMonitorRelationDimension,
|
||||
value: &str,
|
||||
related_limit: usize,
|
||||
per_item_limit: usize,
|
||||
include_provider_info: bool,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_usage_data_reader() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default();
|
||||
let since_unix_secs = now_unix_secs.saturating_sub(lookback_hours * 3600);
|
||||
let related_limit = related_limit.max(1);
|
||||
let per_item_limit = per_item_limit.max(1);
|
||||
|
||||
let mut related_endpoints = Vec::new();
|
||||
let mut related_models = Vec::new();
|
||||
let mut related_providers = Vec::new();
|
||||
|
||||
match dimension {
|
||||
HealthMonitorRelationDimension::Endpoint => {
|
||||
let api_format = value.to_string();
|
||||
related_models = build_related_health_items(
|
||||
state,
|
||||
breakdown_summary_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
None,
|
||||
None,
|
||||
Some(api_format.clone()),
|
||||
UsageBreakdownGroupBy::Model,
|
||||
),
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
related_limit,
|
||||
per_item_limit,
|
||||
"model",
|
||||
{
|
||||
let api_format = api_format.clone();
|
||||
move |row| {
|
||||
usage_audit_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
None,
|
||||
Some(row.group_key.clone()),
|
||||
Some(api_format.clone()),
|
||||
per_item_limit,
|
||||
)
|
||||
}
|
||||
},
|
||||
|row, events| related_model_display_meta(row, events),
|
||||
)
|
||||
.await;
|
||||
|
||||
if include_provider_info {
|
||||
let api_format = value.to_string();
|
||||
related_providers = build_related_health_items(
|
||||
state,
|
||||
breakdown_summary_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
None,
|
||||
None,
|
||||
Some(api_format.clone()),
|
||||
UsageBreakdownGroupBy::Provider,
|
||||
),
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
related_limit,
|
||||
per_item_limit,
|
||||
"provider",
|
||||
{
|
||||
let api_format = api_format.clone();
|
||||
move |row| {
|
||||
usage_audit_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
Some(row.group_key.clone()),
|
||||
None,
|
||||
Some(api_format.clone()),
|
||||
per_item_limit,
|
||||
)
|
||||
}
|
||||
},
|
||||
related_provider_display_meta,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
HealthMonitorRelationDimension::Model => {
|
||||
let model = value.to_string();
|
||||
related_endpoints = build_related_health_items(
|
||||
state,
|
||||
breakdown_summary_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
None,
|
||||
Some(model.clone()),
|
||||
None,
|
||||
UsageBreakdownGroupBy::ApiFormat,
|
||||
),
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
related_limit,
|
||||
per_item_limit,
|
||||
"endpoint",
|
||||
{
|
||||
let model = model.clone();
|
||||
move |row| {
|
||||
usage_audit_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
None,
|
||||
Some(model.clone()),
|
||||
Some(row.group_key.clone()),
|
||||
per_item_limit,
|
||||
)
|
||||
}
|
||||
},
|
||||
related_endpoint_display_meta,
|
||||
)
|
||||
.await;
|
||||
|
||||
if include_provider_info {
|
||||
let model = value.to_string();
|
||||
related_providers = build_related_health_items(
|
||||
state,
|
||||
breakdown_summary_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
None,
|
||||
Some(model.clone()),
|
||||
None,
|
||||
UsageBreakdownGroupBy::Provider,
|
||||
),
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
related_limit,
|
||||
per_item_limit,
|
||||
"provider",
|
||||
{
|
||||
let model = model.clone();
|
||||
move |row| {
|
||||
usage_audit_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
Some(row.group_key.clone()),
|
||||
Some(model.clone()),
|
||||
None,
|
||||
per_item_limit,
|
||||
)
|
||||
}
|
||||
},
|
||||
related_provider_display_meta,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
HealthMonitorRelationDimension::Provider => {
|
||||
let provider_name = value.to_string();
|
||||
related_endpoints = build_related_health_items(
|
||||
state,
|
||||
breakdown_summary_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
Some(provider_name.clone()),
|
||||
None,
|
||||
None,
|
||||
UsageBreakdownGroupBy::ApiFormat,
|
||||
),
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
related_limit,
|
||||
per_item_limit,
|
||||
"endpoint",
|
||||
{
|
||||
let provider_name = provider_name.clone();
|
||||
move |row| {
|
||||
usage_audit_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
Some(provider_name.clone()),
|
||||
None,
|
||||
Some(row.group_key.clone()),
|
||||
per_item_limit,
|
||||
)
|
||||
}
|
||||
},
|
||||
related_endpoint_display_meta,
|
||||
)
|
||||
.await;
|
||||
|
||||
let provider_name = value.to_string();
|
||||
related_models = build_related_health_items(
|
||||
state,
|
||||
breakdown_summary_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
Some(provider_name.clone()),
|
||||
None,
|
||||
None,
|
||||
UsageBreakdownGroupBy::Model,
|
||||
),
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
related_limit,
|
||||
per_item_limit,
|
||||
"model",
|
||||
{
|
||||
let provider_name = provider_name.clone();
|
||||
move |row| {
|
||||
usage_audit_query(
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
Some(provider_name.clone()),
|
||||
Some(row.group_key.clone()),
|
||||
None,
|
||||
per_item_limit,
|
||||
)
|
||||
}
|
||||
},
|
||||
|row, events| related_model_display_meta(row, events),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"generated_at": unix_secs_to_rfc3339(now_unix_secs),
|
||||
"dimension": dimension.as_str(),
|
||||
"value": value,
|
||||
"related_endpoints": related_endpoints,
|
||||
"related_models": related_models,
|
||||
"related_providers": related_providers,
|
||||
}))
|
||||
}
|
||||
|
||||
fn breakdown_summary_query(
|
||||
created_from_unix_secs: u64,
|
||||
created_until_unix_secs: u64,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
api_format: Option<String>,
|
||||
group_by: UsageBreakdownGroupBy,
|
||||
) -> UsageBreakdownSummaryQuery {
|
||||
UsageBreakdownSummaryQuery {
|
||||
created_from_unix_secs,
|
||||
created_until_unix_secs,
|
||||
user_id: None,
|
||||
provider_name,
|
||||
model,
|
||||
api_format,
|
||||
exclude_status_codes: vec![USER_CANCELLED_STATUS_CODE],
|
||||
group_by,
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_audit_query(
|
||||
created_from_unix_secs: u64,
|
||||
created_until_unix_secs: u64,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
api_format: Option<String>,
|
||||
limit: usize,
|
||||
) -> UsageAuditListQuery {
|
||||
UsageAuditListQuery {
|
||||
created_from_unix_secs: Some(created_from_unix_secs),
|
||||
created_until_unix_secs: Some(created_until_unix_secs),
|
||||
provider_name,
|
||||
model,
|
||||
api_format,
|
||||
exclude_status_codes: vec![USER_CANCELLED_STATUS_CODE],
|
||||
limit: Some(limit),
|
||||
newest_first: true,
|
||||
..UsageAuditListQuery::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_related_health_items<F, G>(
|
||||
state: &AppState,
|
||||
query: UsageBreakdownSummaryQuery,
|
||||
since_unix_secs: u64,
|
||||
now_unix_secs: u64,
|
||||
related_limit: usize,
|
||||
per_item_limit: usize,
|
||||
kind: &'static str,
|
||||
build_events_query: F,
|
||||
build_display_meta: G,
|
||||
) -> Vec<serde_json::Value>
|
||||
where
|
||||
F: Fn(&StoredUsageBreakdownSummaryRow) -> UsageAuditListQuery,
|
||||
G: Fn(&StoredUsageBreakdownSummaryRow, &[StoredRequestUsageAudit]) -> (String, Option<String>),
|
||||
{
|
||||
let mut rows = state
|
||||
.summarize_usage_breakdown(&query)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|row| !row.group_key.trim().is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rows.sort_by(|left, right| {
|
||||
related_health_sort_rank(left)
|
||||
.cmp(&related_health_sort_rank(right))
|
||||
.then_with(|| right.request_count.cmp(&left.request_count))
|
||||
.then_with(|| left.group_key.cmp(&right.group_key))
|
||||
});
|
||||
|
||||
let mut items = Vec::new();
|
||||
for row in rows.into_iter().take(related_limit) {
|
||||
let events = state
|
||||
.list_usage_audits(&build_events_query(&row))
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
let (display_name, meta_text) = build_display_meta(&row, &events);
|
||||
items.push(related_health_item_payload(
|
||||
kind,
|
||||
&row,
|
||||
&events,
|
||||
display_name,
|
||||
meta_text,
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
));
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn related_health_item_payload(
|
||||
kind: &'static str,
|
||||
row: &StoredUsageBreakdownSummaryRow,
|
||||
events: &[StoredRequestUsageAudit],
|
||||
display_name: String,
|
||||
meta_text: Option<String>,
|
||||
since_unix_secs: u64,
|
||||
now_unix_secs: u64,
|
||||
) -> serde_json::Value {
|
||||
let (timeline, time_range_start, time_range_end) = build_model_health_timeline(
|
||||
events,
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
MODEL_HEALTH_TIMELINE_SEGMENTS,
|
||||
);
|
||||
let total_attempts = row.request_count;
|
||||
let success_count = row.success_count.min(total_attempts);
|
||||
let failed_count = total_attempts.saturating_sub(success_count);
|
||||
let success_rate = if total_attempts > 0 {
|
||||
success_count as f64 / total_attempts as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let last_event_at = events
|
||||
.first()
|
||||
.and_then(|item| unix_secs_to_rfc3339(item.created_at_unix_ms));
|
||||
|
||||
json!({
|
||||
"kind": kind,
|
||||
"key": row.group_key.clone(),
|
||||
"display_name": display_name,
|
||||
"meta_text": meta_text,
|
||||
"total_attempts": total_attempts,
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"success_rate": success_rate,
|
||||
"avg_latency_ms": model_health_average_latency_ms(row),
|
||||
"avg_first_byte_ms": model_health_average_first_byte_ms(events),
|
||||
"avg_tps": model_health_average_tps(row),
|
||||
"last_event_at": last_event_at,
|
||||
"timeline": timeline,
|
||||
"time_range_start": unix_secs_to_rfc3339(time_range_start),
|
||||
"time_range_end": unix_secs_to_rfc3339(time_range_end),
|
||||
})
|
||||
}
|
||||
|
||||
fn related_model_display_meta(
|
||||
row: &StoredUsageBreakdownSummaryRow,
|
||||
events: &[StoredRequestUsageAudit],
|
||||
) -> (String, Option<String>) {
|
||||
let provider_count = model_health_provider_count(events);
|
||||
let meta_text = if provider_count > 0 {
|
||||
Some(format!("{provider_count} 个提供商"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(model_health_display_name(&row.group_key), meta_text)
|
||||
}
|
||||
|
||||
fn related_provider_display_meta(
|
||||
row: &StoredUsageBreakdownSummaryRow,
|
||||
_events: &[StoredRequestUsageAudit],
|
||||
) -> (String, Option<String>) {
|
||||
(row.group_key.clone(), None)
|
||||
}
|
||||
|
||||
fn related_endpoint_display_meta(
|
||||
row: &StoredUsageBreakdownSummaryRow,
|
||||
_events: &[StoredRequestUsageAudit],
|
||||
) -> (String, Option<String>) {
|
||||
(
|
||||
api_format_display_name(&row.group_key),
|
||||
Some(public_api_format_local_path(&row.group_key).to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn related_health_sort_rank(row: &StoredUsageBreakdownSummaryRow) -> u8 {
|
||||
if row.request_count == 0 {
|
||||
return 3;
|
||||
}
|
||||
let success_count = row.success_count.min(row.request_count);
|
||||
let success_rate = success_count as f64 / row.request_count as f64;
|
||||
if success_rate < 0.8 {
|
||||
0
|
||||
} else if success_rate < 0.95 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_provider_health_payload(
|
||||
state: &AppState,
|
||||
provider: StoredProviderCatalogProvider,
|
||||
@@ -810,6 +1275,8 @@ async fn build_provider_health_payload(
|
||||
created_until_unix_secs: now_unix_secs,
|
||||
user_id: None,
|
||||
provider_name: Some(provider.name.clone()),
|
||||
model: None,
|
||||
api_format: None,
|
||||
exclude_status_codes: vec![USER_CANCELLED_STATUS_CODE],
|
||||
group_by: UsageBreakdownGroupBy::Model,
|
||||
})
|
||||
|
||||
@@ -10,10 +10,11 @@ pub(crate) use self::catalog_helpers::{
|
||||
admin_requested_force_stream, api_format_display_name, build_api_format_health_monitor_payload,
|
||||
build_model_health_monitor_payload, build_provider_health_monitor_payload,
|
||||
build_public_catalog_models_payload, build_public_catalog_search_models_payload,
|
||||
build_public_health_timeline, build_public_providers_payload, normalize_admin_base_url,
|
||||
provider_key_api_formats, request_candidate_event_unix_ms, request_candidate_status_label,
|
||||
build_public_health_timeline, build_public_providers_payload,
|
||||
build_related_health_monitor_payload, normalize_admin_base_url, provider_key_api_formats,
|
||||
request_candidate_event_unix_ms, request_candidate_status_label,
|
||||
sanitize_public_model_config_for_user, ApiFormatHealthMonitorOptions,
|
||||
ModelHealthMonitorOptions,
|
||||
HealthMonitorRelationDimension, ModelHealthMonitorOptions,
|
||||
};
|
||||
pub(crate) use self::system_modules_helpers::{
|
||||
build_admin_keys_grouped_by_format_payload, build_public_auth_modules_status_payload,
|
||||
|
||||
@@ -2,9 +2,10 @@ use super::{
|
||||
build_api_format_health_monitor_payload, build_model_health_monitor_payload,
|
||||
build_public_auth_modules_status_payload, build_public_catalog_models_payload,
|
||||
build_public_catalog_search_models_payload, build_public_providers_payload,
|
||||
capability_detail_by_name, ldap_module_config_is_valid, sanitize_public_model_config_for_user,
|
||||
serialize_public_capability, supported_capability_names, ApiFormatHealthMonitorOptions,
|
||||
ModelHealthMonitorOptions, PUBLIC_CAPABILITY_DEFINITIONS,
|
||||
build_related_health_monitor_payload, capability_detail_by_name, ldap_module_config_is_valid,
|
||||
sanitize_public_model_config_for_user, serialize_public_capability, supported_capability_names,
|
||||
ApiFormatHealthMonitorOptions, HealthMonitorRelationDimension, ModelHealthMonitorOptions,
|
||||
PUBLIC_CAPABILITY_DEFINITIONS,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::shared::{
|
||||
@@ -477,6 +478,77 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
||||
.await?;
|
||||
return Some(Json(payload).into_response());
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("health_related")
|
||||
&& request_context.request_path == "/api/public/health/related"
|
||||
{
|
||||
let Some(dimension) =
|
||||
query_param_value(request_context.request_query_string.as_deref(), "dimension")
|
||||
.and_then(|value| HealthMonitorRelationDimension::parse(&value))
|
||||
else {
|
||||
return Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "dimension 必须是 endpoint 或 model" })),
|
||||
)
|
||||
.into_response(),
|
||||
);
|
||||
};
|
||||
if matches!(dimension, HealthMonitorRelationDimension::Provider) {
|
||||
return Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "公开健康监控不支持 provider 维度" })),
|
||||
)
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
let Some(value) =
|
||||
query_param_value(request_context.request_query_string.as_deref(), "value")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "value 不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
);
|
||||
};
|
||||
let lookback_hours = query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"lookback_hours",
|
||||
)
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| (1..=168).contains(value))
|
||||
.unwrap_or(6);
|
||||
let related_limit = query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"related_limit",
|
||||
)
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| (1..=50).contains(value))
|
||||
.unwrap_or(8);
|
||||
let per_item_limit = query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"per_item_limit",
|
||||
)
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| (10..=500).contains(value))
|
||||
.unwrap_or(100);
|
||||
let payload = build_related_health_monitor_payload(
|
||||
state,
|
||||
lookback_hours,
|
||||
dimension,
|
||||
&value,
|
||||
related_limit,
|
||||
per_item_limit,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
return Some(Json(payload).into_response());
|
||||
}
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("capabilities") {
|
||||
|
||||
@@ -848,6 +848,8 @@ pub(super) async fn handle_users_me_usage_get(
|
||||
created_until_unix_secs,
|
||||
user_id: Some(auth.user.id.clone()),
|
||||
provider_name: None,
|
||||
model: None,
|
||||
api_format: None,
|
||||
exclude_status_codes: Vec::new(),
|
||||
group_by: UsageBreakdownGroupBy::Model,
|
||||
})
|
||||
@@ -869,6 +871,8 @@ pub(super) async fn handle_users_me_usage_get(
|
||||
created_until_unix_secs,
|
||||
user_id: Some(auth.user.id.clone()),
|
||||
provider_name: None,
|
||||
model: None,
|
||||
api_format: None,
|
||||
exclude_status_codes: Vec::new(),
|
||||
group_by: UsageBreakdownGroupBy::Provider,
|
||||
})
|
||||
@@ -890,6 +894,8 @@ pub(super) async fn handle_users_me_usage_get(
|
||||
created_until_unix_secs,
|
||||
user_id: Some(auth.user.id.clone()),
|
||||
provider_name: None,
|
||||
model: None,
|
||||
api_format: None,
|
||||
exclude_status_codes: Vec::new(),
|
||||
group_by: UsageBreakdownGroupBy::ApiFormat,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user