mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate
- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦 - 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块 - 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支 - 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合 - 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor - 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
pub(super) fn admin_health_key_id(request_path: &str) -> Option<String> {
|
||||
let raw = request_path.strip_prefix("/api/admin/endpoints/health/key/")?;
|
||||
let normalized = raw.trim().trim_matches('/');
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_recover_key_id(request_path: &str) -> Option<String> {
|
||||
let raw = request_path.strip_prefix("/api/admin/endpoints/health/keys/")?;
|
||||
let normalized = raw.trim().trim_matches('/');
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_rpm_key_id(request_path: &str) -> Option<String> {
|
||||
let raw = request_path.strip_prefix("/api/admin/endpoints/rpm/key/")?;
|
||||
let normalized = raw.trim().trim_matches('/');
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use super::extractors::{admin_health_key_id, admin_recover_key_id};
|
||||
use super::health_builders::{
|
||||
build_admin_endpoint_health_status_payload, build_admin_health_summary_payload,
|
||||
build_admin_key_health_payload, recover_admin_key_health, recover_all_admin_key_health,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::handlers::public::{
|
||||
build_api_format_health_monitor_payload, ApiFormatHealthMonitorOptions,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
const ADMIN_ENDPOINT_HEALTH_DATA_UNAVAILABLE_DETAIL: &str =
|
||||
"Admin endpoint health data unavailable";
|
||||
|
||||
fn build_admin_endpoint_health_data_unavailable_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "detail": ADMIN_ENDPOINT_HEALTH_DATA_UNAVAILABLE_DETAIL })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_endpoints_health_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_health")
|
||||
&& decision.route_kind.as_deref() == Some("health_summary")
|
||||
&& request_context.request_path == "/api/admin/endpoints/health/summary"
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
|
||||
}
|
||||
let Some(payload) = build_admin_health_summary_payload(state).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("key_health")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/health/key/")
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
|
||||
}
|
||||
let Some(key_id) = admin_health_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let api_format = query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"api_format",
|
||||
);
|
||||
return Ok(Some(
|
||||
match build_admin_key_health_payload(state, &key_id, api_format.as_deref()).await {
|
||||
Some(payload) => Json(payload).into_response(),
|
||||
None => (
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_health")
|
||||
&& decision.route_kind.as_deref() == Some("recover_key_health")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/health/keys/")
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
|
||||
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
|
||||
}
|
||||
let Some(key_id) = admin_recover_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let api_format = query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"api_format",
|
||||
);
|
||||
return Ok(Some(
|
||||
match recover_admin_key_health(state, &key_id, api_format.as_deref()).await {
|
||||
Some(payload) => Json(payload).into_response(),
|
||||
None => (
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_health")
|
||||
&& decision.route_kind.as_deref() == Some("recover_all_keys_health")
|
||||
&& request_context.request_path == "/api/admin/endpoints/health/keys"
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
|
||||
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
|
||||
}
|
||||
let Some(payload) = recover_all_admin_key_health(state).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_api_formats")
|
||||
&& request_context.request_path == "/api/admin/endpoints/health/api-formats"
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_request_candidate_data_reader() {
|
||||
return Ok(Some(build_admin_endpoint_health_data_unavailable_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..=72).contains(value))
|
||||
.unwrap_or(6);
|
||||
let per_format_limit = query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"per_format_limit",
|
||||
)
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| (10..=200).contains(value))
|
||||
.unwrap_or(60);
|
||||
let Some(payload) = build_api_format_health_monitor_payload(
|
||||
state,
|
||||
lookback_hours,
|
||||
per_format_limit,
|
||||
ApiFormatHealthMonitorOptions {
|
||||
include_api_path: false,
|
||||
include_provider_count: true,
|
||||
include_key_count: true,
|
||||
},
|
||||
)
|
||||
.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.request_path == "/api/admin/endpoints/health/status"
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_request_candidate_data_reader() {
|
||||
return Ok(Some(build_admin_endpoint_health_data_unavailable_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..=72).contains(value))
|
||||
.unwrap_or(6);
|
||||
let Some(payload) = build_admin_endpoint_health_status_payload(state, lookback_hours).await
|
||||
else {
|
||||
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
|
||||
};
|
||||
return Ok(Some(Json(payload).into_response()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
use crate::handlers::public::provider_key_api_formats;
|
||||
use crate::AppState;
|
||||
use aether_scheduler_core::count_recent_rpm_requests_for_provider_key_since;
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(crate) async fn build_admin_key_health_payload(
|
||||
state: &AppState,
|
||||
key_id: &str,
|
||||
api_format: Option<&str>,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key = state
|
||||
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut keys| keys.drain(..).next())?;
|
||||
|
||||
let request_count = key.request_count.unwrap_or(0);
|
||||
let success_count = key.success_count.unwrap_or(0);
|
||||
let error_count = key
|
||||
.error_count
|
||||
.unwrap_or(request_count.saturating_sub(success_count));
|
||||
let avg_response_time_ms = match (key.total_response_time_ms, success_count) {
|
||||
(Some(total), successes) if successes > 0 => total as f64 / successes as f64,
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
let mut payload = json!({
|
||||
"key_id": key.id,
|
||||
"key_is_active": key.is_active,
|
||||
"key_statistics": {
|
||||
"request_count": request_count,
|
||||
"success_count": success_count,
|
||||
"error_count": error_count,
|
||||
"success_rate": if request_count > 0 {
|
||||
success_count as f64 / request_count as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
"avg_response_time_ms": avg_response_time_ms,
|
||||
},
|
||||
});
|
||||
|
||||
let health_by_format: Option<&serde_json::Map<String, serde_json::Value>> = key
|
||||
.health_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let circuit_by_format: Option<&serde_json::Map<String, serde_json::Value>> = key
|
||||
.circuit_breaker_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object);
|
||||
|
||||
if let Some(api_format) = api_format.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let health_data = health_by_format.and_then(|formats| formats.get(api_format));
|
||||
let circuit_data = circuit_by_format.and_then(|formats| formats.get(api_format));
|
||||
|
||||
payload["api_format"] = json!(api_format);
|
||||
payload["key_health_score"] = json!(health_data
|
||||
.and_then(|value| value.get("health_score"))
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.unwrap_or(1.0));
|
||||
payload["key_consecutive_failures"] = json!(health_data
|
||||
.and_then(|value| value.get("consecutive_failures"))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0));
|
||||
payload["key_last_failure_at"] = health_data
|
||||
.and_then(|value| value.get("last_failure_at"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
payload["circuit_breaker_open"] = json!(circuit_data
|
||||
.and_then(|value| value.get("open"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false));
|
||||
payload["circuit_breaker_open_at"] = circuit_data
|
||||
.and_then(|value| value.get("open_at"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
payload["next_probe_at"] = circuit_data
|
||||
.and_then(|value| value.get("next_probe_at"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
payload["half_open_until"] = circuit_data
|
||||
.and_then(|value| value.get("half_open_until"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
payload["half_open_successes"] = json!(circuit_data
|
||||
.and_then(|value| value.get("half_open_successes"))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0));
|
||||
payload["half_open_failures"] = json!(circuit_data
|
||||
.and_then(|value| value.get("half_open_failures"))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0));
|
||||
} else {
|
||||
let mut formats_payload = serde_json::Map::new();
|
||||
for format_name in provider_key_api_formats(&key) {
|
||||
let health_data = health_by_format.and_then(|formats| formats.get(&format_name));
|
||||
let circuit_data = circuit_by_format.and_then(|formats| formats.get(&format_name));
|
||||
let is_open = circuit_data
|
||||
.and_then(|value| value.get("open"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
formats_payload.insert(
|
||||
format_name.clone(),
|
||||
json!({
|
||||
"health_score": health_data
|
||||
.and_then(|value| value.get("health_score"))
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.unwrap_or(1.0),
|
||||
"error_rate": 0.0,
|
||||
"window_size": 0,
|
||||
"consecutive_failures": health_data
|
||||
.and_then(|value| value.get("consecutive_failures"))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0),
|
||||
"last_failure_at": health_data
|
||||
.and_then(|value| value.get("last_failure_at"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"circuit_breaker": {
|
||||
"state": if is_open { "open" } else { "closed" },
|
||||
"open": is_open,
|
||||
"open_at": circuit_data
|
||||
.and_then(|value| value.get("open_at"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"next_probe_at": circuit_data
|
||||
.and_then(|value| value.get("next_probe_at"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"half_open_until": circuit_data
|
||||
.and_then(|value| value.get("half_open_until"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"half_open_successes": circuit_data
|
||||
.and_then(|value| value.get("half_open_successes"))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0),
|
||||
"half_open_failures": circuit_data
|
||||
.and_then(|value| value.get("half_open_failures"))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let key_health_score = formats_payload
|
||||
.values()
|
||||
.filter_map(|value| value.get("health_score"))
|
||||
.filter_map(serde_json::Value::as_f64)
|
||||
.reduce(f64::min)
|
||||
.unwrap_or(1.0);
|
||||
let any_circuit_open = formats_payload.values().any(|value| {
|
||||
value
|
||||
.get("circuit_breaker")
|
||||
.and_then(|circuit| circuit.get("open"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
payload["key_health_score"] = json!(key_health_score);
|
||||
payload["any_circuit_open"] = json!(any_circuit_open);
|
||||
payload["health_by_format"] = serde_json::Value::Object(formats_payload);
|
||||
}
|
||||
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_key_rpm_payload(
|
||||
state: &AppState,
|
||||
key_id: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key = state
|
||||
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut keys| keys.drain(..).next())?;
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let recent_candidates = state.read_recent_request_candidates(256).await.ok()?;
|
||||
let reset_after_unix_secs = state.provider_key_rpm_reset_at(key.id.as_str(), now_unix_secs);
|
||||
let current_rpm = count_recent_rpm_requests_for_provider_key_since(
|
||||
&recent_candidates,
|
||||
key.id.as_str(),
|
||||
now_unix_secs,
|
||||
reset_after_unix_secs,
|
||||
);
|
||||
|
||||
Some(json!({
|
||||
"key_id": key.id,
|
||||
"current_rpm": current_rpm,
|
||||
"rpm_limit": key.rpm_limit,
|
||||
}))
|
||||
}
|
||||
|
||||
fn default_key_health_payload() -> serde_json::Value {
|
||||
json!({
|
||||
"health_score": 1.0,
|
||||
"consecutive_failures": 0,
|
||||
"last_failure_at": serde_json::Value::Null,
|
||||
})
|
||||
}
|
||||
|
||||
fn default_key_circuit_payload() -> serde_json::Value {
|
||||
json!({
|
||||
"open": false,
|
||||
"open_at": serde_json::Value::Null,
|
||||
"next_probe_at": serde_json::Value::Null,
|
||||
"half_open_until": serde_json::Value::Null,
|
||||
"half_open_successes": 0,
|
||||
"half_open_failures": 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn recover_admin_key_health(
|
||||
state: &AppState,
|
||||
key_id: &str,
|
||||
api_format: Option<&str>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let key = state
|
||||
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut keys| keys.drain(..).next())?;
|
||||
|
||||
let api_format = api_format.map(str::trim).filter(|value| !value.is_empty());
|
||||
let (health_by_format, circuit_breaker_by_format, message, details) =
|
||||
if let Some(api_format) = api_format {
|
||||
let mut health_by_format = key
|
||||
.health_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut circuit_breaker_by_format = key
|
||||
.circuit_breaker_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
health_by_format.insert(api_format.to_string(), default_key_health_payload());
|
||||
circuit_breaker_by_format.insert(api_format.to_string(), default_key_circuit_payload());
|
||||
(
|
||||
serde_json::Value::Object(health_by_format),
|
||||
serde_json::Value::Object(circuit_breaker_by_format),
|
||||
format!("Key 的 {api_format} 格式已恢复"),
|
||||
json!({
|
||||
"api_format": api_format,
|
||||
"health_score": 1.0,
|
||||
"circuit_breaker_open": false,
|
||||
"is_active": true,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
json!({}),
|
||||
json!({}),
|
||||
"Key 所有格式已恢复".to_string(),
|
||||
json!({
|
||||
"health_score": 1.0,
|
||||
"circuit_breaker_open": false,
|
||||
"is_active": true,
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
let updated = state
|
||||
.update_provider_catalog_key_health_state(
|
||||
key_id,
|
||||
true,
|
||||
Some(&health_by_format),
|
||||
Some(&circuit_breaker_by_format),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !updated {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"message": message,
|
||||
"details": details,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn recover_all_admin_key_health(state: &AppState) -> Option<serde_json::Value> {
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let providers = state
|
||||
.list_provider_catalog_providers(false)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
let provider_ids = providers
|
||||
.iter()
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let keys = if provider_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
state
|
||||
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let recovered_keys = keys
|
||||
.into_iter()
|
||||
.filter(|key| {
|
||||
key.circuit_breaker_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|formats| {
|
||||
formats.values().any(|circuit| {
|
||||
circuit
|
||||
.get("open")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if recovered_keys.is_empty() {
|
||||
return Some(json!({
|
||||
"message": "没有需要恢复的 Key",
|
||||
"recovered_count": 0,
|
||||
"recovered_keys": [],
|
||||
}));
|
||||
}
|
||||
|
||||
let empty_health = json!({});
|
||||
let empty_circuit = json!({});
|
||||
let mut payload_items = Vec::new();
|
||||
for key in recovered_keys {
|
||||
let updated = state
|
||||
.update_provider_catalog_key_health_state(
|
||||
&key.id,
|
||||
true,
|
||||
Some(&empty_health),
|
||||
Some(&empty_circuit),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !updated {
|
||||
continue;
|
||||
}
|
||||
payload_items.push(json!({
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"provider_id": key.provider_id,
|
||||
"api_formats": key.api_formats.unwrap_or_else(|| json!([])),
|
||||
}));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"message": format!("已恢复 {} 个 Key", payload_items.len()),
|
||||
"recovered_count": payload_items.len(),
|
||||
"recovered_keys": payload_items,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod keys;
|
||||
mod status;
|
||||
|
||||
pub(super) use self::keys::{
|
||||
build_admin_key_health_payload, build_admin_key_rpm_payload, recover_admin_key_health,
|
||||
recover_all_admin_key_health,
|
||||
};
|
||||
pub(crate) use self::status::build_admin_endpoint_health_status_payload;
|
||||
pub(super) use self::status::build_admin_health_summary_payload;
|
||||
@@ -0,0 +1,305 @@
|
||||
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
|
||||
use crate::handlers::public::{
|
||||
api_format_display_name, build_public_health_timeline, provider_key_api_formats,
|
||||
};
|
||||
use crate::AppState;
|
||||
use aether_data_contracts::repository::candidates::PublicHealthTimelineBucket;
|
||||
use aether_scheduler_core::{is_provider_key_circuit_open, provider_key_health_score};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(crate) async fn build_admin_endpoint_health_status_payload(
|
||||
state: &AppState,
|
||||
lookback_hours: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_request_candidate_data_reader() {
|
||||
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 providers = state
|
||||
.list_provider_catalog_providers(true)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
let provider_ids = providers
|
||||
.iter()
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let active_endpoints = if provider_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
state
|
||||
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|endpoint| endpoint.is_active)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let mut endpoint_ids_by_format = BTreeMap::<String, Vec<String>>::new();
|
||||
let mut endpoint_to_format = BTreeMap::<String, String>::new();
|
||||
let mut provider_ids_by_format = BTreeMap::<String, BTreeSet<String>>::new();
|
||||
let mut active_provider_formats = BTreeSet::<(String, String)>::new();
|
||||
for endpoint in active_endpoints {
|
||||
endpoint_to_format.insert(endpoint.id.clone(), endpoint.api_format.clone());
|
||||
endpoint_ids_by_format
|
||||
.entry(endpoint.api_format.clone())
|
||||
.or_default()
|
||||
.push(endpoint.id);
|
||||
provider_ids_by_format
|
||||
.entry(endpoint.api_format.clone())
|
||||
.or_default()
|
||||
.insert(endpoint.provider_id.clone());
|
||||
active_provider_formats.insert((endpoint.provider_id, endpoint.api_format));
|
||||
}
|
||||
let all_endpoint_ids = endpoint_to_format.keys().cloned().collect::<Vec<_>>();
|
||||
|
||||
let mut total_keys_by_format = BTreeMap::<String, BTreeSet<String>>::new();
|
||||
let mut active_keys_by_format = BTreeMap::<String, BTreeSet<String>>::new();
|
||||
let mut health_scores_by_format = BTreeMap::<String, Vec<f64>>::new();
|
||||
if !provider_ids.is_empty() {
|
||||
let keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
for key in keys {
|
||||
for api_format in provider_key_api_formats(&key) {
|
||||
if !active_provider_formats.contains(&(key.provider_id.clone(), api_format.clone()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
total_keys_by_format
|
||||
.entry(api_format.clone())
|
||||
.or_default()
|
||||
.insert(key.id.clone());
|
||||
if key.is_active && !is_provider_key_circuit_open(&key, &api_format) {
|
||||
let key_health_score =
|
||||
provider_key_health_score(&key, &api_format).unwrap_or(1.0);
|
||||
active_keys_by_format
|
||||
.entry(api_format.clone())
|
||||
.or_default()
|
||||
.insert(key.id.clone());
|
||||
health_scores_by_format
|
||||
.entry(api_format)
|
||||
.or_default()
|
||||
.push(key_health_score);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let timeline_rows = state
|
||||
.aggregate_finalized_request_candidate_timeline_by_endpoint_ids_since(
|
||||
&all_endpoint_ids,
|
||||
since_unix_secs,
|
||||
now_unix_secs,
|
||||
100,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
let mut timeline_by_format =
|
||||
BTreeMap::<String, BTreeMap<u32, PublicHealthTimelineBucket>>::new();
|
||||
for row in timeline_rows {
|
||||
let Some(api_format) = endpoint_to_format.get(&row.endpoint_id) else {
|
||||
continue;
|
||||
};
|
||||
let bucket = timeline_by_format
|
||||
.entry(api_format.clone())
|
||||
.or_default()
|
||||
.entry(row.segment_idx)
|
||||
.or_insert_with(|| PublicHealthTimelineBucket {
|
||||
endpoint_id: api_format.clone(),
|
||||
segment_idx: row.segment_idx,
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
failed_count: 0,
|
||||
min_created_at_unix_secs: None,
|
||||
max_created_at_unix_secs: None,
|
||||
});
|
||||
bucket.total_count += row.total_count;
|
||||
bucket.success_count += row.success_count;
|
||||
bucket.failed_count += row.failed_count;
|
||||
bucket.min_created_at_unix_secs = match (
|
||||
bucket.min_created_at_unix_secs,
|
||||
row.min_created_at_unix_secs,
|
||||
) {
|
||||
(Some(left), Some(right)) => Some(left.min(right)),
|
||||
(None, Some(right)) => Some(right),
|
||||
(left, None) => left,
|
||||
};
|
||||
bucket.max_created_at_unix_secs = match (
|
||||
bucket.max_created_at_unix_secs,
|
||||
row.max_created_at_unix_secs,
|
||||
) {
|
||||
(Some(left), Some(right)) => Some(left.max(right)),
|
||||
(None, Some(right)) => Some(right),
|
||||
(left, None) => left,
|
||||
};
|
||||
}
|
||||
|
||||
let mut payload = endpoint_ids_by_format
|
||||
.into_iter()
|
||||
.map(|(api_format, endpoint_ids)| {
|
||||
let empty_timeline = BTreeMap::new();
|
||||
let timeline_source = timeline_by_format.get(&api_format).unwrap_or(&empty_timeline);
|
||||
let (timeline, time_range_start, time_range_end) =
|
||||
build_public_health_timeline(timeline_source, 100);
|
||||
let healthy_count = timeline.iter().filter(|status| **status == "healthy").count();
|
||||
let warning_count = timeline.iter().filter(|status| **status == "warning").count();
|
||||
let unhealthy_count = timeline.iter().filter(|status| **status == "unhealthy").count();
|
||||
let known_count = healthy_count + warning_count + unhealthy_count;
|
||||
let total_keys = total_keys_by_format
|
||||
.get(&api_format)
|
||||
.map(BTreeSet::len)
|
||||
.unwrap_or(0);
|
||||
let health_score = if known_count > 0 {
|
||||
(healthy_count as f64 + warning_count as f64 * 0.8) / known_count as f64
|
||||
} else if let Some(scores) = health_scores_by_format.get(&api_format) {
|
||||
if scores.is_empty() {
|
||||
if total_keys == 0 { 0.0 } else { 0.1 }
|
||||
} else {
|
||||
scores.iter().copied().sum::<f64>() / scores.len() as f64
|
||||
}
|
||||
} else if total_keys == 0 {
|
||||
0.0
|
||||
} else {
|
||||
0.1
|
||||
};
|
||||
|
||||
json!({
|
||||
"api_format": api_format.clone(),
|
||||
"display_name": api_format_display_name(&api_format),
|
||||
"health_score": health_score,
|
||||
"timeline": timeline,
|
||||
"time_range_start": time_range_start.and_then(unix_secs_to_rfc3339),
|
||||
"time_range_end": time_range_end.or(Some(now_unix_secs)).and_then(unix_secs_to_rfc3339),
|
||||
"total_endpoints": endpoint_ids.len(),
|
||||
"total_keys": total_keys,
|
||||
"active_keys": active_keys_by_format.get(&api_format).map(BTreeSet::len).unwrap_or(0),
|
||||
"provider_count": provider_ids_by_format.get(&api_format).map(BTreeSet::len).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
payload.sort_by(|left, right| {
|
||||
let left_score = left
|
||||
.get("health_score")
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.unwrap_or(0.0);
|
||||
let right_score = right
|
||||
.get("health_score")
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.unwrap_or(0.0);
|
||||
right_score
|
||||
.partial_cmp(&left_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
Some(serde_json::Value::Array(payload))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_health_summary_payload(
|
||||
state: &AppState,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let providers = state
|
||||
.list_provider_catalog_providers(false)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
let provider_ids = providers
|
||||
.iter()
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let endpoints = if provider_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
state
|
||||
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let keys = if provider_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
state
|
||||
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let active_endpoints = endpoints
|
||||
.iter()
|
||||
.filter(|endpoint| endpoint.is_active)
|
||||
.count();
|
||||
let unhealthy_endpoints = endpoints
|
||||
.iter()
|
||||
.filter(|endpoint| endpoint.health_score < 0.5)
|
||||
.count();
|
||||
let active_keys = keys.iter().filter(|key| key.is_active).count();
|
||||
let unhealthy_keys = keys
|
||||
.iter()
|
||||
.filter(|key| {
|
||||
key.health_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|formats| {
|
||||
formats.values().any(|health| {
|
||||
health
|
||||
.get("health_score")
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.unwrap_or(1.0)
|
||||
< 0.5
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
let circuit_open_keys = keys
|
||||
.iter()
|
||||
.filter(|key| {
|
||||
key.circuit_breaker_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|formats| {
|
||||
formats.values().any(|circuit| {
|
||||
circuit
|
||||
.get("open")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
|
||||
Some(json!({
|
||||
"endpoints": {
|
||||
"total": endpoints.len(),
|
||||
"active": active_endpoints,
|
||||
"unhealthy": unhealthy_endpoints,
|
||||
},
|
||||
"keys": {
|
||||
"total": keys.len(),
|
||||
"active": active_keys,
|
||||
"unhealthy": unhealthy_keys,
|
||||
"circuit_open": circuit_open_keys,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub(super) use crate::handlers::admin::provider::endpoint_keys::maybe_build_local_admin_endpoints_keys_response;
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::http::Response;
|
||||
|
||||
mod extractors;
|
||||
mod health;
|
||||
mod health_builders;
|
||||
mod keys;
|
||||
mod routes;
|
||||
mod rpm;
|
||||
|
||||
pub(crate) use self::health_builders::build_admin_endpoint_health_status_payload;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_endpoints_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
if let Some(response) =
|
||||
health::maybe_build_local_admin_endpoints_health_response(state, request_context).await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if let Some(response) =
|
||||
rpm::maybe_build_local_admin_endpoints_rpm_response(state, request_context).await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if let Some(response) =
|
||||
keys::maybe_build_local_admin_endpoints_keys_response(state, request_context, request_body)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if let Some(response) = routes::maybe_build_local_admin_endpoints_routes_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub(super) use crate::handlers::admin::provider::endpoints_admin::maybe_build_local_admin_endpoints_routes_response;
|
||||
@@ -0,0 +1,94 @@
|
||||
use super::extractors::admin_rpm_key_id;
|
||||
use super::health_builders::build_admin_key_rpm_payload;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_endpoints_rpm_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_rpm")
|
||||
&& decision.route_kind.as_deref() == Some("key_rpm")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/rpm/key/")
|
||||
{
|
||||
let Some(key_id) = admin_rpm_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(
|
||||
match build_admin_key_rpm_payload(state, &key_id).await {
|
||||
Some(payload) => Json(payload).into_response(),
|
||||
None => (
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_rpm")
|
||||
&& decision.route_kind.as_deref() == Some("reset_key_rpm")
|
||||
&& request_context.request_method == http::Method::DELETE
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/rpm/key/")
|
||||
{
|
||||
let Some(key_id) = admin_rpm_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(_key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
state.mark_provider_key_rpm_reset(&key_id, now_unix_secs);
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": "RPM 计数已重置"
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
Reference in New Issue
Block a user