fix(providers): correct endpoint health aggregation and display

This commit is contained in:
elky
2026-09-06 18:24:27 +08:00
parent dba5e6e9e9
commit d1b5eb08ee
9 changed files with 425 additions and 38 deletions
@@ -13,6 +13,7 @@ use aether_data_contracts::repository::candidates::{
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_scheduler_core::provider_key_health_score;
use serde_json::json;
use std::collections::BTreeMap;
@@ -91,23 +92,17 @@ pub(crate) fn build_admin_provider_summary_value(
.get(&endpoint.id)
.cloned()
.unwrap_or_default();
let health_score = if endpoint_keys.is_empty() {
1.0
} else {
let mut scores = Vec::new();
for key in &endpoint_keys {
let score = key
.health_by_format
.as_ref()
.and_then(|value| value.get(&endpoint.api_format))
.and_then(|value| value.get("health_score"))
.and_then(serde_json::Value::as_f64)
.unwrap_or(1.0);
scores.push(score);
}
scores.iter().sum::<f64>() / scores.len() as f64
};
endpoint_health_scores.push(health_score);
let scores = endpoint_keys
.iter()
.filter(|key| endpoint.is_active && key.is_active)
.filter_map(|key| provider_key_health_score(key, &endpoint.api_format))
.filter(|score| score.is_finite())
.collect::<Vec<_>>();
let health_score =
(!scores.is_empty()).then(|| scores.iter().sum::<f64>() / scores.len() as f64);
if let Some(score) = health_score {
endpoint_health_scores.push(score);
}
json!({
"api_format": endpoint.api_format,
"health_score": health_score,
@@ -117,11 +112,8 @@ pub(crate) fn build_admin_provider_summary_value(
})
})
.collect::<Vec<_>>();
let avg_health_score = if endpoint_health_scores.is_empty() {
1.0
} else {
endpoint_health_scores.iter().sum::<f64>() / endpoint_health_scores.len() as f64
};
let avg_health_score = (!endpoint_health_scores.is_empty())
.then(|| endpoint_health_scores.iter().sum::<f64>() / endpoint_health_scores.len() as f64);
let unhealthy_endpoints = endpoint_health_scores
.iter()
.filter(|score| **score < 0.5)
@@ -40,6 +40,198 @@ use crate::data::GatewayDataState;
const ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin provider catalog data unavailable";
async fn provider_health_summary(
endpoints: &[StoredProviderCatalogEndpoint],
keys: &[StoredProviderCatalogKey],
) -> serde_json::Value {
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
endpoints.to_vec(),
keys.to_vec(),
));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
repository,
));
let response = local_admin_providers_response(
&state,
http::Method::GET,
"/api/admin/providers/summary",
None,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("summary body should read");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("summary should parse");
assert_eq!(payload["items"].as_array().map(Vec::len), Some(1));
payload["items"][0].clone()
}
#[tokio::test]
async fn admin_provider_summary_health_ignores_disabled_keys() {
let endpoint = sample_endpoint(
"endpoint-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example",
);
for (active_score, disabled_score) in [(0.2, 1.0), (1.0, 0.2), (0.0, 1.0)] {
let active_key = sample_key("key-active", "provider-openai", "openai:chat", "test")
.with_health_fields(
Some(json!({"openai:chat": {"health_score": active_score}})),
None,
);
let mut disabled_key = sample_key("key-disabled", "provider-openai", "openai:chat", "test")
.with_health_fields(
Some(json!({"openai:chat": {"health_score": disabled_score}})),
None,
);
disabled_key.is_active = false;
let payload =
provider_health_summary(std::slice::from_ref(&endpoint), &[active_key, disabled_key])
.await;
assert_eq!(
payload["endpoint_health_details"][0]["health_score"],
active_score
);
assert_eq!(payload["endpoint_health_details"][0]["total_keys"], 2);
assert_eq!(payload["endpoint_health_details"][0]["active_keys"], 1);
assert_eq!(payload["avg_health_score"], active_score);
assert_eq!(
payload["unhealthy_endpoints"],
usize::from(active_score < 0.5)
);
}
}
#[tokio::test]
async fn admin_provider_summary_health_does_not_inflate_observed_scores_with_missing_data() {
let endpoint = sample_endpoint(
"endpoint-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example",
);
let keys = [
sample_key("key-observed", "provider-openai", "openai:chat", "test")
.with_health_fields(Some(json!({"openai:chat": {"health_score": 0.2}})), None),
sample_key("key-unobserved", "provider-openai", "openai:chat", "test"),
sample_key("key-other-format", "provider-openai", "openai:chat", "test")
.with_health_fields(
Some(json!({"openai:responses": {"health_score": 1.0}})),
None,
),
];
let payload = provider_health_summary(&[endpoint], &keys).await;
assert_eq!(payload["endpoint_health_details"][0]["health_score"], 0.2);
assert_eq!(payload["endpoint_health_details"][0]["active_keys"], 3);
assert_eq!(payload["avg_health_score"], 0.2);
assert_eq!(payload["unhealthy_endpoints"], 1);
}
#[tokio::test]
async fn admin_provider_summary_health_is_unknown_without_active_observations() {
let endpoint = sample_endpoint(
"endpoint-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example",
);
let mut disabled_key = sample_key("key-disabled", "provider-openai", "openai:chat", "test")
.with_health_fields(Some(json!({"openai:chat": {"health_score": 0.2}})), None);
disabled_key.is_active = false;
for keys in [
Vec::new(),
vec![sample_key(
"key-unobserved",
"provider-openai",
"openai:chat",
"test",
)],
vec![disabled_key],
] {
let payload = provider_health_summary(std::slice::from_ref(&endpoint), &keys).await;
assert_eq!(
payload["endpoint_health_details"][0]["health_score"],
json!(null)
);
assert_eq!(payload["avg_health_score"], json!(null));
assert_eq!(payload["unhealthy_endpoints"], 0);
}
let payload = provider_health_summary(&[], &[]).await;
assert_eq!(payload["avg_health_score"], json!(null));
assert_eq!(payload["unhealthy_endpoints"], 0);
}
#[tokio::test]
async fn admin_provider_summary_health_excludes_disabled_and_unobserved_endpoints() {
let mut disabled_endpoint = sample_endpoint(
"endpoint-disabled",
"provider-openai",
"openai:responses",
"https://api.openai.example",
);
disabled_endpoint.is_active = false;
let endpoints = [
sample_endpoint(
"endpoint-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example",
),
disabled_endpoint,
sample_endpoint(
"endpoint-unobserved",
"provider-openai",
"openai:embedding",
"https://api.openai.example",
),
];
let keys = [
sample_key("key-chat", "provider-openai", "openai:chat", "test")
.with_health_fields(Some(json!({"openai:chat": {"health_score": 0.8}})), None),
sample_key(
"key-responses",
"provider-openai",
"openai:responses",
"test",
)
.with_health_fields(
Some(json!({"openai:responses": {"health_score": 0.2}})),
None,
),
sample_key(
"key-unobserved",
"provider-openai",
"openai:embedding",
"test",
),
];
let payload = provider_health_summary(&endpoints, &keys).await;
assert_eq!(payload["endpoint_health_details"][0]["health_score"], 0.8);
assert_eq!(
payload["endpoint_health_details"][1]["health_score"],
json!(null)
);
assert_eq!(
payload["endpoint_health_details"][2]["health_score"],
json!(null)
);
assert_eq!(payload["avg_health_score"], 0.8);
assert_eq!(payload["unhealthy_endpoints"], 0);
}
fn trusted_admin_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(GATEWAY_HEADER, HeaderValue::from_static("rust-phase3b"));