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:
fawney19
2026-04-07 02:50:19 +08:00
parent 763ff03a7b
commit 5d96d6673b
732 changed files with 28593 additions and 20666 deletions

View File

@@ -1,15 +1,15 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::{
use super::super::users::{
default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
generate_admin_user_api_key_plaintext, hash_admin_user_api_key, masked_user_api_key_display,
normalize_admin_optional_api_key_name, normalize_admin_user_api_formats,
normalize_admin_user_string_list,
};
use crate::handlers::public::serialize_admin_system_users_export_wallet;
use crate::handlers::{
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, query_param_bool,
query_param_optional_bool, query_param_value,
};
use crate::handlers::admin::system::shared::serialize_admin_system_users_export_wallet;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
@@ -21,23 +21,17 @@ use serde_json::json;
const ADMIN_API_KEYS_DATA_UNAVAILABLE_DETAIL: &str = "Admin standalone API key data unavailable";
#[path = "api_keys/mutation_routes.rs"]
mod admin_api_keys_mutation_routes;
#[path = "api_keys/read_routes.rs"]
mod admin_api_keys_read_routes;
#[path = "api_keys/routes.rs"]
mod admin_api_keys_routes;
#[path = "api_keys/shared.rs"]
mod admin_api_keys_shared;
mod mutation_routes;
mod read_routes;
mod routes;
mod shared;
use self::admin_api_keys_mutation_routes::{
use self::mutation_routes::{
build_admin_create_api_key_response, build_admin_delete_api_key_response,
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
};
use self::admin_api_keys_read_routes::{
build_admin_api_key_detail_response, build_admin_list_api_keys_response,
};
use self::admin_api_keys_shared::{
use self::read_routes::{build_admin_api_key_detail_response, build_admin_list_api_keys_response};
use self::shared::{
admin_api_key_total_tokens_by_ids, admin_api_keys_id_from_path, admin_api_keys_operator_id,
admin_api_keys_parse_limit, admin_api_keys_parse_skip, build_admin_api_key_detail_payload,
build_admin_api_key_list_item_payload, build_admin_api_keys_bad_request_response,
@@ -51,10 +45,6 @@ pub(crate) async fn maybe_build_local_admin_api_keys_response(
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
admin_api_keys_routes::maybe_build_local_admin_api_keys_routes_response(
state,
request_context,
request_body,
)
.await
routes::maybe_build_local_admin_api_keys_routes_response(state, request_context, request_body)
.await
}

View File

@@ -1,4 +1,4 @@
use super::admin_api_keys_shared::{
use super::shared::{
admin_api_key_total_tokens_by_ids, admin_api_keys_id_from_path, admin_api_keys_operator_id,
build_admin_api_key_detail_payload, build_admin_api_keys_bad_request_response,
build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response,
@@ -12,7 +12,7 @@ use super::{
normalize_admin_user_api_formats, normalize_admin_user_string_list,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
@@ -27,7 +27,7 @@ pub(super) async fn build_admin_create_api_key_response(
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_api_key_writer() {
if !state.data.has_auth_api_key_writer() {
return Ok(build_admin_api_keys_data_unavailable_response());
}
@@ -142,7 +142,7 @@ pub(super) async fn build_admin_update_api_key_response(
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_api_key_writer() {
if !state.data.has_auth_api_key_writer() {
return Ok(build_admin_api_keys_data_unavailable_response());
}
@@ -266,7 +266,7 @@ pub(super) async fn build_admin_toggle_api_key_response(
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_api_key_writer() {
if !state.data.has_auth_api_key_writer() {
return Ok(build_admin_api_keys_data_unavailable_response());
}
@@ -290,8 +290,10 @@ pub(super) async fn build_admin_toggle_api_key_response(
};
let Some(snapshot) = state
.read_auth_api_key_snapshots_by_ids(std::slice::from_ref(&api_key_id))
.await?
.data
.list_auth_api_key_snapshots_by_ids(std::slice::from_ref(&api_key_id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.find(|snapshot| snapshot.api_key_id == api_key_id)
else {
@@ -327,7 +329,7 @@ pub(super) async fn build_admin_delete_api_key_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_api_key_writer() {
if !state.data.has_auth_api_key_writer() {
return Ok(build_admin_api_keys_data_unavailable_response());
}

View File

@@ -1,4 +1,4 @@
use super::admin_api_keys_shared::{
use super::shared::{
admin_api_key_total_tokens_by_ids, admin_api_keys_id_from_path, admin_api_keys_parse_limit,
admin_api_keys_parse_skip, build_admin_api_key_detail_payload,
build_admin_api_key_list_item_payload, build_admin_api_keys_bad_request_response,
@@ -6,7 +6,7 @@ use super::admin_api_keys_shared::{
};
use super::{decrypt_catalog_secret_with_fallbacks, query_param_bool, query_param_optional_bool};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
@@ -15,11 +15,14 @@ use axum::{
Json,
};
use serde_json::json;
use std::time::Instant;
use tracing::info;
pub(super) async fn build_admin_list_api_keys_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let handler_started_at = Instant::now();
let query = request_context.request_query_string.as_deref();
let skip = match admin_api_keys_parse_skip(query) {
Ok(value) => value,
@@ -30,24 +33,43 @@ pub(super) async fn build_admin_list_api_keys_response(
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let is_active = query_param_optional_bool(query, "is_active");
let include_usage_summary = query_param_bool(query, "include_usage_summary", false);
let total = state
.count_auth_api_key_export_standalone_records(is_active)
.await? as usize;
let paged_records = state
.list_auth_api_key_export_standalone_records_page(
&aether_data::repository::auth::StandaloneApiKeyExportListQuery {
skip,
limit,
is_active,
},
)
.await?;
let list_query = aether_data::repository::auth::StandaloneApiKeyExportListQuery {
skip,
limit,
is_active,
};
let count_and_page_started_at = Instant::now();
let (total, paged_records) = tokio::try_join!(
state.count_auth_api_key_export_standalone_records(is_active),
state.list_auth_api_key_export_standalone_records_page(&list_query),
)?;
let count_and_page_ms = count_and_page_started_at.elapsed().as_millis() as u64;
let api_key_ids = paged_records
.iter()
.map(|record| record.api_key_id.clone())
.collect::<Vec<_>>();
let total_tokens_by_api_key_id = admin_api_key_total_tokens_by_ids(state, &api_key_ids).await?;
let wallet_lookup_started_at = Instant::now();
let wallets_by_api_key_id = state
.list_wallet_snapshots_by_api_key_ids(&api_key_ids)
.await?
.into_iter()
.filter_map(|wallet| {
wallet
.api_key_id
.clone()
.map(|api_key_id| (api_key_id, wallet))
})
.collect::<std::collections::BTreeMap<_, _>>();
let wallet_lookup_ms = wallet_lookup_started_at.elapsed().as_millis() as u64;
let usage_summary_started_at = Instant::now();
let total_tokens_by_api_key_id = if include_usage_summary {
Some(admin_api_key_total_tokens_by_ids(state, &api_key_ids).await?)
} else {
None
};
let usage_summary_ms = usage_summary_started_at.elapsed().as_millis() as u64;
let api_keys = paged_records
.iter()
@@ -56,16 +78,30 @@ pub(super) async fn build_admin_list_api_keys_response(
state,
record,
total_tokens_by_api_key_id
.get(&record.api_key_id)
.copied()
.unwrap_or(0),
.as_ref()
.and_then(|totals| totals.get(&record.api_key_id).copied()),
wallets_by_api_key_id.get(&record.api_key_id),
)
})
.collect::<Vec<_>>();
info!(
event_name = "admin_api_keys_list_timing",
log_type = "event",
trace_id = request_context.trace_id.as_str(),
returned_items = api_keys.len(),
total,
include_usage_summary,
count_and_page_ms,
wallet_lookup_ms,
usage_summary_ms,
handler_ms = handler_started_at.elapsed().as_millis() as u64,
"measured admin api keys list handler timing"
);
Ok(Json(json!({
"api_keys": api_keys,
"total": total,
"total": total as usize,
"limit": limit,
"skip": skip,
}))
@@ -81,8 +117,10 @@ pub(super) async fn build_admin_api_key_detail_response(
};
if state
.read_auth_api_key_snapshots_by_ids(std::slice::from_ref(&api_key_id))
.await?
.data
.list_auth_api_key_snapshots_by_ids(std::slice::from_ref(&api_key_id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.any(|snapshot| snapshot.api_key_id == api_key_id && !snapshot.api_key_is_standalone)
{

View File

@@ -1,11 +1,9 @@
use super::admin_api_keys_mutation_routes::{
use super::mutation_routes::{
build_admin_create_api_key_response, build_admin_delete_api_key_response,
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
};
use super::admin_api_keys_read_routes::{
build_admin_api_key_detail_response, build_admin_list_api_keys_response,
};
use super::admin_api_keys_shared::build_admin_api_keys_data_unavailable_response;
use super::read_routes::{build_admin_api_key_detail_response, build_admin_list_api_keys_response};
use super::shared::build_admin_api_keys_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{body::Body, http, response::Response};

View File

@@ -1,9 +1,9 @@
use super::ADMIN_API_KEYS_DATA_UNAVAILABLE_DETAIL;
use super::{
format_optional_unix_secs_iso8601, http, json, masked_user_api_key_display, query_param_value,
serialize_admin_system_users_export_wallet, AppState, Body, GatewayError,
GatewayPublicRequestContext, IntoResponse, Json, Response,
};
use crate::handlers::admin::api_keys::ADMIN_API_KEYS_DATA_UNAVAILABLE_DETAIL;
#[derive(Debug, Default, serde::Deserialize)]
pub(super) struct AdminStandaloneApiKeyCreateRequest {
@@ -121,7 +121,8 @@ pub(super) fn admin_api_keys_parse_limit(query: Option<&str>) -> Result<usize, S
pub(super) fn build_admin_api_key_list_item_payload(
state: &AppState,
record: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
total_tokens: u64,
total_tokens: Option<u64>,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
json!({
"id": record.api_key_id,
@@ -142,6 +143,7 @@ pub(super) fn build_admin_api_key_list_item_payload(
"created_at": serde_json::Value::Null,
"updated_at": serde_json::Value::Null,
"auto_delete_on_expiry": record.auto_delete_on_expiry,
"wallet": serialize_admin_system_users_export_wallet(wallet),
})
}

View File

@@ -1,5 +1,5 @@
use super::ldap_shared::*;
use crate::handlers::{
use super::shared::*;
use crate::handlers::admin::shared::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks,
};
use crate::{AppState, GatewayError};

View File

@@ -5,17 +5,14 @@ use axum::{
response::Response,
};
#[path = "ldap/builders.rs"]
mod ldap_builders;
#[path = "ldap/routes.rs"]
mod ldap_routes;
#[path = "ldap/shared.rs"]
mod ldap_shared;
mod builders;
mod routes;
mod shared;
pub(crate) async fn maybe_build_local_admin_ldap_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
ldap_routes::maybe_build_local_admin_ldap_response(state, request_context, request_body).await
routes::maybe_build_local_admin_ldap_response(state, request_context, request_body).await
}

View File

@@ -1,10 +1,10 @@
use super::ldap_builders::{
use super::builders::{
admin_ldap_test_connection, build_admin_ldap_test_config, build_admin_ldap_update_config,
AdminLdapConfigTestRequest, AdminLdapConfigUpdateRequest,
};
use super::ldap_shared::*;
use super::shared::*;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -0,0 +1,14 @@
mod api_keys;
mod ldap;
mod oauth_config;
mod oauth_routes;
mod security;
pub(crate) use self::api_keys::maybe_build_local_admin_api_keys_response;
pub(crate) use self::ldap::maybe_build_local_admin_ldap_response;
pub(crate) use self::oauth_config::{
build_admin_oauth_provider_payload, build_admin_oauth_supported_types_payload,
build_admin_oauth_upsert_record, build_proxy_error_response,
};
pub(crate) use self::oauth_routes::maybe_build_local_admin_oauth_response;
pub(crate) use self::security::maybe_build_local_admin_security_response;

View File

@@ -1,6 +1,4 @@
use crate::handlers::{
encrypt_catalog_secret_with_fallbacks, AdminOAuthProviderUpsertRequest,
};
use crate::handlers::admin::shared::encrypt_catalog_secret_with_fallbacks;
use crate::AppState;
use aether_data::repository::oauth_providers::{
EncryptedSecretUpdate, UpsertOAuthProviderConfigRecord,
@@ -11,9 +9,36 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
use url::Url;
#[derive(Debug, Deserialize)]
pub(crate) struct AdminOAuthProviderUpsertRequest {
pub(crate) display_name: String,
pub(crate) client_id: String,
#[serde(default)]
pub(crate) client_secret: Option<String>,
#[serde(default)]
pub(crate) authorization_url_override: Option<String>,
#[serde(default)]
pub(crate) token_url_override: Option<String>,
#[serde(default)]
pub(crate) userinfo_url_override: Option<String>,
#[serde(default)]
pub(crate) scopes: Option<Vec<String>>,
pub(crate) redirect_uri: String,
pub(crate) frontend_callback_url: String,
#[serde(default)]
pub(crate) attribute_mapping: Option<serde_json::Value>,
#[serde(default)]
pub(crate) extra_config: Option<serde_json::Value>,
#[serde(default)]
pub(crate) is_enabled: bool,
#[serde(default)]
pub(crate) force: bool,
}
pub(crate) fn build_admin_oauth_supported_types_payload() -> Vec<serde_json::Value> {
vec![json!({
"provider_type": "linuxdo",
@@ -65,6 +90,19 @@ pub(crate) fn build_proxy_error_response(
.into_response()
}
pub(crate) fn admin_oauth_provider_type_from_path(request_path: &str) -> Option<String> {
let provider_type = request_path.strip_prefix("/api/admin/oauth/providers/")?;
(!provider_type.is_empty() && !provider_type.contains('/')).then_some(provider_type.to_string())
}
pub(crate) fn admin_oauth_test_provider_type_from_path(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/oauth/providers/")?
.strip_suffix("/test")
.filter(|provider_type| !provider_type.is_empty() && !provider_type.contains('/'))
.map(ToOwned::to_owned)
}
fn admin_oauth_is_supported_provider(provider_type: &str) -> bool {
provider_type.eq_ignore_ascii_case("linuxdo")
}

View File

@@ -1,13 +1,10 @@
use super::super::{
use super::oauth_config::{
admin_oauth_provider_type_from_path, admin_oauth_test_provider_type_from_path,
build_admin_oauth_provider_payload, build_admin_oauth_supported_types_payload,
build_admin_oauth_upsert_record, build_proxy_error_response,
build_admin_oauth_upsert_record, build_proxy_error_response, AdminOAuthProviderUpsertRequest,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::{
admin_oauth_provider_type_from_path, admin_oauth_test_provider_type_from_path,
AdminOAuthProviderUpsertRequest,
};
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
@@ -17,7 +14,7 @@ use axum::{
};
use serde_json::json;
pub(super) async fn maybe_build_local_admin_core_oauth_response(
pub(crate) async fn maybe_build_local_admin_oauth_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,

View File

@@ -1,5 +1,5 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -7,7 +7,7 @@ use super::{
normalize_admin_billing_required_text,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::unix_secs_to_rfc3339;
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
@@ -288,12 +288,12 @@ async fn build_admin_create_dimension_collector_response(
crate::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Dimension collector not found"),
),
crate::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法创建维度采集器"),
),
crate::LocalMutationOutcome::NotFound => Ok(build_admin_billing_not_found_response(
"Dimension collector not found",
)),
crate::LocalMutationOutcome::Unavailable => Ok(build_admin_billing_read_only_response(
"当前为只读模式,无法创建维度采集器",
)),
}
}
@@ -321,15 +321,15 @@ async fn build_admin_update_dimension_collector_response(
crate::LocalMutationOutcome::Applied(record) => {
Ok(Json(build_admin_billing_collector_payload_from_record(&record)).into_response())
}
crate::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Dimension collector not found"),
),
crate::LocalMutationOutcome::NotFound => Ok(build_admin_billing_not_found_response(
"Dimension collector not found",
)),
crate::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法更新维度采集器"),
),
crate::LocalMutationOutcome::Unavailable => Ok(build_admin_billing_read_only_response(
"当前为只读模式,无法更新维度采集器",
)),
}
}

View File

@@ -1,5 +1,5 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{query_param_value, unix_secs_to_rfc3339};
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
@@ -13,12 +13,14 @@ use sqlx::Row;
const ADMIN_BILLING_DATA_UNAVAILABLE_DETAIL: &str = "Admin billing data unavailable";
#[path = "billing/collectors.rs"]
mod billing_collectors;
#[path = "billing/presets.rs"]
mod billing_presets;
#[path = "billing/rules.rs"]
mod billing_rules;
mod collectors;
mod payments;
mod presets;
mod rules;
mod wallets;
pub(crate) use self::payments::maybe_build_local_admin_payments_response;
pub(crate) use self::wallets::maybe_build_local_admin_wallets_response;
fn default_admin_billing_true() -> bool {
true
@@ -256,7 +258,7 @@ pub(crate) async fn maybe_build_local_admin_billing_response(
return Ok(None);
}
if let Some(response) = billing_presets::maybe_build_local_admin_billing_presets_response(
if let Some(response) = presets::maybe_build_local_admin_billing_presets_response(
state,
request_context,
request_body,
@@ -265,16 +267,13 @@ pub(crate) async fn maybe_build_local_admin_billing_response(
{
return Ok(Some(response));
}
if let Some(response) = billing_rules::maybe_build_local_admin_billing_rules_response(
state,
request_context,
request_body,
)
.await?
if let Some(response) =
rules::maybe_build_local_admin_billing_rules_response(state, request_context, request_body)
.await?
{
return Ok(Some(response));
}
if let Some(response) = billing_collectors::maybe_build_local_admin_billing_collectors_response(
if let Some(response) = collectors::maybe_build_local_admin_billing_collectors_response(
state,
request_context,
request_body,

View File

@@ -4,7 +4,7 @@ use super::{
parse_admin_payments_offset,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::query_param_value;
use crate::handlers::admin::shared::query_param_value;
use crate::{AppState, GatewayError};
use axum::{
body::Body,

View File

@@ -2,18 +2,14 @@ use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{body::Body, response::Response};
#[path = "payment/postgres.rs"]
mod callbacks;
mod orders;
#[path = "../../payment/postgres.rs"]
mod payment_postgres;
#[path = "payments/callbacks.rs"]
mod payments_callbacks;
#[path = "payments/orders.rs"]
mod payments_orders;
#[path = "payments/routes.rs"]
mod payments_routes;
#[path = "payments/shared.rs"]
mod payments_shared;
mod routes;
mod shared;
use self::payments_shared::{
use self::shared::{
admin_payment_operator_id, admin_payment_order_id_from_detail_path,
admin_payment_order_id_from_suffix_path, build_admin_payment_callback_payload,
build_admin_payment_callback_payload_from_record, build_admin_payment_order_not_found_response,
@@ -29,6 +25,5 @@ pub(crate) async fn maybe_build_local_admin_payments_response(
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
payments_routes::maybe_build_local_admin_payments_response(state, request_context, request_body)
.await
routes::maybe_build_local_admin_payments_response(state, request_context, request_body).await
}

View File

@@ -8,8 +8,7 @@ use super::{
parse_admin_payments_offset, AdminPaymentOrderCreditRequest,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::query_param_value;
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value};
use crate::{AppState, GatewayError};
use axum::{
body::Body,
@@ -251,18 +250,16 @@ async fn build_admin_payment_fail_order_response(
return Ok(build_admin_payment_order_not_found_response());
};
match state.admin_fail_payment_order(&order_id).await? {
crate::AdminWalletMutationOutcome::Applied(order) => {
Ok(attach_admin_audit_response(
Json(json!({
"order": build_admin_payment_order_payload(&order),
}))
.into_response(),
"admin_payment_order_failed",
"fail_payment_order",
"payment_order",
&order_id,
))
}
crate::AdminWalletMutationOutcome::Applied(order) => Ok(attach_admin_audit_response(
Json(json!({
"order": build_admin_payment_order_payload(&order),
}))
.into_response(),
"admin_payment_order_failed",
"fail_payment_order",
"payment_order",
&order_id,
)),
crate::AdminWalletMutationOutcome::NotFound => {
Ok(build_admin_payment_order_not_found_response())
}

View File

@@ -1,7 +1,7 @@
use super::{
build_admin_payments_data_unavailable_response,
payments_callbacks::maybe_build_local_admin_payment_callbacks_response,
payments_orders::maybe_build_local_admin_payment_orders_response,
callbacks::maybe_build_local_admin_payment_callbacks_response,
orders::maybe_build_local_admin_payment_orders_response,
};
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};

View File

@@ -1,6 +1,6 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{query_param_value, unix_secs_to_rfc3339};
use crate::GatewayError;
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
use crate::{GatewayAdminPaymentCallbackView, GatewayError};
use axum::{
body::Body,
http,
@@ -250,7 +250,7 @@ pub(super) fn build_admin_payment_callback_payload(
}
pub(super) fn build_admin_payment_callback_payload_from_record(
record: &crate::state::AdminPaymentCallbackRecord,
record: &GatewayAdminPaymentCallbackView,
) -> serde_json::Value {
json!({
"id": record.id,

View File

@@ -3,7 +3,7 @@ use super::{
build_admin_billing_read_only_response, normalize_admin_billing_required_text,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
@@ -37,8 +37,7 @@ fn build_admin_billing_presets_payload() -> serde_json::Value {
})
}
fn build_admin_billing_aether_core_collectors(
) -> Vec<crate::AdminBillingCollectorWriteInput> {
fn build_admin_billing_aether_core_collectors() -> Vec<crate::AdminBillingCollectorWriteInput> {
vec![
crate::AdminBillingCollectorWriteInput {
api_format: "OPENAI:CHAT".to_string(),
@@ -237,10 +236,7 @@ fn build_admin_billing_aether_core_collectors(
fn resolve_admin_billing_preset_collectors(
preset: &str,
) -> Option<(
&'static str,
Vec<crate::AdminBillingCollectorWriteInput>,
)> {
) -> Option<(&'static str, Vec<crate::AdminBillingCollectorWriteInput>)> {
let normalized = preset.trim().to_ascii_lowercase();
match normalized.as_str() {
"aether-core" | "default" => {
@@ -323,15 +319,15 @@ async fn build_admin_apply_billing_preset_response(
resolved_preset,
))
}
crate::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法应用计费预设"),
),
crate::LocalMutationOutcome::Unavailable => Ok(build_admin_billing_read_only_response(
"当前为只读模式,无法应用计费预设",
)),
crate::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Billing preset not found"),
),
crate::LocalMutationOutcome::NotFound => Ok(build_admin_billing_not_found_response(
"Billing preset not found",
)),
}
}

View File

@@ -7,7 +7,7 @@ use super::{
normalize_admin_billing_optional_text, normalize_admin_billing_required_text,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::unix_secs_to_rfc3339;
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
@@ -245,12 +245,12 @@ async fn build_admin_create_billing_rule_response(
crate::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Billing rule not found"),
),
crate::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法创建计费规则"),
),
crate::LocalMutationOutcome::NotFound => Ok(build_admin_billing_not_found_response(
"Billing rule not found",
)),
crate::LocalMutationOutcome::Unavailable => Ok(build_admin_billing_read_only_response(
"当前为只读模式,无法创建计费规则",
)),
}
}
@@ -270,15 +270,15 @@ async fn build_admin_update_billing_rule_response(
crate::LocalMutationOutcome::Applied(record) => {
Ok(Json(build_admin_billing_rule_payload_from_record(&record)).into_response())
}
crate::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Billing rule not found"),
),
crate::LocalMutationOutcome::NotFound => Ok(build_admin_billing_not_found_response(
"Billing rule not found",
)),
crate::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法更新计费规则"),
),
crate::LocalMutationOutcome::Unavailable => Ok(build_admin_billing_read_only_response(
"当前为只读模式,无法更新计费规则",
)),
}
}

View File

@@ -2,24 +2,16 @@ use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{body::Body, response::Response};
#[path = "wallets/routes.rs"]
mod admin_wallets_routes;
#[path = "wallets/shared.rs"]
mod admin_wallets_shared;
#[path = "wallets/mutations.rs"]
mod mutations;
#[path = "wallets/reads.rs"]
mod reads;
mod routes;
mod shared;
pub(crate) async fn maybe_build_local_admin_wallets_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
admin_wallets_routes::maybe_build_local_admin_wallets_routes_response(
state,
request_context,
request_body,
)
.await
routes::maybe_build_local_admin_wallets_routes_response(state, request_context, request_body)
.await
}

View File

@@ -1,4 +1,4 @@
use super::admin_wallets_shared::{
use super::shared::{
admin_wallet_id_from_suffix_path, admin_wallet_operator_id,
admin_wallet_refund_ids_from_suffix_path, build_admin_wallet_not_found_response,
build_admin_wallet_payment_order_payload, build_admin_wallet_refund_not_found_response,
@@ -14,8 +14,7 @@ use super::admin_wallets_shared::{
ADMIN_WALLETS_API_KEY_REFUND_DETAIL,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::unix_secs_to_rfc3339;
use crate::handlers::admin::shared::{attach_admin_audit_response, unix_secs_to_rfc3339};
use crate::{AppState, GatewayError};
use axum::{
body::Body,

View File

@@ -1,4 +1,4 @@
use super::admin_wallets_shared::{
use super::shared::{
admin_wallet_id_from_detail_path, admin_wallet_id_from_suffix_path,
build_admin_wallet_not_found_response, build_admin_wallet_refund_payload,
build_admin_wallet_summary_payload, build_admin_wallets_bad_request_response,
@@ -7,7 +7,7 @@ use super::admin_wallets_shared::{
ADMIN_WALLETS_API_KEY_REFUND_DETAIL,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{query_param_value, unix_secs_to_rfc3339};
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
use crate::{AppState, GatewayError};
use axum::{
body::Body,

View File

@@ -1,4 +1,3 @@
use super::admin_wallets_shared::build_admin_wallets_data_unavailable_response;
use super::mutations::{
build_admin_wallet_adjust_response, build_admin_wallet_complete_refund_response,
build_admin_wallet_fail_refund_response, build_admin_wallet_process_refund_response,
@@ -9,6 +8,7 @@ use super::reads::{
build_admin_wallet_list_response, build_admin_wallet_refund_requests_response,
build_admin_wallet_refunds_response, build_admin_wallet_transactions_response,
};
use super::shared::build_admin_wallets_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{body::Body, http, response::Response};

View File

@@ -1,5 +1,5 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{query_param_value, unix_secs_to_rfc3339};
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
use crate::{AppState, GatewayError};
use axum::{
body::Body,
@@ -495,8 +495,10 @@ pub(super) async fn resolve_admin_wallet_owner_summary(
} else if let Some(api_key_id) = wallet.api_key_id.as_deref() {
let api_key_ids = vec![api_key_id.to_string()];
let snapshots = state
.read_auth_api_key_snapshots_by_ids(&api_key_ids)
.await?;
.data
.list_auth_api_key_snapshots_by_ids(&api_key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let owner_name = snapshots
.into_iter()
.find(|snapshot| snapshot.api_key_id == api_key_id)

View File

@@ -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())
}
}

View File

@@ -1,13 +1,13 @@
use super::super::{
admin_health_key_id, admin_recover_key_id, 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 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::handlers::query_param_value;
use crate::{AppState, GatewayError};
use axum::{
body::Body,

View File

@@ -1,6 +1,6 @@
use crate::handlers::public::provider_key_api_formats;
use crate::scheduler::count_recent_rpm_requests_for_provider_key_since;
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};

View File

@@ -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;

View File

@@ -1,10 +1,10 @@
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::handlers::unix_secs_to_rfc3339;
use crate::scheduler::{is_provider_key_circuit_open, provider_key_health_score};
use crate::AppState;
use aether_data::repository::candidates::PublicHealthTimelineBucket;
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};

View File

@@ -0,0 +1 @@
pub(super) use crate::handlers::admin::provider::endpoint_keys::maybe_build_local_admin_endpoints_keys_response;

View File

@@ -3,14 +3,14 @@ use crate::{AppState, GatewayError};
use axum::body::{Body, Bytes};
use axum::http::Response;
#[path = "endpoints/health.rs"]
mod endpoints_health;
#[path = "endpoints/keys.rs"]
mod endpoints_keys;
#[path = "endpoints/routes.rs"]
mod endpoints_routes;
#[path = "endpoints/rpm.rs"]
mod endpoints_rpm;
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,
@@ -18,30 +18,25 @@ pub(crate) async fn maybe_build_local_admin_endpoints_response(
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
if let Some(response) =
endpoints_health::maybe_build_local_admin_endpoints_health_response(state, request_context)
.await?
health::maybe_build_local_admin_endpoints_health_response(state, request_context).await?
{
return Ok(Some(response));
}
if let Some(response) =
endpoints_rpm::maybe_build_local_admin_endpoints_rpm_response(state, request_context)
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) = endpoints_keys::maybe_build_local_admin_endpoints_keys_response(
state,
request_context,
request_body,
)
.await?
{
return Ok(Some(response));
}
if let Some(response) = endpoints_routes::maybe_build_local_admin_endpoints_routes_response(
if let Some(response) = routes::maybe_build_local_admin_endpoints_routes_response(
state,
request_context,
request_body,

View File

@@ -0,0 +1 @@
pub(super) use crate::handlers::admin::provider::endpoints_admin::maybe_build_local_admin_endpoints_routes_response;

View File

@@ -1,4 +1,5 @@
use super::super::{admin_rpm_key_id, build_admin_key_rpm_payload};
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::{

View File

@@ -1,18 +0,0 @@
#[path = "endpoints_health_helpers/endpoints.rs"]
mod endpoints;
#[path = "endpoints_health_helpers/keys.rs"]
mod keys;
#[path = "endpoints_health_helpers/status.rs"]
mod status;
pub(crate) use self::endpoints::{
build_admin_create_provider_endpoint_record, build_admin_endpoint_payload,
build_admin_provider_endpoints_payload, build_admin_update_provider_endpoint_record,
};
pub(crate) 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, build_admin_health_summary_payload,
};

View File

@@ -1,6 +1,6 @@
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use aether_data::repository::provider_catalog::StoredProviderCatalogKey;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use axum::body::{Body, Bytes};
use axum::http::{self, Response};
use axum::response::IntoResponse;
@@ -14,10 +14,8 @@ const ADMIN_GEMINI_FILES_DEFAULT_PAGE: usize = 1;
const ADMIN_GEMINI_FILES_DEFAULT_PAGE_SIZE: usize = 20;
const ADMIN_GEMINI_FILES_MAX_PAGE_SIZE: usize = 100;
#[path = "gemini_files/read_routes.rs"]
mod admin_gemini_files_read_routes;
#[path = "gemini_files/upload.rs"]
mod admin_gemini_files_upload;
mod read_routes;
mod upload;
pub(crate) async fn maybe_build_local_admin_gemini_files_response(
state: &AppState,
@@ -32,22 +30,18 @@ pub(crate) async fn maybe_build_local_admin_gemini_files_response(
}
if let Some(response) =
admin_gemini_files_read_routes::maybe_build_local_admin_gemini_files_read_response(
state,
request_context,
)
.await?
read_routes::maybe_build_local_admin_gemini_files_read_response(state, request_context)
.await?
{
return Ok(Some(response));
}
if let Some(response) =
admin_gemini_files_upload::maybe_build_local_admin_gemini_files_upload_response(
state,
request_context,
request_body,
)
.await?
if let Some(response) = upload::maybe_build_local_admin_gemini_files_upload_response(
state,
request_context,
request_body,
)
.await?
{
return Ok(Some(response));
}

View File

@@ -5,12 +5,13 @@ use super::{
ADMIN_GEMINI_FILES_MAX_PAGE_SIZE,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{
use crate::handlers::admin::shared::{
admin_gemini_file_mapping_id_from_path, is_admin_gemini_files_capable_keys_root,
is_admin_gemini_files_mappings_root, is_admin_gemini_files_stats_root,
query_param_optional_bool, query_param_value, unix_secs_to_rfc3339,
};
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use axum::body::Body;
use axum::http::{self, Response};
use axum::response::IntoResponse;
@@ -113,8 +114,7 @@ async fn admin_gemini_files_capable_keys(
async fn admin_gemini_files_all_keys(
state: &AppState,
) -> Result<Vec<aether_data::repository::provider_catalog::StoredProviderCatalogKey>, GatewayError>
{
) -> Result<Vec<StoredProviderCatalogKey>, GatewayError> {
let providers = state.list_provider_catalog_providers(false).await?;
let provider_ids = providers
.into_iter()

View File

@@ -3,10 +3,10 @@ use super::{
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{is_admin_gemini_files_upload_root, query_param_value};
use crate::handlers::admin::shared::{is_admin_gemini_files_upload_root, query_param_value};
use crate::{AppState, GatewayError};
use aether_contracts::{ExecutionPlan, ExecutionResult, RequestBody};
use aether_data::repository::provider_catalog::{
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use axum::body::{Body, Bytes};
@@ -443,11 +443,12 @@ async fn admin_gemini_files_upload_single_key(
client_api_format: "gemini:files".to_string(),
provider_api_format: "gemini:files".to_string(),
model_name: Some("gemini-files".to_string()),
proxy: crate::provider_transport::resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await,
proxy: crate::provider_transport::resolve_transport_proxy_snapshot_with_tunnel_affinity(
state, &transport,
)
.await,
tls_profile: crate::provider_transport::resolve_transport_tls_profile(&transport),
timeouts: crate::provider_transport::resolve_transport_execution_timeouts(
&transport,
),
timeouts: crate::provider_transport::resolve_transport_execution_timeouts(&transport),
};
let result = admin_gemini_files_execute_upload_plan(state, trace_id, &plan)
@@ -484,12 +485,7 @@ async fn admin_gemini_files_execute_upload_plan(
trace_id: &str,
plan: &ExecutionPlan,
) -> Result<ExecutionResult, GatewayError> {
crate::execution_runtime::execute_execution_runtime_sync_plan(
state,
Some(trace_id),
plan,
)
.await
crate::execution_runtime::execute_execution_runtime_sync_plan(state, Some(trace_id), plan).await
}
fn admin_gemini_files_execution_json_body(result: &ExecutionResult) -> Option<serde_json::Value> {

View File

@@ -0,0 +1,5 @@
mod gemini_files;
mod video_tasks;
pub(crate) use self::gemini_files::maybe_build_local_admin_gemini_files_response;
pub(crate) use self::video_tasks::maybe_build_local_admin_video_tasks_response;

View File

@@ -1,4 +1,5 @@
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::video_tasks::{StoredVideoTask, VideoTaskStatus};
use axum::http;
use chrono::{SecondsFormat, Utc};
use serde_json::json;
@@ -6,19 +7,17 @@ use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::time::{SystemTime, UNIX_EPOCH};
pub(super) fn admin_video_task_status_name(
status: aether_data::repository::video_tasks::VideoTaskStatus,
) -> &'static str {
pub(super) fn admin_video_task_status_name(status: VideoTaskStatus) -> &'static str {
match status {
aether_data::repository::video_tasks::VideoTaskStatus::Pending => "pending",
aether_data::repository::video_tasks::VideoTaskStatus::Submitted => "submitted",
aether_data::repository::video_tasks::VideoTaskStatus::Queued => "queued",
aether_data::repository::video_tasks::VideoTaskStatus::Processing => "processing",
aether_data::repository::video_tasks::VideoTaskStatus::Completed => "completed",
aether_data::repository::video_tasks::VideoTaskStatus::Failed => "failed",
aether_data::repository::video_tasks::VideoTaskStatus::Cancelled => "cancelled",
aether_data::repository::video_tasks::VideoTaskStatus::Expired => "expired",
aether_data::repository::video_tasks::VideoTaskStatus::Deleted => "deleted",
VideoTaskStatus::Pending => "pending",
VideoTaskStatus::Submitted => "submitted",
VideoTaskStatus::Queued => "queued",
VideoTaskStatus::Processing => "processing",
VideoTaskStatus::Completed => "completed",
VideoTaskStatus::Failed => "failed",
VideoTaskStatus::Cancelled => "cancelled",
VideoTaskStatus::Expired => "expired",
VideoTaskStatus::Deleted => "deleted",
}
}
@@ -43,7 +42,7 @@ pub(super) fn truncate_admin_video_task_prompt(prompt: Option<&str>) -> Option<S
pub(super) async fn build_admin_video_task_provider_names(
state: &AppState,
tasks: &[aether_data::repository::video_tasks::StoredVideoTask],
tasks: &[StoredVideoTask],
) -> Result<BTreeMap<String, String>, GatewayError> {
let provider_ids = tasks
.iter()
@@ -64,7 +63,7 @@ pub(super) async fn build_admin_video_task_provider_names(
}
pub(super) fn build_admin_video_task_list_item(
task: &aether_data::repository::video_tasks::StoredVideoTask,
task: &StoredVideoTask,
provider_names: &BTreeMap<String, String>,
) -> Value {
let provider_name = task

View File

@@ -2,14 +2,12 @@ use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{body::Body, response::Response};
#[path = "video_tasks/builders.rs"]
mod video_tasks_builders;
#[path = "video_tasks/routes.rs"]
mod video_tasks_routes;
mod builders;
mod routes;
pub(crate) async fn maybe_build_local_admin_video_tasks_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Option<Response<Body>>, GatewayError> {
video_tasks_routes::maybe_build_local_admin_video_tasks_response(state, request_context).await
routes::maybe_build_local_admin_video_tasks_response(state, request_context).await
}

View File

@@ -1,7 +1,8 @@
use crate::async_task;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::query_param_value;
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value};
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::video_tasks::{VideoTaskQueryFilter, VideoTaskStatus};
use axum::{
body::Body,
http,
@@ -10,8 +11,8 @@ use axum::{
};
use serde_json::json;
use super::super::{attach_admin_audit_response, build_proxy_error_response};
use super::video_tasks_builders::{
use super::super::super::auth::build_proxy_error_response;
use super::builders::{
admin_video_task_detail_id_from_path, admin_video_task_nested_id_from_path,
admin_video_task_status_name, admin_video_task_timestamp, build_admin_video_task_list_item,
build_admin_video_task_provider_names, current_admin_video_task_unix_secs,
@@ -36,24 +37,20 @@ pub(super) async fn maybe_build_local_admin_video_tasks_response(
{
let status =
match query_param_value(request_context.request_query_string.as_deref(), "status") {
Some(value) => {
match aether_data::repository::video_tasks::VideoTaskStatus::from_database(
&value,
) {
Ok(status) => Some(status),
Err(err) => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
err.to_string(),
None,
)));
}
Some(value) => match VideoTaskStatus::from_database(&value) {
Ok(status) => Some(status),
Err(err) => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
err.to_string(),
None,
)));
}
}
},
None => None,
};
let filter = aether_data::repository::video_tasks::VideoTaskQueryFilter {
let filter = VideoTaskQueryFilter {
user_id: query_param_value(request_context.request_query_string.as_deref(), "user_id"),
status,
model_substring: query_param_value(
@@ -93,18 +90,15 @@ pub(super) async fn maybe_build_local_admin_video_tasks_response(
"/api/admin/video-tasks/stats" | "/api/admin/video-tasks/stats/"
)
{
let filter = aether_data::repository::video_tasks::VideoTaskQueryFilter {
let filter = VideoTaskQueryFilter {
user_id: None,
status: None,
model_substring: None,
client_api_format: None,
};
let stats = async_task::read_video_task_stats(
state,
&filter,
current_admin_video_task_unix_secs(),
)
.await?;
let stats =
async_task::read_video_task_stats(state, &filter, current_admin_video_task_unix_secs())
.await?;
let active_users = state.count_distinct_video_task_users(&filter).await?;
return Ok(Some(
Json(json!({
@@ -179,8 +173,7 @@ pub(super) async fn maybe_build_local_admin_video_tasks_response(
else {
return Ok(None);
};
let Some(task) = async_task::read_video_task_detail(state, task_id).await?
else {
let Some(task) = async_task::read_video_task_detail(state, task_id).await? else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
@@ -336,9 +329,7 @@ pub(super) async fn maybe_build_local_admin_video_tasks_response(
.into_response(),
));
}
let Some(source) =
async_task::read_video_task_video_source(state, task_id).await?
else {
let Some(source) = async_task::read_video_task_video_source(state, task_id).await? else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,

View File

@@ -1,7 +0,0 @@
#[path = "global_models/helpers.rs"]
mod global_models_helpers;
#[path = "global_models/routes.rs"]
mod global_models_routes;
use global_models_helpers::*;
pub(crate) use global_models_routes::maybe_build_local_admin_global_models_response;

View File

@@ -1,238 +0,0 @@
use crate::audit::attach_admin_audit_event;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{json_string_list, unix_secs_to_rfc3339};
use aether_data::repository::provider_catalog::StoredProviderCatalogKey;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::collections::BTreeMap;
pub(crate) fn build_unhandled_admin_proxy_response(
request_context: &GatewayPublicRequestContext,
) -> Response<Body> {
let decision = request_context.control_decision.as_ref();
(
http::StatusCode::NOT_IMPLEMENTED,
Json(json!({
"detail": "admin proxy route not implemented in rust frontdoor",
"route_family": decision.and_then(|value| value.route_family.as_deref()),
"route_kind": decision.and_then(|value| value.route_kind.as_deref()),
"request_path": request_context.request_path,
})),
)
.into_response()
}
pub(crate) fn build_admin_proxy_auth_required_response(
request_context: &GatewayPublicRequestContext,
) -> Response<Body> {
let decision = request_context.control_decision.as_ref();
(
http::StatusCode::UNAUTHORIZED,
Json(json!({
"detail": "admin authentication required",
"route_family": decision.and_then(|value| value.route_family.as_deref()),
"route_kind": decision.and_then(|value| value.route_kind.as_deref()),
"request_path": request_context.request_path,
})),
)
.into_response()
}
pub(crate) fn attach_admin_audit_response(
mut response: Response<Body>,
event_name: &'static str,
action: &'static str,
target_type: &'static str,
target_id: &str,
) -> Response<Body> {
attach_admin_audit_event(&mut response, event_name, action, target_type, target_id);
response
}
pub(crate) fn provider_catalog_key_supports_format(
key: &StoredProviderCatalogKey,
api_format: &str,
) -> bool {
let Some(value) = key.api_formats.as_ref() else {
return true;
};
let Some(values) = value.as_array() else {
return true;
};
values
.iter()
.filter_map(serde_json::Value::as_str)
.any(|candidate| candidate.trim().eq_ignore_ascii_case(api_format))
}
pub(crate) fn key_api_formats_without_entry(
key: &StoredProviderCatalogKey,
api_format: &str,
) -> Option<Vec<String>> {
let current_formats = json_string_list(key.api_formats.as_ref());
if !current_formats
.iter()
.any(|candidate| candidate == api_format)
{
return None;
}
Some(
current_formats
.into_iter()
.filter(|candidate| candidate != api_format)
.collect(),
)
}
pub(crate) 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(crate) 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(crate) 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())
}
}
pub(crate) fn admin_provider_id_for_endpoints(request_path: &str) -> Option<String> {
let raw = request_path.strip_prefix("/api/admin/endpoints/providers/")?;
let raw = raw.strip_suffix("/endpoints")?;
let normalized = raw.trim().trim_matches('/');
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
pub(crate) fn admin_endpoint_id(request_path: &str) -> Option<String> {
let raw = request_path.strip_prefix("/api/admin/endpoints/")?;
let normalized = raw.trim().trim_matches('/');
if normalized.is_empty() || normalized.contains('/') {
None
} else {
Some(normalized.to_string())
}
}
pub(crate) fn admin_default_body_rules_api_format(request_path: &str) -> Option<String> {
let raw = request_path.strip_prefix("/api/admin/endpoints/defaults/")?;
let raw = raw.strip_suffix("/body-rules")?;
let normalized = raw.trim().trim_matches('/');
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
pub(crate) fn masked_proxy_value(proxy: Option<&serde_json::Value>) -> serde_json::Value {
let Some(proxy) = proxy.and_then(serde_json::Value::as_object) else {
return serde_json::Value::Null;
};
let mut masked = proxy.clone();
if masked
.get("password")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
{
masked.insert("password".to_string(), json!("***"));
}
serde_json::Value::Object(masked)
}
pub(crate) fn json_truthy(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Null => false,
serde_json::Value::Bool(value) => *value,
serde_json::Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
serde_json::Value::String(value) => !value.trim().is_empty(),
serde_json::Value::Array(value) => !value.is_empty(),
serde_json::Value::Object(value) => !value.is_empty(),
}
}
pub(crate) fn endpoint_timestamp_or_now(
value: Option<u64>,
now_unix_secs: u64,
) -> serde_json::Value {
unix_secs_to_rfc3339(value.unwrap_or(now_unix_secs))
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null)
}
pub(crate) fn endpoint_key_counts_by_format(
keys: &[aether_data::repository::provider_catalog::StoredProviderCatalogKey],
) -> (BTreeMap<String, usize>, BTreeMap<String, usize>) {
let mut total = BTreeMap::new();
let mut active = BTreeMap::new();
for key in keys {
let Some(formats) = key
.api_formats
.as_ref()
.and_then(serde_json::Value::as_array)
else {
continue;
};
for api_format in formats.iter().filter_map(serde_json::Value::as_str) {
*total.entry(api_format.to_string()).or_insert(0) += 1;
if key.is_active {
*active.entry(api_format.to_string()).or_insert(0) += 1;
}
}
}
(total, active)
}
pub(crate) fn build_admin_provider_endpoint_response(
endpoint: &aether_data::repository::provider_catalog::StoredProviderCatalogEndpoint,
provider_name: &str,
total_keys: usize,
active_keys: usize,
now_unix_secs: u64,
) -> serde_json::Value {
json!({
"id": endpoint.id,
"provider_id": endpoint.provider_id,
"provider_name": provider_name,
"api_format": endpoint.api_format,
"base_url": endpoint.base_url,
"custom_path": endpoint.custom_path,
"header_rules": endpoint.header_rules,
"body_rules": endpoint.body_rules,
"max_retries": endpoint.max_retries.unwrap_or(2),
"is_active": endpoint.is_active,
"config": endpoint.config,
"proxy": masked_proxy_value(endpoint.proxy.as_ref()),
"format_acceptance_config": endpoint.format_acceptance_config,
"total_keys": total_keys,
"active_keys": active_keys,
"created_at": endpoint_timestamp_or_now(endpoint.created_at_unix_secs, now_unix_secs),
"updated_at": endpoint_timestamp_or_now(endpoint.updated_at_unix_secs, now_unix_secs),
})
}

View File

@@ -1,119 +1,11 @@
use super::{
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_refresh_quota,
admin_reveal_key_id, admin_update_key_id, build_admin_provider_key_response,
INTERNAL_GATEWAY_PATH_PREFIXES,
};
pub(crate) mod shared;
mod adaptive;
mod api_keys;
mod billing;
mod catalog_write_helpers;
mod core;
mod endpoints;
pub(crate) mod endpoints_health_helpers;
mod gemini_files;
mod global_models;
mod ldap;
pub(crate) mod misc_helpers;
mod models_helpers;
mod monitoring;
mod oauth_helpers;
mod payments;
mod pool;
mod provider_models;
#[path = "provider_oauth/dispatch.rs"]
mod provider_oauth_dispatch;
#[path = "provider_oauth/quota.rs"]
mod provider_oauth_quota;
#[path = "provider_oauth/refresh.rs"]
pub(crate) mod provider_oauth_refresh;
#[path = "provider_oauth/state.rs"]
mod provider_oauth_state;
pub(crate) mod provider_ops;
mod provider_query;
mod provider_strategy;
mod providers;
mod providers_helpers;
mod proxy_nodes;
mod security;
pub(crate) mod stats;
mod usage;
mod users;
mod video_tasks;
mod wallets;
pub(crate) use self::adaptive::maybe_build_local_admin_adaptive_response;
use self::adaptive::*;
pub(crate) use self::api_keys::maybe_build_local_admin_api_keys_response;
use self::api_keys::*;
pub(crate) use self::billing::maybe_build_local_admin_billing_response;
use self::billing::*;
use self::catalog_write_helpers::*;
pub(crate) use self::core::maybe_build_local_admin_core_response;
use self::core::*;
pub(crate) use self::endpoints::maybe_build_local_admin_endpoints_response;
use self::endpoints::*;
use self::endpoints_health_helpers::{
build_admin_create_provider_endpoint_record, build_admin_endpoint_health_status_payload,
build_admin_endpoint_payload, build_admin_health_summary_payload,
build_admin_key_health_payload, build_admin_key_rpm_payload,
build_admin_provider_endpoints_payload, build_admin_update_provider_endpoint_record,
recover_admin_key_health, recover_all_admin_key_health,
};
pub(crate) use self::gemini_files::maybe_build_local_admin_gemini_files_response;
use self::gemini_files::*;
pub(crate) use self::global_models::maybe_build_local_admin_global_models_response;
use self::global_models::*;
pub(crate) use self::ldap::maybe_build_local_admin_ldap_response;
use self::ldap::*;
use self::misc_helpers::{
admin_default_body_rules_api_format, admin_endpoint_id, admin_health_key_id,
admin_provider_id_for_endpoints, admin_recover_key_id, admin_rpm_key_id,
attach_admin_audit_response, build_admin_provider_endpoint_response,
endpoint_key_counts_by_format, endpoint_timestamp_or_now, json_truthy,
key_api_formats_without_entry,
};
use self::models_helpers::*;
pub(crate) use self::monitoring::maybe_build_local_admin_monitoring_response;
use self::oauth_helpers::*;
pub(crate) use self::payments::maybe_build_local_admin_payments_response;
use self::payments::*;
pub(crate) use self::pool::maybe_build_local_admin_pool_response;
use self::pool::*;
pub(crate) use self::provider_models::maybe_build_local_admin_provider_models_response;
use self::provider_models::*;
pub(crate) use self::provider_oauth_dispatch::maybe_build_local_admin_provider_oauth_response;
use self::provider_oauth_dispatch::*;
use self::provider_oauth_quota::{
normalize_string_id_list, refresh_antigravity_provider_quota_locally,
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
};
use self::provider_oauth_refresh::{
build_internal_control_error_response, normalize_provider_oauth_refresh_error_message,
};
pub(crate) use self::provider_ops::admin_provider_ops_local_action_response;
pub(crate) use self::provider_ops::maybe_build_local_admin_provider_ops_response;
pub(crate) use self::provider_query::maybe_build_local_admin_provider_query_response;
use self::provider_query::*;
pub(crate) use self::provider_strategy::maybe_build_local_admin_provider_strategy_response;
use self::provider_strategy::*;
pub(crate) use self::providers::maybe_build_local_admin_providers_response;
use self::providers::*;
use self::providers_helpers::*;
pub(crate) use self::proxy_nodes::maybe_build_local_admin_proxy_nodes_response;
use self::proxy_nodes::*;
pub(crate) use self::security::maybe_build_local_admin_security_response;
use self::security::*;
pub(crate) use self::stats::maybe_build_local_admin_stats_response;
use self::stats::{
aggregate_usage_stats, list_usage_for_optional_range, parse_bounded_u32, round_to,
AdminStatsTimeRange, AdminStatsUsageFilter,
};
pub(crate) use self::usage::maybe_build_local_admin_usage_response;
use self::usage::*;
pub(crate) use self::users::maybe_build_local_admin_users_response;
use self::users::*;
pub(crate) use self::video_tasks::maybe_build_local_admin_video_tasks_response;
use self::video_tasks::*;
pub(crate) use self::wallets::maybe_build_local_admin_wallets_response;
use self::wallets::*;
pub(crate) mod auth;
pub(crate) mod billing;
pub(crate) mod endpoint;
pub(crate) mod features;
pub(crate) mod model;
pub(crate) mod observability;
pub(crate) mod provider;
pub(crate) mod system;
pub(crate) mod users;

View File

@@ -1,9 +1,8 @@
use super::super::{
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::model::{
build_admin_model_catalog_payload, clear_admin_external_models_cache,
read_admin_external_models_cache,
};
use super::build_admin_model_catalog_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
@@ -13,7 +12,17 @@ use axum::{
};
use serde_json::json;
pub(super) async fn maybe_build_local_admin_core_model_response(
const ADMIN_MODEL_CATALOG_DATA_UNAVAILABLE_DETAIL: &str = "Admin model catalog data unavailable";
fn build_admin_model_catalog_data_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": ADMIN_MODEL_CATALOG_DATA_UNAVAILABLE_DETAIL })),
)
.into_response()
}
pub(crate) async fn maybe_build_local_admin_model_catalog_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Option<Response<Body>>, GatewayError> {

View File

@@ -0,0 +1,231 @@
use crate::handlers::shared::mark_external_models_official_providers;
use crate::{AppState, GatewayError};
use serde_json::json;
use tracing::warn;
const ADMIN_EXTERNAL_MODELS_CACHE_KEY: &str = "aether:external:models_dev";
const ADMIN_EXTERNAL_MODELS_CACHE_TTL_SECS: u64 = 15 * 60;
const ADMIN_EXTERNAL_MODELS_SOURCE_URL_ENV: &str = "AETHER_GATEWAY_EXTERNAL_MODELS_URL";
const ADMIN_EXTERNAL_MODELS_SOURCE_URL_DEFAULT: &str = "https://models.dev/api.json";
fn admin_external_models_source_url() -> String {
std::env::var(ADMIN_EXTERNAL_MODELS_SOURCE_URL_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ADMIN_EXTERNAL_MODELS_SOURCE_URL_DEFAULT.to_string())
}
fn normalize_admin_external_models_payload(payload: serde_json::Value) -> serde_json::Value {
mark_external_models_official_providers(&payload).unwrap_or(payload)
}
async fn store_admin_external_models_cache(
state: &AppState,
payload: &serde_json::Value,
) -> Result<(), GatewayError> {
let Some(runner) = state.redis_kv_runner() else {
return Ok(());
};
let serialized =
serde_json::to_string(payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
runner
.setex(
ADMIN_EXTERNAL_MODELS_CACHE_KEY,
&serialized,
Some(ADMIN_EXTERNAL_MODELS_CACHE_TTL_SECS),
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(())
}
async fn fetch_admin_external_models_from_source(
state: &AppState,
) -> Result<serde_json::Value, GatewayError> {
let url = admin_external_models_source_url();
let response = state
.client
.get(&url)
.send()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let response = response
.error_for_status()
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let payload = response
.json::<serde_json::Value>()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(normalize_admin_external_models_payload(payload))
}
pub(crate) async fn read_admin_external_models_cache(
state: &AppState,
) -> Result<Option<serde_json::Value>, GatewayError> {
if let Some(runner) = state.redis_kv_runner() {
match runner.client().get_multiplexed_async_connection().await {
Ok(mut connection) => {
let namespaced_key = runner.keyspace().key(ADMIN_EXTERNAL_MODELS_CACHE_KEY);
match redis::cmd("GET")
.arg(&namespaced_key)
.query_async::<Option<String>>(&mut connection)
.await
{
Ok(Some(raw)) => match serde_json::from_str::<serde_json::Value>(&raw) {
Ok(payload) => {
let payload = normalize_admin_external_models_payload(payload);
if let Err(err) =
store_admin_external_models_cache(state, &payload).await
{
warn!(error = ?err, "failed to refresh external models cache ttl");
}
return Ok(Some(payload));
}
Err(err) => {
warn!(error = %err, "failed to parse cached external models payload");
}
},
Ok(None) => {}
Err(err) => {
warn!(error = %err, "failed to read external models cache");
}
}
}
Err(err) => {
warn!(error = %err, "failed to connect to redis for external models cache");
}
}
}
match fetch_admin_external_models_from_source(state).await {
Ok(payload) => {
if let Err(err) = store_admin_external_models_cache(state, &payload).await {
warn!(error = ?err, "failed to store fetched external models cache");
}
Ok(Some(payload))
}
Err(err) => {
warn!(error = ?err, "failed to fetch external models catalog");
Ok(None)
}
}
}
pub(crate) async fn clear_admin_external_models_cache(
state: &AppState,
) -> Result<serde_json::Value, GatewayError> {
let Some(runner) = state.redis_kv_runner() else {
return Ok(json!({
"cleared": false,
"message": "Redis 未启用",
}));
};
let deleted = runner
.del(ADMIN_EXTERNAL_MODELS_CACHE_KEY)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(json!({
"cleared": deleted > 0,
"message": if deleted > 0 { "缓存已清除" } else { "缓存不存在" },
}))
}
#[cfg(test)]
mod tests {
use super::{
admin_external_models_source_url, normalize_admin_external_models_payload,
read_admin_external_models_cache, ADMIN_EXTERNAL_MODELS_SOURCE_URL_ENV,
};
use crate::tests::{start_server, AppState};
use axum::routing::get;
use axum::{Json, Router};
use serde_json::json;
struct TestEnvVarGuard {
key: &'static str,
previous: Option<String>,
}
impl Drop for TestEnvVarGuard {
fn drop(&mut self) {
if let Some(previous) = self.previous.as_deref() {
std::env::set_var(self.key, previous);
} else {
std::env::remove_var(self.key);
}
}
}
fn set_test_env_var(key: &'static str, value: &str) -> TestEnvVarGuard {
let previous = std::env::var(key).ok();
std::env::set_var(key, value);
TestEnvVarGuard { key, previous }
}
#[test]
fn normalizes_external_models_payload_with_official_flags() {
let payload = json!({
"openai": {
"name": "OpenAI",
"models": {}
},
"openrouter": {
"name": "OpenRouter",
"models": {}
}
});
let normalized = normalize_admin_external_models_payload(payload);
assert_eq!(normalized["openai"]["official"], json!(true));
assert_eq!(normalized["openrouter"]["official"], json!(false));
}
#[test]
fn external_models_source_url_uses_env_override_when_present() {
let _guard = set_test_env_var(
ADMIN_EXTERNAL_MODELS_SOURCE_URL_ENV,
"http://127.0.0.1:12345/api",
);
assert_eq!(
admin_external_models_source_url(),
"http://127.0.0.1:12345/api"
);
}
#[tokio::test]
async fn read_external_models_fetches_remote_payload_when_cache_missing() {
let upstream = Router::new().route(
"/api.json",
get(|| async {
Json(json!({
"openai": {
"name": "OpenAI",
"models": {
"gpt-5": {
"name": "GPT-5"
}
}
}
}))
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let _guard = set_test_env_var(
ADMIN_EXTERNAL_MODELS_SOURCE_URL_ENV,
&format!("{upstream_url}/api.json"),
);
let state = AppState::new().expect("gateway should build");
let payload = read_admin_external_models_cache(&state)
.await
.expect("external models read should succeed")
.expect("payload should be fetched");
assert_eq!(payload["openai"]["official"], json!(true));
assert_eq!(payload["openai"]["models"]["gpt-5"]["name"], json!("GPT-5"));
upstream_handle.abort();
}
}

View File

@@ -1,11 +1,11 @@
use super::{
use super::payloads::{
admin_provider_model_effective_capability, admin_provider_model_effective_input_price,
admin_provider_model_effective_output_price, model_tiered_pricing_first_tier_value,
timestamp_or_now,
};
use crate::handlers::json_string_list;
use crate::handlers::admin::shared::json_string_list;
use crate::AppState;
use aether_data::repository::global_models::{
use aether_data_contracts::repository::global_models::{
AdminGlobalModelListQuery, StoredAdminGlobalModel, StoredAdminProviderModel,
};
use futures_util::stream::{self, StreamExt};

View File

@@ -0,0 +1,5 @@
mod helpers;
mod routes;
use self::helpers::*;
pub(crate) use self::routes::maybe_build_local_admin_global_models_response;

View File

@@ -5,18 +5,19 @@ use super::super::{
build_admin_global_model_update_record, build_admin_global_models_payload,
resolve_admin_global_model_by_id_or_err,
};
use super::global_models_helpers::{
use super::helpers::{
build_admin_global_models_data_unavailable_response,
ADMIN_GLOBAL_MODELS_DATA_UNAVAILABLE_DETAIL,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::{
use crate::handlers::admin::model::shared::{
admin_global_model_assign_to_providers_id, admin_global_model_id_from_path,
admin_global_model_providers_id, admin_global_model_routing_id, is_admin_global_models_root,
query_param_optional_bool, query_param_value, AdminBatchAssignToProvidersRequest,
AdminBatchDeleteIdsRequest, AdminGlobalModelCreateRequest, AdminGlobalModelUpdateRequest,
AdminBatchAssignToProvidersRequest, AdminBatchDeleteIdsRequest, AdminGlobalModelCreateRequest,
AdminGlobalModelUpdateRequest,
};
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -0,0 +1,36 @@
pub(crate) mod shared;
mod catalog_routes;
mod external_cache;
mod global;
mod global_models;
mod payloads;
mod routing;
mod write;
pub(crate) use self::catalog_routes::maybe_build_local_admin_model_catalog_response;
pub(crate) use self::external_cache::{
clear_admin_external_models_cache, read_admin_external_models_cache,
};
pub(crate) use self::global::{
build_admin_global_model_payload, build_admin_global_model_providers_payload,
build_admin_global_model_response, build_admin_global_models_payload,
build_admin_model_catalog_payload, resolve_admin_global_model_by_id_or_err,
};
pub(crate) use self::global_models::maybe_build_local_admin_global_models_response;
pub(crate) use self::payloads::{
admin_provider_model_effective_capability, admin_provider_model_effective_input_price,
admin_provider_model_effective_output_price, admin_provider_model_name_exists,
build_admin_provider_model_payload, build_admin_provider_model_response,
build_admin_provider_models_payload, normalize_optional_price,
normalize_required_trimmed_string,
};
pub(crate) use self::routing::{
build_admin_assign_global_model_to_providers_payload, build_admin_global_model_routing_payload,
};
pub(crate) use self::write::{
build_admin_batch_assign_global_models_payload, build_admin_global_model_create_record,
build_admin_global_model_update_record, build_admin_import_provider_models_payload,
build_admin_provider_available_source_models_payload, build_admin_provider_model_create_record,
build_admin_provider_model_update_record,
};

View File

@@ -1,39 +1,12 @@
use crate::handlers::unix_secs_to_rfc3339;
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::{AppState, GatewayError};
use aether_data::repository::global_models::{
use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, StoredAdminProviderModel,
};
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
#[path = "models/external_cache.rs"]
mod models_external_cache;
#[path = "models/global.rs"]
mod models_global;
#[path = "models/routing.rs"]
mod models_routing;
#[path = "models/write.rs"]
mod models_write;
pub(crate) use self::models_external_cache::{
clear_admin_external_models_cache, read_admin_external_models_cache,
};
pub(crate) use self::models_global::{
build_admin_global_model_payload, build_admin_global_model_providers_payload,
build_admin_global_model_response, build_admin_global_models_payload,
build_admin_model_catalog_payload, resolve_admin_global_model_by_id_or_err,
};
pub(crate) use self::models_routing::{
build_admin_assign_global_model_to_providers_payload, build_admin_global_model_routing_payload,
};
pub(crate) use self::models_write::{
build_admin_batch_assign_global_models_payload, build_admin_global_model_create_record,
build_admin_global_model_update_record, build_admin_import_provider_models_payload,
build_admin_provider_available_source_models_payload, build_admin_provider_model_create_record,
build_admin_provider_model_update_record,
};
fn model_tiered_pricing_first_tier_value(
pub(crate) fn model_tiered_pricing_first_tier_value(
tiered_pricing: Option<&serde_json::Value>,
field_name: &str,
) -> Option<f64> {
@@ -94,13 +67,16 @@ fn merge_admin_provider_model_effective_config(
}
}
fn timestamp_or_now(value: Option<u64>, now_unix_secs: u64) -> serde_json::Value {
pub(crate) fn timestamp_or_now(value: Option<u64>, now_unix_secs: u64) -> serde_json::Value {
unix_secs_to_rfc3339(value.unwrap_or(now_unix_secs))
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null)
}
fn normalize_required_trimmed_string(value: &str, field_name: &str) -> Result<String, String> {
pub(crate) fn normalize_required_trimmed_string(
value: &str,
field_name: &str,
) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{field_name} 不能为空"));
@@ -108,7 +84,10 @@ fn normalize_required_trimmed_string(value: &str, field_name: &str) -> Result<St
Ok(trimmed.to_string())
}
fn normalize_optional_price(value: Option<f64>, field_name: &str) -> Result<Option<f64>, String> {
pub(crate) fn normalize_optional_price(
value: Option<f64>,
field_name: &str,
) -> Result<Option<f64>, String> {
let Some(value) = value else {
return Ok(None);
};
@@ -118,7 +97,9 @@ fn normalize_optional_price(value: Option<f64>, field_name: &str) -> Result<Opti
Ok(Some(value))
}
fn admin_provider_model_effective_input_price(model: &StoredAdminProviderModel) -> Option<f64> {
pub(crate) fn admin_provider_model_effective_input_price(
model: &StoredAdminProviderModel,
) -> Option<f64> {
model_tiered_pricing_first_tier_value(model.tiered_pricing.as_ref(), "input_price_per_1m")
.or_else(|| {
model_tiered_pricing_first_tier_value(
@@ -128,7 +109,9 @@ fn admin_provider_model_effective_input_price(model: &StoredAdminProviderModel)
})
}
fn admin_provider_model_effective_output_price(model: &StoredAdminProviderModel) -> Option<f64> {
pub(crate) fn admin_provider_model_effective_output_price(
model: &StoredAdminProviderModel,
) -> Option<f64> {
model_tiered_pricing_first_tier_value(model.tiered_pricing.as_ref(), "output_price_per_1m")
.or_else(|| {
model_tiered_pricing_first_tier_value(
@@ -138,7 +121,7 @@ fn admin_provider_model_effective_output_price(model: &StoredAdminProviderModel)
})
}
fn admin_provider_model_effective_capability(
pub(crate) fn admin_provider_model_effective_capability(
model: &StoredAdminProviderModel,
capability: &str,
) -> bool {
@@ -202,10 +185,19 @@ pub(crate) fn build_admin_provider_model_response(
"supports_extended_thinking": model.supports_extended_thinking,
"supports_image_generation": model.supports_image_generation,
"effective_supports_vision": admin_provider_model_effective_capability(model, "vision"),
"effective_supports_function_calling": admin_provider_model_effective_capability(model, "function_calling"),
"effective_supports_function_calling": admin_provider_model_effective_capability(
model,
"function_calling",
),
"effective_supports_streaming": admin_provider_model_effective_capability(model, "streaming"),
"effective_supports_extended_thinking": admin_provider_model_effective_capability(model, "extended_thinking"),
"effective_supports_image_generation": admin_provider_model_effective_capability(model, "image_generation"),
"effective_supports_extended_thinking": admin_provider_model_effective_capability(
model,
"extended_thinking",
),
"effective_supports_image_generation": admin_provider_model_effective_capability(
model,
"image_generation",
),
"is_active": model.is_active,
"is_available": model.is_available,
"config": model.config.clone(),

View File

@@ -1,14 +1,15 @@
use super::resolve_admin_global_model_by_id_or_err;
use crate::handlers::admin::misc_helpers::provider_catalog_key_supports_format;
use crate::handlers::{json_string_list, masked_catalog_api_key};
use crate::scheduler::{is_provider_key_circuit_open, provider_key_health_score};
use crate::handlers::admin::shared::{
json_string_list, masked_catalog_api_key, provider_catalog_key_supports_format,
};
use crate::AppState;
use aether_data::repository::global_models::{
use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, UpsertAdminProviderModelRecord,
};
use aether_data::repository::provider_catalog::{
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use aether_scheduler_core::{is_provider_key_circuit_open, provider_key_health_score};
use serde_json::json;
use std::collections::BTreeMap;
use uuid::Uuid;

View File

@@ -0,0 +1,5 @@
mod paths;
mod payloads;
pub(crate) use self::paths::*;
pub(crate) use self::payloads::*;

View File

@@ -0,0 +1,46 @@
pub(crate) fn is_admin_global_models_root(request_path: &str) -> bool {
matches!(
request_path,
"/api/admin/models/global" | "/api/admin/models/global/"
)
}
pub(crate) fn admin_global_model_id_from_path(request_path: &str) -> Option<String> {
let raw = request_path.strip_prefix("/api/admin/models/global/")?;
let normalized = raw.trim().trim_matches('/');
if normalized.is_empty()
|| normalized.contains('/')
|| normalized == "batch-delete"
|| normalized.ends_with("/providers")
|| normalized.ends_with("/assign-to-providers")
|| normalized.ends_with("/routing")
{
None
} else {
Some(normalized.to_string())
}
}
pub(crate) fn admin_global_model_assign_to_providers_id(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/models/global/")?
.strip_suffix("/assign-to-providers")
.map(|value| value.trim().trim_matches('/').to_string())
.filter(|value| !value.is_empty() && !value.contains('/'))
}
pub(crate) fn admin_global_model_routing_id(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/models/global/")?
.strip_suffix("/routing")
.map(|value| value.trim().trim_matches('/').to_string())
.filter(|value| !value.is_empty() && !value.contains('/'))
}
pub(crate) fn admin_global_model_providers_id(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/models/global/")?
.strip_suffix("/providers")
.map(|value| value.trim().trim_matches('/').to_string())
.filter(|value| !value.is_empty() && !value.contains('/'))
}

View File

@@ -0,0 +1,45 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(crate) struct AdminGlobalModelCreateRequest {
pub(crate) name: String,
pub(crate) display_name: String,
#[serde(default)]
pub(crate) default_price_per_request: Option<f64>,
#[serde(default)]
pub(crate) default_tiered_pricing: Option<serde_json::Value>,
#[serde(default)]
pub(crate) supported_capabilities: Option<Vec<String>>,
#[serde(default)]
pub(crate) config: Option<serde_json::Value>,
#[serde(default)]
pub(crate) is_active: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AdminGlobalModelUpdateRequest {
#[serde(default)]
pub(crate) display_name: Option<String>,
#[serde(default)]
pub(crate) is_active: Option<bool>,
#[serde(default)]
pub(crate) default_price_per_request: Option<f64>,
#[serde(default)]
pub(crate) default_tiered_pricing: Option<serde_json::Value>,
#[serde(default)]
pub(crate) supported_capabilities: Option<Vec<String>>,
#[serde(default)]
pub(crate) config: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AdminBatchDeleteIdsRequest {
pub(crate) ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AdminBatchAssignToProvidersRequest {
pub(crate) provider_ids: Vec<String>,
#[serde(default)]
pub(crate) create_models: Option<bool>,
}

View File

@@ -1,16 +1,21 @@
use super::super::{normalize_json_array, normalize_json_object, normalize_string_list};
use super::{
admin_provider_model_effective_capability, admin_provider_model_effective_input_price,
admin_provider_model_effective_output_price, admin_provider_model_name_exists,
normalize_optional_price, normalize_required_trimmed_string,
resolve_admin_global_model_by_id_or_err,
};
use crate::handlers::{
AdminGlobalModelCreateRequest, AdminGlobalModelUpdateRequest, AdminImportProviderModelsRequest,
AdminProviderModelCreateRequest, AdminProviderModelUpdateRequest,
use crate::handlers::admin::model::shared::{
AdminGlobalModelCreateRequest, AdminGlobalModelUpdateRequest,
};
use crate::handlers::admin::provider::shared::{
AdminImportProviderModelsRequest, AdminProviderModelCreateRequest,
AdminProviderModelUpdateRequest,
};
use crate::handlers::admin::shared::{
normalize_json_array, normalize_json_object, normalize_string_list,
};
use crate::AppState;
use aether_data::repository::global_models::{
use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, CreateAdminGlobalModelRecord, StoredAdminGlobalModel,
StoredAdminProviderModel, UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
};

View File

@@ -1,85 +0,0 @@
use crate::handlers::{
ADMIN_EXTERNAL_MODELS_CACHE_KEY, ADMIN_EXTERNAL_MODELS_CACHE_TTL_SECS,
OFFICIAL_EXTERNAL_MODEL_PROVIDERS,
};
use crate::{AppState, GatewayError};
use serde_json::json;
fn mark_admin_external_models_official(mut payload: serde_json::Value) -> serde_json::Value {
let Some(models) = payload
.get_mut("models")
.and_then(serde_json::Value::as_array_mut)
else {
return payload;
};
for item in models {
let provider_name = item
.get("provider")
.or_else(|| item.get("provider_name"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
if let Some(object) = item.as_object_mut() {
object.insert(
"official".to_string(),
json!(OFFICIAL_EXTERNAL_MODEL_PROVIDERS.contains(&provider_name.as_str())),
);
}
}
payload
}
pub(crate) async fn read_admin_external_models_cache(
state: &AppState,
) -> Result<Option<serde_json::Value>, GatewayError> {
let Some(runner) = state.redis_kv_runner() else {
return Ok(None);
};
let mut connection = runner
.client()
.get_multiplexed_async_connection()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let namespaced_key = runner.keyspace().key(ADMIN_EXTERNAL_MODELS_CACHE_KEY);
let raw = redis::cmd("GET")
.arg(&namespaced_key)
.query_async::<Option<String>>(&mut connection)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(raw) = raw else {
return Ok(None);
};
let payload = serde_json::from_str::<serde_json::Value>(&raw)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let payload = mark_admin_external_models_official(payload);
let serialized =
serde_json::to_string(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
runner
.setex(
ADMIN_EXTERNAL_MODELS_CACHE_KEY,
&serialized,
Some(ADMIN_EXTERNAL_MODELS_CACHE_TTL_SECS),
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(Some(payload))
}
pub(crate) async fn clear_admin_external_models_cache(
state: &AppState,
) -> Result<serde_json::Value, GatewayError> {
let Some(runner) = state.redis_kv_runner() else {
return Ok(json!({
"cleared": false,
"message": "Redis 未启用",
}));
};
let deleted = runner
.del(ADMIN_EXTERNAL_MODELS_CACHE_KEY)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(json!({
"cleared": deleted > 0,
"message": if deleted > 0 { "缓存已清除" } else { "缓存不存在" },
}))
}

View File

@@ -1,246 +0,0 @@
use super::INTERNAL_GATEWAY_PATH_PREFIXES;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{query_param_value, unix_secs_to_rfc3339};
use crate::{AppState, GatewayError};
use aether_crypto::decrypt_python_fernet_ciphertext;
#[cfg(test)]
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use axum::{body::Body, response::Response, Json};
use chrono::Utc;
use serde_json::json;
#[path = "monitoring/activity.rs"]
mod activity;
#[path = "monitoring/cache.rs"]
mod cache;
#[path = "monitoring/cache_affinity.rs"]
mod cache_affinity;
#[path = "monitoring/cache_identity.rs"]
mod cache_identity;
#[path = "monitoring/cache_payloads.rs"]
mod cache_payloads;
#[path = "monitoring/cache_route_helpers.rs"]
mod cache_route_helpers;
#[path = "monitoring/cache_store.rs"]
mod cache_store;
#[path = "monitoring/common.rs"]
mod common;
#[path = "monitoring/resilience.rs"]
mod resilience;
#[path = "monitoring/route_filters.rs"]
mod route_filters;
#[path = "monitoring/routes.rs"]
mod routes;
#[cfg(test)]
#[path = "monitoring/test_support.rs"]
mod test_support;
#[path = "monitoring/trace.rs"]
mod trace;
use self::activity::{
build_admin_monitoring_audit_logs_response,
build_admin_monitoring_suspicious_activities_response,
build_admin_monitoring_system_status_response, build_admin_monitoring_user_behavior_response,
};
use self::cache::{
build_admin_monitoring_cache_affinities_response,
build_admin_monitoring_cache_affinity_delete_response,
build_admin_monitoring_cache_affinity_response, build_admin_monitoring_cache_config_response,
build_admin_monitoring_cache_flush_response, build_admin_monitoring_cache_metrics_response,
build_admin_monitoring_cache_provider_delete_response,
build_admin_monitoring_cache_stats_response,
build_admin_monitoring_cache_users_delete_response,
build_admin_monitoring_model_mapping_delete_model_response,
build_admin_monitoring_model_mapping_delete_provider_response,
build_admin_monitoring_model_mapping_delete_response,
build_admin_monitoring_model_mapping_stats_response,
build_admin_monitoring_redis_cache_categories_response,
build_admin_monitoring_redis_keys_delete_response,
};
use self::cache_affinity::{
admin_monitoring_cache_affinity_record, admin_monitoring_scheduler_affinity_cache_key,
clear_admin_monitoring_scheduler_affinity_entries,
delete_admin_monitoring_cache_affinity_entries_for_tests,
delete_admin_monitoring_cache_affinity_raw_keys,
};
use self::cache_identity::{
admin_monitoring_find_user_summary_by_id, admin_monitoring_list_export_api_key_records_by_ids,
admin_monitoring_load_affinity_identity_maps,
};
use self::cache_payloads::{
admin_monitoring_cache_affinity_sort_value, admin_monitoring_masked_provider_key_prefix,
admin_monitoring_masked_user_api_key_prefix,
};
use self::cache_route_helpers::{
admin_monitoring_cache_affinity_delete_params_from_path,
admin_monitoring_cache_affinity_not_found_response,
admin_monitoring_cache_affinity_unavailable_response,
admin_monitoring_cache_affinity_user_identifier_from_path,
admin_monitoring_cache_model_mapping_provider_params_from_path,
admin_monitoring_cache_model_name_from_path, admin_monitoring_cache_provider_id_from_path,
admin_monitoring_cache_redis_category_from_path,
admin_monitoring_cache_users_not_found_response,
admin_monitoring_cache_users_user_identifier_from_path,
admin_monitoring_redis_unavailable_response, parse_admin_monitoring_keyword_filter,
};
use self::cache_store::{
admin_monitoring_has_test_redis_keys, build_admin_monitoring_cache_snapshot,
delete_admin_monitoring_namespaced_keys, list_admin_monitoring_cache_affinity_records,
list_admin_monitoring_cache_affinity_records_by_affinity_keys,
list_admin_monitoring_namespaced_keys, load_admin_monitoring_cache_affinity_entries_for_tests,
};
use self::common::{
admin_monitoring_bad_request_response, admin_monitoring_data_unavailable_response,
admin_monitoring_not_found_response, admin_monitoring_usage_is_error,
admin_monitoring_user_behavior_user_id_from_path, AdminMonitoringCacheAffinityRecord,
AdminMonitoringCacheSnapshot, AdminMonitoringResilienceSnapshot,
};
use self::resilience::{
build_admin_monitoring_reset_error_stats_response,
build_admin_monitoring_resilience_circuit_history_response,
build_admin_monitoring_resilience_status_response,
};
use self::route_filters::{
admin_monitoring_escape_like_pattern, parse_admin_monitoring_days,
parse_admin_monitoring_event_type_filter, parse_admin_monitoring_hours,
parse_admin_monitoring_limit, parse_admin_monitoring_offset,
parse_admin_monitoring_username_filter,
};
use self::routes::{
match_admin_monitoring_route, AdminMonitoringRoute,
};
use self::trace::{
build_admin_monitoring_trace_provider_stats_response,
build_admin_monitoring_trace_request_response,
};
const ADMIN_MONITORING_DATA_UNAVAILABLE_DETAIL: &str = "Admin monitoring data unavailable";
const ADMIN_MONITORING_CACHE_AFFINITY_REDIS_REQUIRED_DETAIL: &str =
"Redis未初始化无法获取缓存亲和性";
const ADMIN_MONITORING_REDIS_REQUIRED_DETAIL: &str = "Redis 未启用";
const ADMIN_MONITORING_CACHE_AFFINITY_DEFAULT_TTL_SECS: u64 = 300;
const ADMIN_MONITORING_CACHE_RESERVATION_RATIO: f64 = 0.1;
const ADMIN_MONITORING_DYNAMIC_RESERVATION_PROBE_PHASE_REQUESTS: u64 = 100;
pub(crate) async fn maybe_build_local_admin_monitoring_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Option<Response<Body>>, GatewayError> {
routes::maybe_build_local_admin_monitoring_response(state, request_context).await
}
const ADMIN_MONITORING_DYNAMIC_RESERVATION_PROBE_RESERVATION: f64 = 0.1;
const ADMIN_MONITORING_DYNAMIC_RESERVATION_STABLE_MIN_RESERVATION: f64 = 0.1;
const ADMIN_MONITORING_DYNAMIC_RESERVATION_STABLE_MAX_RESERVATION: f64 = 0.35;
const ADMIN_MONITORING_DYNAMIC_RESERVATION_LOW_LOAD_THRESHOLD: f64 = 0.5;
const ADMIN_MONITORING_DYNAMIC_RESERVATION_HIGH_LOAD_THRESHOLD: f64 = 0.8;
const ADMIN_MONITORING_REDIS_CACHE_CATEGORIES: &[(&str, &str, &str, &str)] = &[
(
"upstream_models",
"上游模型",
"upstream_models:*",
"Provider 上游获取的模型列表缓存",
),
("model_id", "模型 ID", "model:id:*", "Model 按 ID 缓存"),
(
"model_provider_global",
"模型映射",
"model:provider_global:*",
"Provider-GlobalModel 模型映射缓存",
),
(
"provider_mapping_preview",
"映射预览",
"admin:providers:mapping-preview:*",
"Provider 详情页 mapping-preview 缓存",
),
(
"global_model",
"全局模型",
"global_model:*",
"GlobalModel 缓存ID/名称/解析)",
),
(
"models_list",
"模型列表",
"models:list:*",
"/v1/models 端点模型列表缓存",
),
("user", "用户", "user:*", "用户信息缓存ID/Email"),
(
"apikey",
"API Key",
"apikey:*",
"API Key 认证缓存Hash/Auth",
),
(
"api_key_id",
"API Key ID",
"api_key:id:*",
"API Key 按 ID 缓存",
),
(
"cache_affinity",
"缓存亲和性",
"cache_affinity:*",
"请求路由亲和性缓存",
),
(
"provider_billing",
"Provider 计费",
"provider:billing_type:*",
"Provider 计费类型缓存",
),
(
"provider_rate",
"Provider 费率",
"provider_api_key:rate_multiplier:*",
"ProviderAPIKey 费率倍数缓存",
),
(
"provider_balance",
"Provider 余额",
"provider_ops:balance:*",
"Provider 余额查询缓存",
),
("health", "健康检查", "health:*", "端点健康状态缓存"),
(
"endpoint_status",
"端点状态",
"endpoint_status:*",
"用户端点状态缓存",
),
("dashboard", "仪表盘", "dashboard:*", "仪表盘统计缓存"),
(
"activity_heatmap",
"活动热力图",
"activity_heatmap:*",
"用户活动热力图缓存",
),
(
"gemini_files",
"Gemini 文件映射",
"gemini_files:*",
"Gemini Files API 文件-Key 映射缓存",
),
(
"provider_oauth",
"OAuth 状态",
"provider_oauth_state:*",
"Provider OAuth 授权流程临时状态",
),
(
"oauth_refresh_lock",
"OAuth 刷新锁",
"provider_oauth_refresh_lock:*",
"OAuth Token 刷新分布式锁",
),
(
"concurrency_lock",
"并发锁",
"concurrency:*",
"请求并发控制锁",
),
];
#[cfg(test)]
#[path = "monitoring/tests.rs"]
mod tests;

File diff suppressed because it is too large Load Diff

View File

@@ -1,95 +0,0 @@
use super::ADMIN_MONITORING_DATA_UNAVAILABLE_DETAIL;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) struct AdminMonitoringCacheSnapshot {
pub(super) scheduler_name: String,
pub(super) scheduling_mode: String,
pub(super) provider_priority_mode: String,
pub(super) storage_type: &'static str,
pub(super) total_affinities: usize,
pub(super) cache_hits: usize,
pub(super) cache_misses: usize,
pub(super) cache_hit_rate: f64,
pub(super) provider_switches: usize,
pub(super) key_switches: usize,
pub(super) cache_invalidations: usize,
}
#[derive(Debug, Clone)]
pub(super) struct AdminMonitoringCacheAffinityRecord {
pub(super) raw_key: String,
pub(super) affinity_key: String,
pub(super) api_format: String,
pub(super) model_name: String,
pub(super) provider_id: Option<String>,
pub(super) endpoint_id: Option<String>,
pub(super) key_id: Option<String>,
pub(super) created_at: Option<serde_json::Value>,
pub(super) expire_at: Option<serde_json::Value>,
pub(super) request_count: u64,
}
pub(super) struct AdminMonitoringResilienceSnapshot {
pub(super) timestamp: chrono::DateTime<chrono::Utc>,
pub(super) health_score: i64,
pub(super) status: &'static str,
pub(super) error_statistics: serde_json::Value,
pub(super) recent_errors: Vec<serde_json::Value>,
pub(super) recommendations: Vec<String>,
pub(super) previous_stats: serde_json::Value,
}
pub(super) fn admin_monitoring_data_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": ADMIN_MONITORING_DATA_UNAVAILABLE_DETAIL })),
)
.into_response()
}
pub(super) fn admin_monitoring_bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
pub(super) fn admin_monitoring_not_found_response(detail: &'static str) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": detail })),
)
.into_response()
}
pub(super) fn admin_monitoring_usage_is_error(
item: &aether_data::repository::usage::StoredRequestUsageAudit,
) -> bool {
item.status_code.is_some_and(|value| value >= 400)
|| item.status.trim().eq_ignore_ascii_case("failed")
|| item.status.trim().eq_ignore_ascii_case("error")
|| item.error_message.is_some()
|| item.error_category.is_some()
}
pub(super) fn admin_monitoring_user_behavior_user_id_from_path(
request_path: &str,
) -> Option<String> {
let value = request_path
.strip_prefix("/api/admin/monitoring/user-behavior/")?
.trim()
.trim_matches('/')
.to_string();
if value.is_empty() || value.contains('/') {
None
} else {
Some(value)
}
}

View File

@@ -0,0 +1,7 @@
mod monitoring;
pub(crate) mod stats;
mod usage;
pub(crate) use self::monitoring::maybe_build_local_admin_monitoring_response;
pub(crate) use self::stats::maybe_build_local_admin_stats_response;
pub(crate) use self::usage::maybe_build_local_admin_usage_response;

View File

@@ -1,14 +1,16 @@
use super::INTERNAL_GATEWAY_PATH_PREFIXES;
use super::{
admin_monitoring_bad_request_response, admin_monitoring_escape_like_pattern,
admin_monitoring_usage_is_error, admin_monitoring_user_behavior_user_id_from_path,
parse_admin_monitoring_days, parse_admin_monitoring_event_type_filter,
parse_admin_monitoring_hours, parse_admin_monitoring_limit, parse_admin_monitoring_offset,
use super::responses::admin_monitoring_bad_request_response;
use super::route_filters::{
admin_monitoring_escape_like_pattern, parse_admin_monitoring_days,
parse_admin_monitoring_event_type_filter, parse_admin_monitoring_hours,
parse_admin_monitoring_limit, parse_admin_monitoring_offset,
parse_admin_monitoring_username_filter,
};
use super::usage_helpers::admin_monitoring_usage_is_error;
use crate::constants::INTERNAL_GATEWAY_PATH_PREFIXES;
use crate::control::GatewayPublicRequestContext;
use crate::query::monitoring as monitoring_query;
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::usage::UsageAuditListQuery;
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -84,6 +86,19 @@ fn build_admin_monitoring_user_behavior_payload(
.into_response()
}
fn admin_monitoring_user_behavior_user_id_from_path(request_path: &str) -> Option<String> {
let value = request_path
.strip_prefix("/api/admin/monitoring/user-behavior/")?
.trim()
.trim_matches('/')
.to_string();
if value.is_empty() || value.contains('/') {
None
} else {
Some(value)
}
}
pub(super) async fn build_admin_monitoring_audit_logs_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -260,7 +275,7 @@ pub(super) async fn build_admin_monitoring_system_status_response(
.saturating_add(standalone_api_key_summary.active);
let today_usage = state
.list_usage_audits(&aether_data::repository::usage::UsageAuditListQuery {
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: Some(today_start.timestamp().max(0) as u64),
..Default::default()
})
@@ -276,7 +291,7 @@ pub(super) async fn build_admin_monitoring_system_status_response(
.sum::<f64>();
let recent_errors = state
.list_usage_audits(&aether_data::repository::usage::UsageAuditListQuery {
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: Some(recent_error_from.timestamp().max(0) as u64),
..Default::default()
})

View File

@@ -0,0 +1,208 @@
use super::cache_config::{
ADMIN_MONITORING_CACHE_AFFINITY_DEFAULT_TTL_SECS, ADMIN_MONITORING_CACHE_RESERVATION_RATIO,
ADMIN_MONITORING_DYNAMIC_RESERVATION_HIGH_LOAD_THRESHOLD,
ADMIN_MONITORING_DYNAMIC_RESERVATION_LOW_LOAD_THRESHOLD,
ADMIN_MONITORING_DYNAMIC_RESERVATION_PROBE_PHASE_REQUESTS,
ADMIN_MONITORING_DYNAMIC_RESERVATION_PROBE_RESERVATION,
ADMIN_MONITORING_DYNAMIC_RESERVATION_STABLE_MAX_RESERVATION,
ADMIN_MONITORING_DYNAMIC_RESERVATION_STABLE_MIN_RESERVATION,
};
use super::cache_store::build_admin_monitoring_cache_snapshot;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn build_admin_monitoring_cache_stats_response(
state: &AppState,
) -> Result<Response<Body>, GatewayError> {
let snapshot = build_admin_monitoring_cache_snapshot(state).await?;
Ok(Json(json!({
"status": "ok",
"data": {
"scheduler": snapshot.scheduler_name,
"total_affinities": snapshot.total_affinities,
"cache_hit_rate": snapshot.cache_hit_rate,
"provider_switches": snapshot.provider_switches,
"key_switches": snapshot.key_switches,
"cache_hits": snapshot.cache_hits,
"cache_misses": snapshot.cache_misses,
"scheduler_metrics": {
"cache_hits": snapshot.cache_hits,
"cache_misses": snapshot.cache_misses,
"cache_hit_rate": snapshot.cache_hit_rate,
"total_batches": 0,
"last_batch_size": 0,
"total_candidates": 0,
"last_candidate_count": 0,
"concurrency_denied": 0,
"avg_candidates_per_batch": 0.0,
"scheduling_mode": snapshot.scheduling_mode,
"provider_priority_mode": snapshot.provider_priority_mode,
},
"affinity_stats": {
"storage_type": snapshot.storage_type,
"total_affinities": snapshot.total_affinities,
"cache_hits": snapshot.cache_hits,
"cache_misses": snapshot.cache_misses,
"cache_hit_rate": snapshot.cache_hit_rate,
"cache_invalidations": snapshot.cache_invalidations,
"provider_switches": snapshot.provider_switches,
"key_switches": snapshot.key_switches,
"config": {
"default_ttl": ADMIN_MONITORING_CACHE_AFFINITY_DEFAULT_TTL_SECS,
}
}
}
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_cache_metrics_response(
state: &AppState,
) -> Result<Response<Body>, GatewayError> {
let snapshot = build_admin_monitoring_cache_snapshot(state).await?;
let metrics = [
(
"cache_scheduler_total_batches",
"Number of scheduling batches processed",
0.0,
),
(
"cache_scheduler_last_batch_size",
"Size of the most recent scheduling batch",
0.0,
),
(
"cache_scheduler_total_candidates",
"Total candidates seen during scheduling",
0.0,
),
(
"cache_scheduler_last_candidate_count",
"Number of candidates in the most recent batch",
0.0,
),
(
"cache_scheduler_cache_hits",
"Cache hits counted during scheduling",
snapshot.cache_hits as f64,
),
(
"cache_scheduler_cache_misses",
"Cache misses counted during scheduling",
snapshot.cache_misses as f64,
),
(
"cache_scheduler_cache_hit_rate",
"Cache hit rate during scheduling",
snapshot.cache_hit_rate,
),
(
"cache_scheduler_concurrency_denied",
"Times candidate rejected due to concurrency limits",
0.0,
),
(
"cache_scheduler_avg_candidates_per_batch",
"Average candidates per batch",
0.0,
),
(
"cache_affinity_total",
"Total cache affinities stored",
snapshot.total_affinities as f64,
),
(
"cache_affinity_hits",
"Affinity cache hits",
snapshot.cache_hits as f64,
),
(
"cache_affinity_misses",
"Affinity cache misses",
snapshot.cache_misses as f64,
),
(
"cache_affinity_hit_rate",
"Affinity cache hit rate",
snapshot.cache_hit_rate,
),
(
"cache_affinity_invalidations",
"Affinity invalidations",
snapshot.cache_invalidations as f64,
),
(
"cache_affinity_provider_switches",
"Affinity provider switches",
snapshot.provider_switches as f64,
),
(
"cache_affinity_key_switches",
"Affinity key switches",
snapshot.key_switches as f64,
),
];
let mut lines = Vec::with_capacity(metrics.len() * 3 + 1);
for (name, help_text, value) in metrics {
lines.push(format!("# HELP {name} {help_text}"));
lines.push(format!("# TYPE {name} gauge"));
lines.push(format!("{name} {value}"));
}
lines.push(format!(
"cache_scheduler_info{{scheduler=\"{}\"}} 1",
snapshot.scheduler_name
));
Ok((
[(
http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
lines.join("\n") + "\n",
)
.into_response())
}
pub(super) async fn build_admin_monitoring_cache_config_response(
) -> Result<Response<Body>, GatewayError> {
Ok(Json(json!({
"status": "ok",
"data": {
"cache_ttl_seconds": ADMIN_MONITORING_CACHE_AFFINITY_DEFAULT_TTL_SECS,
"cache_reservation_ratio": ADMIN_MONITORING_CACHE_RESERVATION_RATIO,
"dynamic_reservation": {
"enabled": true,
"config": {
"probe_phase_requests": ADMIN_MONITORING_DYNAMIC_RESERVATION_PROBE_PHASE_REQUESTS,
"probe_reservation": ADMIN_MONITORING_DYNAMIC_RESERVATION_PROBE_RESERVATION,
"stable_min_reservation": ADMIN_MONITORING_DYNAMIC_RESERVATION_STABLE_MIN_RESERVATION,
"stable_max_reservation": ADMIN_MONITORING_DYNAMIC_RESERVATION_STABLE_MAX_RESERVATION,
"low_load_threshold": ADMIN_MONITORING_DYNAMIC_RESERVATION_LOW_LOAD_THRESHOLD,
"high_load_threshold": ADMIN_MONITORING_DYNAMIC_RESERVATION_HIGH_LOAD_THRESHOLD,
},
"description": {
"probe_phase_requests": "探测阶段请求数阈值",
"probe_reservation": "探测阶段预留比例",
"stable_min_reservation": "稳定阶段最小预留比例",
"stable_max_reservation": "稳定阶段最大预留比例",
"low_load_threshold": "低负载阈值(低于此值使用最小预留)",
"high_load_threshold": "高负载阈值(高于此值根据置信度使用较高预留)",
},
},
"description": {
"cache_ttl": "缓存亲和性有效期(秒)",
"cache_reservation_ratio": "静态预留比例(已被动态预留替代)",
"dynamic_reservation": "动态预留机制配置",
},
}
}))
.into_response())
}

View File

@@ -1,4 +1,4 @@
use super::AdminMonitoringCacheAffinityRecord;
use super::cache_types::AdminMonitoringCacheAffinityRecord;
use crate::{AppState, GatewayError};
fn parse_admin_monitoring_cache_affinity_key(raw_key: &str) -> Option<(String, String, String)> {

View File

@@ -0,0 +1,351 @@
use super::cache_identity::{
admin_monitoring_find_user_summary_by_id, admin_monitoring_list_export_api_key_records_by_ids,
admin_monitoring_load_affinity_identity_maps,
};
use super::cache_payloads::{
admin_monitoring_cache_affinity_sort_value, admin_monitoring_masked_provider_key_prefix,
admin_monitoring_masked_user_api_key_prefix,
};
use super::cache_route_helpers::{
admin_monitoring_cache_affinity_not_found_response,
admin_monitoring_cache_affinity_user_identifier_from_path,
parse_admin_monitoring_keyword_filter,
};
use super::cache_store::{
list_admin_monitoring_cache_affinity_records,
list_admin_monitoring_cache_affinity_records_by_affinity_keys,
};
use super::responses::admin_monitoring_bad_request_response;
use super::route_filters::{parse_admin_monitoring_limit, parse_admin_monitoring_offset};
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
fn normalize_keyword<'a>(keyword: Option<&'a String>) -> Option<String> {
keyword.map(|value| value.to_ascii_lowercase())
}
pub(super) async fn build_admin_monitoring_cache_affinities_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let limit = match parse_admin_monitoring_limit(request_context.request_query_string.as_deref())
{
Ok(value) => value,
Err(detail) => return Ok(admin_monitoring_bad_request_response(detail)),
};
let offset =
match parse_admin_monitoring_offset(request_context.request_query_string.as_deref()) {
Ok(value) => value,
Err(detail) => return Ok(admin_monitoring_bad_request_response(detail)),
};
let keyword =
parse_admin_monitoring_keyword_filter(request_context.request_query_string.as_deref());
let mut matched_user_id = None::<String>;
let mut matched_api_key_id = None::<String>;
let filtered_affinities = if let Some(keyword_value) = keyword.as_deref() {
let direct_affinity_keys =
std::iter::once(keyword_value.to_string()).collect::<std::collections::BTreeSet<_>>();
let direct_affinities = list_admin_monitoring_cache_affinity_records_by_affinity_keys(
state,
&direct_affinity_keys,
)
.await?;
if !direct_affinities.is_empty() {
matched_api_key_id = Some(keyword_value.to_string());
matched_user_id = admin_monitoring_list_export_api_key_records_by_ids(
state,
&[keyword_value.to_string()],
)
.await?
.get(keyword_value)
.map(|item| item.user_id.clone());
direct_affinities
} else if let Some(user) = state.find_user_auth_by_identifier(keyword_value).await? {
matched_user_id = Some(user.id.clone());
let user_api_key_ids = state
.list_auth_api_key_export_records_by_user_ids(std::slice::from_ref(&user.id))
.await?
.into_iter()
.map(|item| item.api_key_id)
.collect::<std::collections::BTreeSet<_>>();
list_admin_monitoring_cache_affinity_records_by_affinity_keys(state, &user_api_key_ids)
.await?
} else {
list_admin_monitoring_cache_affinity_records(state).await?
}
} else {
list_admin_monitoring_cache_affinity_records(state).await?
};
let (api_key_by_id, user_by_id) =
admin_monitoring_load_affinity_identity_maps(state, &filtered_affinities).await?;
let provider_ids = filtered_affinities
.iter()
.filter_map(|item| item.provider_id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let endpoint_ids = filtered_affinities
.iter()
.filter_map(|item| item.endpoint_id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let key_ids = filtered_affinities
.iter()
.filter_map(|item| item.key_id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let provider_by_id = state
.data
.list_provider_catalog_providers_by_ids(&provider_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.map(|item| (item.id.clone(), item))
.collect::<std::collections::BTreeMap<_, _>>();
let endpoint_by_id = state
.data
.list_provider_catalog_endpoints_by_ids(&endpoint_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.map(|item| (item.id.clone(), item))
.collect::<std::collections::BTreeMap<_, _>>();
let key_by_id = state
.data
.list_provider_catalog_keys_by_ids(&key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.map(|item| (item.id.clone(), item))
.collect::<std::collections::BTreeMap<_, _>>();
let keyword_lower = normalize_keyword(keyword.as_ref());
let mut items = Vec::new();
for affinity in filtered_affinities {
let user_api_key = api_key_by_id.get(&affinity.affinity_key);
let user_id = user_api_key.map(|item| item.user_id.clone());
let user = user_id.as_ref().and_then(|id| user_by_id.get(id));
let provider = affinity
.provider_id
.as_ref()
.and_then(|id| provider_by_id.get(id));
let endpoint = affinity
.endpoint_id
.as_ref()
.and_then(|id| endpoint_by_id.get(id));
let key = affinity.key_id.as_ref().and_then(|id| key_by_id.get(id));
let user_api_key_name = user_api_key.and_then(|item| item.name.clone());
let user_api_key_prefix = user_api_key.and_then(|item| {
admin_monitoring_masked_user_api_key_prefix(state, item.key_encrypted.as_deref())
});
let provider_name = provider.map(|item| item.name.clone());
let endpoint_url = endpoint
.map(|item| item.base_url.clone())
.filter(|value| !value.trim().is_empty());
let key_name = key.map(|item| item.name.clone());
let key_prefix =
key.and_then(|item| admin_monitoring_masked_provider_key_prefix(state, item));
let user_id_text = user_id.clone();
let username = user.map(|item| item.username.clone());
let email = user.and_then(|item| item.email.clone());
let provider_id = affinity.provider_id.clone();
let key_id = affinity.key_id.clone();
if let Some(keyword_value) = keyword_lower.as_deref() {
if matched_user_id.is_none() && matched_api_key_id.is_none() {
let searchable = [
Some(affinity.affinity_key.as_str()),
user_api_key_name.as_deref(),
user_id_text.as_deref(),
username.as_deref(),
email.as_deref(),
provider_id.as_deref(),
key_id.as_deref(),
];
if !searchable
.into_iter()
.flatten()
.any(|value| value.to_ascii_lowercase().contains(keyword_value))
{
continue;
}
}
}
items.push(json!({
"affinity_key": affinity.affinity_key,
"user_api_key_name": user_api_key_name,
"user_api_key_prefix": user_api_key_prefix,
"is_standalone": user_api_key.map(|item| item.is_standalone).unwrap_or(false),
"user_id": user_id_text,
"username": username,
"email": email,
"provider_id": provider_id,
"provider_name": provider_name,
"endpoint_id": affinity.endpoint_id,
"endpoint_url": endpoint_url,
"key_id": key_id,
"key_name": key_name,
"key_prefix": key_prefix,
"rate_multipliers": key.and_then(|item| item.rate_multipliers.clone()),
"global_model_id": affinity.model_name,
"model_name": affinity.model_name,
"model_display_name": serde_json::Value::Null,
"api_format": affinity.api_format,
"created_at": affinity.created_at,
"expire_at": affinity.expire_at,
"request_count": affinity.request_count,
}));
}
items.sort_by(|left, right| {
admin_monitoring_cache_affinity_sort_value(right.get("expire_at"))
.partial_cmp(&admin_monitoring_cache_affinity_sort_value(
left.get("expire_at"),
))
.unwrap_or(std::cmp::Ordering::Equal)
});
let total = items.len();
let paged_items = items
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
let paged_count = paged_items.len();
Ok(Json(json!({
"status": "ok",
"data": {
"items": paged_items,
"meta": {
"total": total,
"limit": limit,
"offset": offset,
"count": paged_count,
},
"matched_user_id": matched_user_id,
}
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_cache_affinity_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(user_identifier) =
admin_monitoring_cache_affinity_user_identifier_from_path(&request_context.request_path)
else {
return Ok(admin_monitoring_bad_request_response(
"缺少 user_identifier",
));
};
let direct_api_key_by_id =
admin_monitoring_list_export_api_key_records_by_ids(state, &[user_identifier.clone()])
.await?;
let direct_affinity_keys =
std::iter::once(user_identifier.clone()).collect::<std::collections::BTreeSet<_>>();
let direct_affinities =
list_admin_monitoring_cache_affinity_records_by_affinity_keys(state, &direct_affinity_keys)
.await?;
let (resolved_user_id, username, email, filtered_affinities) = if !direct_affinities.is_empty()
|| direct_api_key_by_id.contains_key(&user_identifier)
{
let user_id = direct_api_key_by_id
.get(&user_identifier)
.map(|item| item.user_id.clone());
let user = match user_id.as_deref() {
Some(user_id) => admin_monitoring_find_user_summary_by_id(state, user_id).await?,
None => None,
};
(
user_id,
user.as_ref().map(|item| item.username.clone()),
user.and_then(|item| item.email),
direct_affinities,
)
} else if let Some(user) = state.find_user_auth_by_identifier(&user_identifier).await? {
let user_api_key_ids = state
.list_auth_api_key_export_records_by_user_ids(std::slice::from_ref(&user.id))
.await?
.into_iter()
.map(|item| item.api_key_id)
.collect::<std::collections::BTreeSet<_>>();
let affinities =
list_admin_monitoring_cache_affinity_records_by_affinity_keys(state, &user_api_key_ids)
.await?;
(Some(user.id), Some(user.username), user.email, affinities)
} else {
return Ok(admin_monitoring_cache_affinity_not_found_response(
&user_identifier,
));
};
if filtered_affinities.is_empty() {
let display_name = username.clone().unwrap_or_else(|| user_identifier.clone());
return Ok(Json(json!({
"status": "not_found",
"message": format!(
"用户 {} ({}) 没有缓存亲和性",
display_name,
email.clone().unwrap_or_else(|| "null".to_string()),
),
"user_info": {
"user_id": resolved_user_id,
"username": username,
"email": email,
},
"affinities": [],
}))
.into_response());
}
let mut affinities = filtered_affinities
.into_iter()
.map(|item| {
json!({
"provider_id": item.provider_id,
"endpoint_id": item.endpoint_id,
"key_id": item.key_id,
"api_format": item.api_format,
"model_name": item.model_name,
"created_at": item.created_at,
"expire_at": item.expire_at,
"request_count": item.request_count,
})
})
.collect::<Vec<_>>();
affinities.sort_by(|left, right| {
admin_monitoring_cache_affinity_sort_value(right.get("expire_at"))
.partial_cmp(&admin_monitoring_cache_affinity_sort_value(
left.get("expire_at"),
))
.unwrap_or(std::cmp::Ordering::Equal)
});
let total_endpoints = affinities.len();
Ok(Json(json!({
"status": "ok",
"user_info": {
"user_id": resolved_user_id,
"username": username,
"email": email,
},
"affinities": affinities,
"total_endpoints": total_endpoints,
}))
.into_response())
}

View File

@@ -0,0 +1,119 @@
pub(crate) const ADMIN_MONITORING_CACHE_AFFINITY_REDIS_REQUIRED_DETAIL: &str =
"Redis未初始化无法获取缓存亲和性";
pub(crate) const ADMIN_MONITORING_REDIS_REQUIRED_DETAIL: &str = "Redis 未启用";
pub(crate) const ADMIN_MONITORING_CACHE_AFFINITY_DEFAULT_TTL_SECS: u64 = 300;
pub(crate) const ADMIN_MONITORING_CACHE_RESERVATION_RATIO: f64 = 0.1;
pub(crate) const ADMIN_MONITORING_DYNAMIC_RESERVATION_PROBE_PHASE_REQUESTS: u64 = 100;
pub(crate) const ADMIN_MONITORING_DYNAMIC_RESERVATION_PROBE_RESERVATION: f64 = 0.1;
pub(crate) const ADMIN_MONITORING_DYNAMIC_RESERVATION_STABLE_MIN_RESERVATION: f64 = 0.1;
pub(crate) const ADMIN_MONITORING_DYNAMIC_RESERVATION_STABLE_MAX_RESERVATION: f64 = 0.35;
pub(crate) const ADMIN_MONITORING_DYNAMIC_RESERVATION_LOW_LOAD_THRESHOLD: f64 = 0.5;
pub(crate) const ADMIN_MONITORING_DYNAMIC_RESERVATION_HIGH_LOAD_THRESHOLD: f64 = 0.8;
pub(crate) const ADMIN_MONITORING_REDIS_CACHE_CATEGORIES: &[(&str, &str, &str, &str)] = &[
(
"upstream_models",
"上游模型",
"upstream_models:*",
"Provider 上游获取的模型列表缓存",
),
("model_id", "模型 ID", "model:id:*", "Model 按 ID 缓存"),
(
"model_provider_global",
"模型映射",
"model:provider_global:*",
"Provider-GlobalModel 模型映射缓存",
),
(
"provider_mapping_preview",
"映射预览",
"admin:providers:mapping-preview:*",
"Provider 详情页 mapping-preview 缓存",
),
(
"global_model",
"全局模型",
"global_model:*",
"GlobalModel 缓存ID/名称/解析)",
),
(
"models_list",
"模型列表",
"models:list:*",
"/v1/models 端点模型列表缓存",
),
("user", "用户", "user:*", "用户信息缓存ID/Email"),
(
"apikey",
"API Key",
"apikey:*",
"API Key 认证缓存Hash/Auth",
),
(
"api_key_id",
"API Key ID",
"api_key:id:*",
"API Key 按 ID 缓存",
),
(
"cache_affinity",
"缓存亲和性",
"cache_affinity:*",
"请求路由亲和性缓存",
),
(
"provider_billing",
"Provider 计费",
"provider:billing_type:*",
"Provider 计费类型缓存",
),
(
"provider_rate",
"Provider 费率",
"provider_api_key:rate_multiplier:*",
"ProviderAPIKey 费率倍数缓存",
),
(
"provider_balance",
"Provider 余额",
"provider_ops:balance:*",
"Provider 余额查询缓存",
),
("health", "健康检查", "health:*", "端点健康状态缓存"),
(
"endpoint_status",
"端点状态",
"endpoint_status:*",
"用户端点状态缓存",
),
("dashboard", "仪表盘", "dashboard:*", "仪表盘统计缓存"),
(
"activity_heatmap",
"活动热力图",
"activity_heatmap:*",
"用户活动热力图缓存",
),
(
"gemini_files",
"Gemini 文件映射",
"gemini_files:*",
"Gemini Files API 文件-Key 映射缓存",
),
(
"provider_oauth",
"OAuth 状态",
"provider_oauth_state:*",
"Provider OAuth 授权流程临时状态",
),
(
"oauth_refresh_lock",
"OAuth 刷新锁",
"provider_oauth_refresh_lock:*",
"OAuth Token 刷新分布式锁",
),
(
"concurrency_lock",
"并发锁",
"concurrency:*",
"请求并发控制锁",
),
];

View File

@@ -1,4 +1,4 @@
use super::AdminMonitoringCacheAffinityRecord;
use super::cache_types::AdminMonitoringCacheAffinityRecord;
use crate::{AppState, GatewayError};
pub(super) async fn admin_monitoring_list_export_api_key_records_by_ids(

View File

@@ -0,0 +1,109 @@
use super::cache_config::ADMIN_MONITORING_REDIS_CACHE_CATEGORIES;
use super::cache_store::{
admin_monitoring_has_test_redis_keys, list_admin_monitoring_namespaced_keys,
};
use crate::AppState;
use crate::GatewayError;
use axum::{
body::Body,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn build_admin_monitoring_model_mapping_stats_response(
state: &AppState,
) -> Result<Response<Body>, GatewayError> {
if state.redis_kv_runner().is_none() && !admin_monitoring_has_test_redis_keys(state) {
return Ok(Json(json!({
"status": "ok",
"data": {
"available": false,
"message": "Redis 未启用,模型映射缓存不可用",
}
}))
.into_response());
};
let model_id_keys = list_admin_monitoring_namespaced_keys(state, "model:id:*").await?;
let global_model_id_keys =
list_admin_monitoring_namespaced_keys(state, "global_model:id:*").await?;
let global_model_name_keys =
list_admin_monitoring_namespaced_keys(state, "global_model:name:*").await?;
let global_model_resolve_keys =
list_admin_monitoring_namespaced_keys(state, "global_model:resolve:*").await?;
let provider_global_keys =
list_admin_monitoring_namespaced_keys(state, "model:provider_global:*")
.await?
.into_iter()
.filter(|key| !key.starts_with("model:provider_global:hits:"))
.collect::<Vec<_>>();
let total_keys = model_id_keys.len()
+ global_model_id_keys.len()
+ global_model_name_keys.len()
+ global_model_resolve_keys.len()
+ provider_global_keys.len();
Ok(Json(json!({
"status": "ok",
"data": {
"available": true,
"ttl_seconds": 300,
"total_keys": total_keys,
"breakdown": {
"model_by_id": model_id_keys.len(),
"model_by_provider_global": provider_global_keys.len(),
"global_model_by_id": global_model_id_keys.len(),
"global_model_by_name": global_model_name_keys.len(),
"global_model_resolve": global_model_resolve_keys.len(),
},
"mappings": [],
"provider_model_mappings": serde_json::Value::Null,
"unmapped": serde_json::Value::Null,
}
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_redis_cache_categories_response(
state: &AppState,
) -> Result<Response<Body>, GatewayError> {
if state.redis_kv_runner().is_none() && !admin_monitoring_has_test_redis_keys(state) {
return Ok(Json(json!({
"status": "ok",
"data": {
"available": false,
"message": "Redis 未启用",
}
}))
.into_response());
};
let mut categories = Vec::with_capacity(ADMIN_MONITORING_REDIS_CACHE_CATEGORIES.len());
let mut total_keys = 0usize;
for (key, name, pattern, description) in ADMIN_MONITORING_REDIS_CACHE_CATEGORIES {
let count = list_admin_monitoring_namespaced_keys(state, pattern)
.await?
.len();
total_keys += count;
categories.push(json!({
"key": key,
"name": name,
"pattern": pattern,
"description": description,
"count": count,
}));
}
Ok(Json(json!({
"status": "ok",
"data": {
"available": true,
"categories": categories,
"total_keys": total_keys,
}
}))
.into_response())
}

View File

@@ -0,0 +1,400 @@
use super::cache_affinity::{
clear_admin_monitoring_scheduler_affinity_entries,
delete_admin_monitoring_cache_affinity_raw_keys,
};
use super::cache_config::ADMIN_MONITORING_REDIS_CACHE_CATEGORIES;
use super::cache_identity::{
admin_monitoring_find_user_summary_by_id, admin_monitoring_list_export_api_key_records_by_ids,
};
use super::cache_route_helpers::{
admin_monitoring_cache_affinity_delete_params_from_path,
admin_monitoring_cache_affinity_unavailable_response,
admin_monitoring_cache_model_mapping_provider_params_from_path,
admin_monitoring_cache_model_name_from_path, admin_monitoring_cache_provider_id_from_path,
admin_monitoring_cache_redis_category_from_path,
admin_monitoring_cache_users_not_found_response,
admin_monitoring_cache_users_user_identifier_from_path,
admin_monitoring_redis_unavailable_response,
};
use super::cache_store::{
admin_monitoring_has_test_redis_keys, delete_admin_monitoring_namespaced_keys,
list_admin_monitoring_cache_affinity_records,
list_admin_monitoring_cache_affinity_records_by_affinity_keys,
list_admin_monitoring_namespaced_keys, load_admin_monitoring_cache_affinity_entries_for_tests,
};
use super::responses::{
admin_monitoring_bad_request_response, admin_monitoring_not_found_response,
};
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn build_admin_monitoring_cache_users_delete_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(user_identifier) =
admin_monitoring_cache_users_user_identifier_from_path(&request_context.request_path)
else {
return Ok(admin_monitoring_bad_request_response(
"缺少 user_identifier",
));
};
if state.redis_kv_runner().is_none()
&& load_admin_monitoring_cache_affinity_entries_for_tests(state).is_empty()
{
return Ok(admin_monitoring_cache_affinity_unavailable_response());
}
let direct_api_key_by_id =
admin_monitoring_list_export_api_key_records_by_ids(state, &[user_identifier.clone()])
.await?;
if let Some(api_key) = direct_api_key_by_id.get(&user_identifier) {
let target_affinity_keys =
std::iter::once(user_identifier.clone()).collect::<std::collections::BTreeSet<_>>();
let target_affinities = list_admin_monitoring_cache_affinity_records_by_affinity_keys(
state,
&target_affinity_keys,
)
.await?;
let raw_keys = target_affinities
.iter()
.map(|item| item.raw_key.clone())
.collect::<Vec<_>>();
let _ = delete_admin_monitoring_cache_affinity_raw_keys(state, &raw_keys).await?;
clear_admin_monitoring_scheduler_affinity_entries(state, &target_affinities);
let user = admin_monitoring_find_user_summary_by_id(state, &api_key.user_id).await?;
let api_key_name = api_key
.name
.clone()
.unwrap_or_else(|| user_identifier.clone());
return Ok(Json(json!({
"status": "ok",
"message": format!("已清除 API Key {api_key_name} 的缓存亲和性"),
"user_info": {
"user_id": Some(api_key.user_id.clone()),
"username": user.as_ref().map(|item| item.username.clone()),
"email": user.and_then(|item| item.email),
"api_key_id": user_identifier,
"api_key_name": api_key.name.clone(),
},
}))
.into_response());
}
let Some(user) = state.find_user_auth_by_identifier(&user_identifier).await? else {
return Ok(admin_monitoring_cache_users_not_found_response(
&user_identifier,
));
};
let user_api_key_ids = state
.list_auth_api_key_export_records_by_user_ids(std::slice::from_ref(&user.id))
.await?
.into_iter()
.map(|item| item.api_key_id.clone())
.collect::<std::collections::BTreeSet<_>>();
let target_affinities =
list_admin_monitoring_cache_affinity_records_by_affinity_keys(state, &user_api_key_ids)
.await?;
let raw_keys = target_affinities
.iter()
.map(|item| item.raw_key.clone())
.collect::<Vec<_>>();
let _ = delete_admin_monitoring_cache_affinity_raw_keys(state, &raw_keys).await?;
clear_admin_monitoring_scheduler_affinity_entries(state, &target_affinities);
Ok(Json(json!({
"status": "ok",
"message": format!("已清除用户 {} 的所有缓存亲和性", user.username),
"user_info": {
"user_id": user.id,
"username": user.username,
"email": user.email,
},
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_cache_affinity_delete_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some((affinity_key, endpoint_id, model_id, api_format)) =
admin_monitoring_cache_affinity_delete_params_from_path(&request_context.request_path)
else {
return Ok(admin_monitoring_bad_request_response(
"缺少 affinity_key、endpoint_id、model_id 或 api_format",
));
};
if state.redis_kv_runner().is_none()
&& load_admin_monitoring_cache_affinity_entries_for_tests(state).is_empty()
{
return Ok(admin_monitoring_cache_affinity_unavailable_response());
}
let target_affinity_keys =
std::iter::once(affinity_key.clone()).collect::<std::collections::BTreeSet<_>>();
let target_affinity =
list_admin_monitoring_cache_affinity_records_by_affinity_keys(state, &target_affinity_keys)
.await?
.into_iter()
.find(|item| {
item.affinity_key == affinity_key
&& item.endpoint_id.as_deref() == Some(endpoint_id.as_str())
&& item.model_name == model_id
&& item.api_format.eq_ignore_ascii_case(&api_format)
});
let Some(target_affinity) = target_affinity else {
return Ok(admin_monitoring_not_found_response(
"未找到指定的缓存亲和性记录",
));
};
let _ = delete_admin_monitoring_cache_affinity_raw_keys(
state,
std::slice::from_ref(&target_affinity.raw_key),
)
.await?;
clear_admin_monitoring_scheduler_affinity_entries(
state,
std::slice::from_ref(&target_affinity),
);
let mut api_key_by_id = admin_monitoring_list_export_api_key_records_by_ids(
state,
std::slice::from_ref(&affinity_key),
)
.await?;
let api_key_name = api_key_by_id
.remove(&affinity_key)
.and_then(|item| item.name)
.unwrap_or_else(|| affinity_key.chars().take(8).collect::<String>());
Ok(Json(json!({
"status": "ok",
"message": format!("已清除缓存亲和性: {api_key_name}"),
"affinity_key": affinity_key,
"endpoint_id": endpoint_id,
"model_id": model_id,
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_cache_flush_response(
state: &AppState,
) -> Result<Response<Body>, GatewayError> {
let raw_affinities = list_admin_monitoring_cache_affinity_records(state).await?;
if state.redis_kv_runner().is_none() && raw_affinities.is_empty() {
return Ok(admin_monitoring_cache_affinity_unavailable_response());
}
let raw_keys = raw_affinities
.iter()
.map(|item| item.raw_key.clone())
.collect::<Vec<_>>();
let deleted = delete_admin_monitoring_cache_affinity_raw_keys(state, &raw_keys).await?;
clear_admin_monitoring_scheduler_affinity_entries(state, &raw_affinities);
Ok(Json(json!({
"status": "ok",
"message": "已清除全部缓存亲和性",
"deleted_affinities": deleted,
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_cache_provider_delete_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(provider_id) =
admin_monitoring_cache_provider_id_from_path(&request_context.request_path)
else {
return Ok(admin_monitoring_bad_request_response("缺少 provider_id"));
};
let raw_affinities = list_admin_monitoring_cache_affinity_records(state).await?;
if state.redis_kv_runner().is_none() && raw_affinities.is_empty() {
return Ok(admin_monitoring_cache_affinity_unavailable_response());
}
let target_affinities = raw_affinities
.into_iter()
.filter(|item| item.provider_id.as_deref() == Some(provider_id.as_str()))
.collect::<Vec<_>>();
if target_affinities.is_empty() {
return Ok((
http::StatusCode::NOT_FOUND,
Json(json!({
"detail": format!("未找到 provider {provider_id} 的缓存亲和性记录")
})),
)
.into_response());
}
let raw_keys = target_affinities
.iter()
.map(|item| item.raw_key.clone())
.collect::<Vec<_>>();
let deleted = delete_admin_monitoring_cache_affinity_raw_keys(state, &raw_keys).await?;
clear_admin_monitoring_scheduler_affinity_entries(state, &target_affinities);
Ok(Json(json!({
"status": "ok",
"message": format!("已清除 provider {provider_id} 的缓存亲和性"),
"provider_id": provider_id,
"deleted_affinities": deleted,
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_model_mapping_delete_response(
state: &AppState,
) -> Result<Response<Body>, GatewayError> {
if state.redis_kv_runner().is_none() && !admin_monitoring_has_test_redis_keys(state) {
return Ok(admin_monitoring_redis_unavailable_response());
}
let mut raw_keys = list_admin_monitoring_namespaced_keys(state, "model:*").await?;
raw_keys.extend(list_admin_monitoring_namespaced_keys(state, "global_model:*").await?);
raw_keys.sort();
raw_keys.dedup();
let deleted_count = delete_admin_monitoring_namespaced_keys(state, &raw_keys).await?;
Ok(Json(json!({
"status": "ok",
"message": "已清除所有模型映射缓存",
"deleted_count": deleted_count,
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_model_mapping_delete_model_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(model_name) =
admin_monitoring_cache_model_name_from_path(&request_context.request_path)
else {
return Ok(admin_monitoring_bad_request_response("缺少 model_name"));
};
if state.redis_kv_runner().is_none() && !admin_monitoring_has_test_redis_keys(state) {
return Ok(admin_monitoring_redis_unavailable_response());
}
let candidate_keys = [
format!("global_model:resolve:{model_name}"),
format!("global_model:name:{model_name}"),
];
let mut existing_keys = Vec::new();
for key in candidate_keys {
let matches = list_admin_monitoring_namespaced_keys(state, key.as_str()).await?;
existing_keys.extend(matches);
}
existing_keys.sort();
existing_keys.dedup();
let deleted_count = delete_admin_monitoring_namespaced_keys(state, &existing_keys).await?;
let deleted_keys = if deleted_count == 0 {
Vec::new()
} else {
existing_keys
};
Ok(Json(json!({
"status": "ok",
"message": format!("已清除模型 {model_name} 的映射缓存"),
"model_name": model_name,
"deleted_keys": deleted_keys,
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_model_mapping_delete_provider_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some((provider_id, global_model_id)) =
admin_monitoring_cache_model_mapping_provider_params_from_path(
&request_context.request_path,
)
else {
return Ok(admin_monitoring_bad_request_response(
"缺少 provider_id 或 global_model_id",
));
};
if state.redis_kv_runner().is_none() && !admin_monitoring_has_test_redis_keys(state) {
return Ok(admin_monitoring_redis_unavailable_response());
}
let candidate_keys = [
format!("model:provider_global:{provider_id}:{global_model_id}"),
format!("model:provider_global:hits:{provider_id}:{global_model_id}"),
];
let mut existing_keys = Vec::new();
for key in candidate_keys {
let matches = list_admin_monitoring_namespaced_keys(state, key.as_str()).await?;
existing_keys.extend(matches);
}
existing_keys.sort();
existing_keys.dedup();
let _ = delete_admin_monitoring_namespaced_keys(state, &existing_keys).await?;
Ok(Json(json!({
"status": "ok",
"message": "已清除 Provider 模型映射缓存",
"provider_id": provider_id,
"global_model_id": global_model_id,
"deleted_keys": existing_keys,
}))
.into_response())
}
pub(super) async fn build_admin_monitoring_redis_keys_delete_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(category) =
admin_monitoring_cache_redis_category_from_path(&request_context.request_path)
else {
return Ok(admin_monitoring_bad_request_response("缺少 category"));
};
let Some((cat_key, name, pattern, _description)) = ADMIN_MONITORING_REDIS_CACHE_CATEGORIES
.iter()
.find(|(cat_key, _, _, _)| *cat_key == category)
else {
return Ok((
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("未知的缓存分类: {category}") })),
)
.into_response());
};
if state.redis_kv_runner().is_none() && !admin_monitoring_has_test_redis_keys(state) {
return Ok(admin_monitoring_redis_unavailable_response());
}
let raw_keys = list_admin_monitoring_namespaced_keys(state, pattern).await?;
let deleted_count = delete_admin_monitoring_namespaced_keys(state, &raw_keys).await?;
Ok(Json(json!({
"status": "ok",
"message": format!("已清除 {name} 缓存"),
"category": cat_key,
"deleted_count": deleted_count,
}))
.into_response())
}

View File

@@ -2,6 +2,7 @@ use crate::AppState;
use aether_crypto::decrypt_python_fernet_ciphertext;
#[cfg(test)]
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
pub(super) fn admin_monitoring_masked_user_api_key_prefix(
state: &AppState,
@@ -23,7 +24,7 @@ pub(super) fn admin_monitoring_masked_user_api_key_prefix(
pub(super) fn admin_monitoring_masked_provider_key_prefix(
state: &AppState,
key: &aether_data::repository::provider_catalog::StoredProviderCatalogKey,
key: &StoredProviderCatalogKey,
) -> Option<String> {
match key.auth_type.trim() {
"service_account" | "vertex_ai" => Some("[Service Account]".to_string()),

View File

@@ -1,7 +1,7 @@
use super::{
use super::cache_config::{
ADMIN_MONITORING_CACHE_AFFINITY_REDIS_REQUIRED_DETAIL, ADMIN_MONITORING_REDIS_REQUIRED_DETAIL,
};
use crate::handlers::query_param_value;
use crate::handlers::admin::shared::query_param_value;
use axum::{
body::Body,
http,

View File

@@ -1,7 +1,8 @@
use super::cache_affinity::admin_monitoring_cache_affinity_record;
use super::{AdminMonitoringCacheAffinityRecord, AdminMonitoringCacheSnapshot};
use crate::handlers::round_to;
use super::cache_types::{AdminMonitoringCacheAffinityRecord, AdminMonitoringCacheSnapshot};
use crate::handlers::admin::observability::stats::round_to;
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::usage::UsageAuditListQuery;
async fn count_admin_monitoring_cache_affinity_entries(state: &AppState) -> usize {
let Some(runner) = state.redis_kv_runner() else {
@@ -287,7 +288,7 @@ pub(super) async fn build_admin_monitoring_cache_snapshot(
let now = chrono::Utc::now();
let usage = if state.has_usage_data_reader() {
state
.list_usage_audits(&aether_data::repository::usage::UsageAuditListQuery {
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: Some(
(now - chrono::Duration::hours(24)).timestamp().max(0) as u64,
),

View File

@@ -0,0 +1,27 @@
#[derive(Debug, Clone)]
pub(super) struct AdminMonitoringCacheAffinityRecord {
pub(super) raw_key: String,
pub(super) affinity_key: String,
pub(super) api_format: String,
pub(super) model_name: String,
pub(super) provider_id: Option<String>,
pub(super) endpoint_id: Option<String>,
pub(super) key_id: Option<String>,
pub(super) created_at: Option<serde_json::Value>,
pub(super) expire_at: Option<serde_json::Value>,
pub(super) request_count: u64,
}
pub(super) struct AdminMonitoringCacheSnapshot {
pub(super) scheduler_name: String,
pub(super) scheduling_mode: String,
pub(super) provider_priority_mode: String,
pub(super) storage_type: &'static str,
pub(super) total_affinities: usize,
pub(super) cache_hits: usize,
pub(super) cache_misses: usize,
pub(super) cache_hit_rate: f64,
pub(super) provider_switches: usize,
pub(super) key_switches: usize,
pub(super) cache_invalidations: usize,
}

View File

@@ -0,0 +1,34 @@
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{body::Body, response::Response};
mod activity;
mod cache;
mod cache_affinity;
mod cache_affinity_reads;
mod cache_config;
mod cache_identity;
mod cache_model_mapping;
mod cache_mutations;
mod cache_payloads;
mod cache_route_helpers;
mod cache_store;
mod cache_types;
mod resilience;
mod responses;
mod route_filters;
mod routes;
#[cfg(test)]
pub(crate) mod test_support;
mod trace;
mod usage_helpers;
pub(crate) async fn maybe_build_local_admin_monitoring_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Option<Response<Body>>, GatewayError> {
routes::maybe_build_local_admin_monitoring_response(state, request_context).await
}
#[cfg(test)]
mod tests;

View File

@@ -1,12 +1,13 @@
use super::{
admin_monitoring_bad_request_response, admin_monitoring_usage_is_error,
AdminMonitoringResilienceSnapshot,
};
use super::responses::admin_monitoring_bad_request_response;
use super::usage_helpers::admin_monitoring_usage_is_error;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{
use crate::handlers::admin::shared::{
provider_key_health_summary, query_param_value, unix_secs_to_rfc3339,
};
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::{
provider_catalog::StoredProviderCatalogKey, usage::UsageAuditListQuery,
};
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -15,6 +16,16 @@ use axum::{
use serde_json::json;
use std::collections::BTreeMap;
struct AdminMonitoringResilienceSnapshot {
timestamp: chrono::DateTime<chrono::Utc>,
health_score: i64,
status: &'static str,
error_statistics: serde_json::Value,
recent_errors: Vec<serde_json::Value>,
recommendations: Vec<String>,
previous_stats: serde_json::Value,
}
fn build_admin_monitoring_resilience_recommendations(
total_errors: usize,
health_score: i64,
@@ -56,7 +67,7 @@ fn parse_admin_monitoring_circuit_history_limit(query: Option<&str>) -> Result<u
}
fn build_admin_monitoring_circuit_history_items(
keys: &[aether_data::repository::provider_catalog::StoredProviderCatalogKey],
keys: &[StoredProviderCatalogKey],
provider_name_by_id: &BTreeMap<String, String>,
limit: usize,
) -> Vec<serde_json::Value> {
@@ -338,7 +349,7 @@ async fn build_admin_monitoring_resilience_snapshot(
}
let mut recent_usage_errors = state
.list_usage_audits(&aether_data::repository::usage::UsageAuditListQuery {
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: Some(recent_error_from.timestamp().max(0) as u64),
..Default::default()
})

View File

@@ -0,0 +1,23 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) fn admin_monitoring_bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
pub(super) fn admin_monitoring_not_found_response(detail: &'static str) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": detail })),
)
.into_response()
}

View File

@@ -1,4 +1,4 @@
use crate::handlers::query_param_value;
use crate::handlers::admin::shared::query_param_value;
pub(super) fn admin_monitoring_escape_like_pattern(value: &str) -> String {
value

View File

@@ -1,32 +1,46 @@
use super::{
build_admin_monitoring_audit_logs_response, build_admin_monitoring_cache_affinities_response,
build_admin_monitoring_cache_affinity_delete_response,
build_admin_monitoring_cache_affinity_response, build_admin_monitoring_cache_config_response,
build_admin_monitoring_cache_flush_response, build_admin_monitoring_cache_metrics_response,
build_admin_monitoring_cache_provider_delete_response,
use super::activity::{
build_admin_monitoring_audit_logs_response,
build_admin_monitoring_suspicious_activities_response,
build_admin_monitoring_system_status_response, build_admin_monitoring_user_behavior_response,
};
use super::cache::{
build_admin_monitoring_cache_config_response, build_admin_monitoring_cache_metrics_response,
build_admin_monitoring_cache_stats_response,
};
use super::cache_affinity_reads::{
build_admin_monitoring_cache_affinities_response,
build_admin_monitoring_cache_affinity_response,
};
use super::cache_model_mapping::{
build_admin_monitoring_model_mapping_stats_response,
build_admin_monitoring_redis_cache_categories_response,
};
use super::cache_mutations::{
build_admin_monitoring_cache_affinity_delete_response,
build_admin_monitoring_cache_flush_response,
build_admin_monitoring_cache_provider_delete_response,
build_admin_monitoring_cache_users_delete_response,
build_admin_monitoring_model_mapping_delete_model_response,
build_admin_monitoring_model_mapping_delete_provider_response,
build_admin_monitoring_model_mapping_delete_response,
build_admin_monitoring_model_mapping_stats_response,
build_admin_monitoring_redis_cache_categories_response,
build_admin_monitoring_redis_keys_delete_response,
};
use super::resilience::{
build_admin_monitoring_reset_error_stats_response,
build_admin_monitoring_resilience_circuit_history_response,
build_admin_monitoring_resilience_status_response,
build_admin_monitoring_suspicious_activities_response,
build_admin_monitoring_system_status_response,
};
use super::trace::{
build_admin_monitoring_trace_provider_stats_response,
build_admin_monitoring_trace_request_response, build_admin_monitoring_user_behavior_response,
build_admin_monitoring_trace_request_response,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::misc_helpers::attach_admin_audit_response;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{body::Body, http, response::Response};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AdminMonitoringRoute {
pub(crate) enum AdminMonitoringRoute {
AuditLogs,
SystemStatus,
SuspiciousActivities,
@@ -53,7 +67,7 @@ pub(super) enum AdminMonitoringRoute {
CacheRedisKeysDelete,
}
pub(super) async fn maybe_build_local_admin_monitoring_response(
pub(crate) async fn maybe_build_local_admin_monitoring_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Option<Response<Body>>, GatewayError> {
@@ -180,7 +194,7 @@ fn admin_monitoring_audit_target_id(request_context: &GatewayPublicRequestContex
}
}
pub(super) fn match_admin_monitoring_route(
pub(crate) fn match_admin_monitoring_route(
method: &http::Method,
path: &str,
) -> Option<AdminMonitoringRoute> {

View File

@@ -1,14 +1,16 @@
use crate::control::GatewayPublicRequestContext;
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data_contracts::repository::{
candidates::{RequestCandidateStatus, StoredRequestCandidate},
provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
},
usage::StoredRequestUsageAudit,
};
use axum::http::{self, Uri};
use serde_json::json;
use aether_data::repository::auth::StoredAuthApiKeyExportRecord;
use aether_data::repository::candidates::{RequestCandidateStatus, StoredRequestCandidate};
use aether_data::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_data::repository::usage::StoredRequestUsageAudit;
use aether_data::repository::users::{StoredUserAuthRecord, StoredUserExportRow};
pub(super) fn request_context(method: http::Method, uri: &str) -> GatewayPublicRequestContext {

View File

@@ -1,8 +1,8 @@
use super::super::test_support::{request_context, sample_key, sample_provider, sample_usage};
use super::super::{
match_admin_monitoring_route, maybe_build_local_admin_monitoring_response,
AdminMonitoringRoute, ADMIN_MONITORING_REDIS_REQUIRED_DETAIL,
use super::super::cache_config::ADMIN_MONITORING_REDIS_REQUIRED_DETAIL;
use super::super::routes::{
match_admin_monitoring_route, maybe_build_local_admin_monitoring_response, AdminMonitoringRoute,
};
use super::super::test_support::{request_context, sample_key, sample_provider, sample_usage};
use crate::AppState;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::usage::InMemoryUsageReadRepository;
@@ -288,13 +288,11 @@ async fn admin_monitoring_cache_stats_returns_local_payload() {
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_usage_reader_for_tests(
usage_repository,
)
.with_system_config_values_for_tests([
("scheduling_mode".to_string(), json!("cache_affinity")),
("provider_priority_mode".to_string(), json!("provider")),
]),
crate::data::GatewayDataState::with_usage_reader_for_tests(usage_repository)
.with_system_config_values_for_tests([
("scheduling_mode".to_string(), json!("cache_affinity")),
("provider_priority_mode".to_string(), json!("provider")),
]),
);
let context = request_context(http::Method::GET, "/api/admin/monitoring/cache/stats");

View File

@@ -1,5 +1,12 @@
use super::test_support::*;
use super::{maybe_build_local_admin_monitoring_response, AppState};
use aether_data_contracts::repository::{
candidates::{RequestCandidateStatus, StoredRequestCandidate},
provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
},
usage::StoredRequestUsageAudit,
};
use axum::body::to_bytes;
use serde_json::json;
use std::sync::Arc;
@@ -7,19 +14,13 @@ use std::sync::Arc;
use aether_data::repository::auth::{
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord,
};
use aether_data::repository::candidates::{
InMemoryRequestCandidateRepository, RequestCandidateStatus, StoredRequestCandidate,
};
use aether_data::repository::provider_catalog::{
InMemoryProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogProvider,
};
use aether_data::repository::usage::{InMemoryUsageReadRepository, StoredRequestUsageAudit};
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::usage::InMemoryUsageReadRepository;
use aether_data::repository::users::{
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow,
};
#[path = "tests_basics.rs"]
mod basics;
#[tokio::test]
@@ -58,10 +59,8 @@ async fn admin_monitoring_cache_affinity_returns_not_found_without_runtime_or_te
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_user_reader_for_tests(
user_repository,
)
.with_auth_api_key_reader(auth_repository),
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository)
.with_auth_api_key_reader(auth_repository),
);
let context = request_context(
http::Method::GET,
@@ -106,11 +105,9 @@ async fn admin_monitoring_cache_affinities_and_affinity_return_local_payload_fro
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog,
)
.with_user_reader(user_repository)
.with_auth_api_key_reader(auth_repository),
crate::data::GatewayDataState::with_provider_catalog_reader_for_tests(provider_catalog)
.with_user_reader(user_repository)
.with_auth_api_key_reader(auth_repository),
)
.with_admin_monitoring_cache_affinity_entry_for_tests(
"cache_affinity:user-key-1:openai:model-alpha",
@@ -212,10 +209,8 @@ async fn admin_monitoring_cache_users_delete_returns_local_payload_from_test_sto
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_user_reader_for_tests(
user_repository,
)
.with_auth_api_key_reader(auth_repository),
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository)
.with_auth_api_key_reader(auth_repository),
)
.with_admin_monitoring_cache_affinity_entry_for_tests(
"cache_affinity:user-key-1:openai:model-alpha",
@@ -600,10 +595,8 @@ async fn admin_monitoring_cache_affinity_delete_returns_local_payload_from_test_
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_user_reader_for_tests(
user_repository,
)
.with_auth_api_key_reader(auth_repository),
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository)
.with_auth_api_key_reader(auth_repository),
)
.with_admin_monitoring_cache_affinity_entry_for_tests(
"cache_affinity:user-key-1:openai:model-alpha",
@@ -720,13 +713,11 @@ async fn admin_monitoring_cache_metrics_returns_local_payload() {
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_usage_reader_for_tests(
usage_repository,
)
.with_system_config_values_for_tests([
("scheduling_mode".to_string(), json!("cache_affinity")),
("provider_priority_mode".to_string(), json!("provider")),
]),
crate::data::GatewayDataState::with_usage_reader_for_tests(usage_repository)
.with_system_config_values_for_tests([
("scheduling_mode".to_string(), json!("cache_affinity")),
("provider_priority_mode".to_string(), json!("provider")),
]),
);
let context = request_context(http::Method::GET, "/api/admin/monitoring/cache/metrics");
@@ -969,9 +960,7 @@ async fn admin_monitoring_circuit_history_returns_local_payload() {
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog,
),
crate::data::GatewayDataState::with_provider_catalog_reader_for_tests(provider_catalog),
);
let context = request_context(
http::Method::GET,
@@ -1001,5 +990,4 @@ async fn admin_monitoring_circuit_history_returns_local_payload() {
);
}
#[path = "tests_trace.rs"]
mod trace_cases;
mod trace;

View File

@@ -1,15 +1,14 @@
use super::super::maybe_build_local_admin_monitoring_response;
use super::super::routes::maybe_build_local_admin_monitoring_response;
use super::super::test_support::{
request_context, sample_candidate, sample_endpoint, sample_key, sample_provider,
};
use crate::AppState;
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
use axum::body::to_bytes;
use serde_json::json;
use std::sync::Arc;
use aether_data::repository::candidates::{
InMemoryRequestCandidateRepository, RequestCandidateStatus,
};
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
#[tokio::test]
@@ -155,3 +154,26 @@ async fn admin_monitoring_trace_provider_stats_returns_local_payload() {
assert_eq!(payload["failure_rate"], json!(50.0));
assert_eq!(payload["avg_latency_ms"], json!(40.0));
}
#[tokio::test]
async fn admin_monitoring_trace_request_returns_contextual_not_found_payload() {
let state = AppState::new().expect("state should build");
let context = request_context(
http::Method::GET,
"/api/admin/monitoring/trace/provider-test-missing?attempted_only=false",
);
let response = maybe_build_local_admin_monitoring_response(&state, &context)
.await
.expect("handler should not error")
.expect("route should be handled locally");
assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(payload["detail"], json!("Request trace not found"));
assert_eq!(payload["request_id"], json!("provider-test-missing"));
assert_eq!(payload["attempted_only"], json!(false));
}

View File

@@ -1,16 +1,18 @@
use super::{
admin_monitoring_bad_request_response, admin_monitoring_not_found_response,
parse_admin_monitoring_limit,
};
use super::responses::admin_monitoring_bad_request_response;
use super::route_filters::parse_admin_monitoring_limit;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{query_param_value, unix_secs_to_rfc3339};
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
use crate::log_ids::short_request_id;
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use tracing::warn;
fn admin_monitoring_trace_request_id_from_path(request_path: &str) -> Option<String> {
let value = request_path
@@ -49,6 +51,21 @@ fn parse_admin_monitoring_attempted_only(query: Option<&str>) -> Result<bool, St
}
}
fn admin_monitoring_trace_not_found_response(
request_id: &str,
attempted_only: bool,
) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({
"detail": "Request trace not found",
"request_id": request_id,
"attempted_only": attempted_only,
})),
)
.into_response()
}
pub(super) async fn build_admin_monitoring_trace_request_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -66,10 +83,23 @@ pub(super) async fn build_admin_monitoring_trace_request_response(
};
let Some(trace) = state
.data
.read_decision_trace(&request_id, attempted_only)
.await?
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
else {
return Ok(admin_monitoring_not_found_response("Request not found"));
warn!(
event_name = "admin_monitoring_request_trace_not_found",
log_type = "admin_monitoring",
request_id = %short_request_id(request_id.as_str()),
attempted_only,
path = %request_context.request_path,
"admin monitoring request trace not found"
);
return Ok(admin_monitoring_trace_not_found_response(
&request_id,
attempted_only,
));
};
let candidates = trace
@@ -142,45 +172,31 @@ pub(super) async fn build_admin_monitoring_trace_provider_stats_response(
let total_attempts = candidates.len();
let success_count = candidates
.iter()
.filter(|item| {
item.status == aether_data::repository::candidates::RequestCandidateStatus::Success
})
.filter(|item| item.status == RequestCandidateStatus::Success)
.count();
let failed_count = candidates
.iter()
.filter(|item| {
item.status == aether_data::repository::candidates::RequestCandidateStatus::Failed
})
.filter(|item| item.status == RequestCandidateStatus::Failed)
.count();
let cancelled_count = candidates
.iter()
.filter(|item| {
item.status == aether_data::repository::candidates::RequestCandidateStatus::Cancelled
})
.filter(|item| item.status == RequestCandidateStatus::Cancelled)
.count();
let skipped_count = candidates
.iter()
.filter(|item| {
item.status == aether_data::repository::candidates::RequestCandidateStatus::Skipped
})
.filter(|item| item.status == RequestCandidateStatus::Skipped)
.count();
let pending_count = candidates
.iter()
.filter(|item| {
item.status == aether_data::repository::candidates::RequestCandidateStatus::Pending
})
.filter(|item| item.status == RequestCandidateStatus::Pending)
.count();
let available_count = candidates
.iter()
.filter(|item| {
item.status == aether_data::repository::candidates::RequestCandidateStatus::Available
})
.filter(|item| item.status == RequestCandidateStatus::Available)
.count();
let unused_count = candidates
.iter()
.filter(|item| {
item.status == aether_data::repository::candidates::RequestCandidateStatus::Unused
})
.filter(|item| item.status == RequestCandidateStatus::Unused)
.count();
let completed_count = success_count + failed_count;
let failure_rate = if completed_count == 0 {

View File

@@ -0,0 +1,9 @@
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
pub(super) fn admin_monitoring_usage_is_error(item: &StoredRequestUsageAudit) -> bool {
item.status_code.is_some_and(|value| value >= 400)
|| item.status.trim().eq_ignore_ascii_case("failed")
|| item.status.trim().eq_ignore_ascii_case("error")
|| item.error_message.is_some()
|| item.error_category.is_some()
}

View File

@@ -1,14 +1,19 @@
use super::{
use super::helpers::{
round_to, AdminStatsComparisonType, AdminStatsGranularity, AdminStatsTimeRange,
AdminStatsUsageFilter,
};
use super::range::{build_comparison_range, list_usage_for_range};
use super::responses::{
admin_stats_bad_request_response, admin_stats_comparison_empty_response,
admin_stats_error_distribution_empty_response,
admin_stats_performance_percentiles_empty_response, admin_stats_time_series_empty_response,
aggregate_usage_stats, build_comparison_range, build_time_series_payload, list_usage_for_range,
pct_change_value, percentile_cont, round_to, AdminStatsComparisonType, AdminStatsGranularity,
AdminStatsTimeRange, AdminStatsUsageFilter,
};
use super::timeseries::{
aggregate_usage_stats, build_time_series_payload, pct_change_value, percentile_cont,
};
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::query_param_value;
use crate::handlers::admin::shared::query_param_value;
use crate::{AppState, GatewayError};
use axum::{
body::Body,

View File

@@ -1,12 +1,17 @@
use super::{
admin_stats_bad_request_response, admin_stats_cost_forecast_empty_response,
admin_stats_cost_savings_empty_response, build_daily_time_series_buckets,
build_time_range_from_days, linear_regression, list_usage_for_range, parse_bounded_u32,
parse_tz_offset_minutes, round_to, AdminStatsForecastPoint, AdminStatsGranularity,
AdminStatsTimeRange, AdminStatsUsageFilter,
use super::helpers::{
round_to, AdminStatsForecastPoint, AdminStatsGranularity, AdminStatsTimeRange,
AdminStatsUsageFilter,
};
use super::range::{
build_time_range_from_days, list_usage_for_range, parse_bounded_u32, parse_tz_offset_minutes,
};
use super::responses::{
admin_stats_bad_request_response, admin_stats_cost_forecast_empty_response,
admin_stats_cost_savings_empty_response,
};
use super::timeseries::{build_daily_time_series_buckets, linear_regression};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::query_param_value;
use crate::handlers::admin::shared::query_param_value;
use crate::{AppState, GatewayError};
use axum::{
body::Body,

View File

@@ -1,60 +1,22 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::query_param_value;
use crate::{AppState, GatewayError};
use axum::{body::Body, response::Response};
use super::range::{
admin_usage_default_days, parse_naive_date, parse_tz_offset_minutes, resolve_preset_dates,
user_today,
};
use crate::handlers::admin::shared::query_param_value;
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
use chrono::Utc;
use serde_json::json;
const MIN_PERCENTILE_SAMPLES: usize = 10;
#[path = "stats/analytics_routes.rs"]
mod analytics_routes;
#[path = "stats/cost_routes.rs"]
mod cost_routes;
#[path = "stats/leaderboard.rs"]
mod leaderboard;
#[path = "stats/leaderboard_routes.rs"]
mod leaderboard_routes;
#[path = "stats/provider_quota_routes.rs"]
mod provider_quota_routes;
#[path = "stats/range.rs"]
mod range;
#[path = "stats/responses.rs"]
mod responses;
#[path = "stats/timeseries.rs"]
mod timeseries;
use self::leaderboard::{
build_api_key_leaderboard_items, build_model_leaderboard_items, build_user_leaderboard_items,
compare_leaderboard_items, compute_dense_rank, load_user_leaderboard_metadata,
};
use self::range::{
admin_usage_default_days, build_comparison_range, build_time_range_from_days,
list_usage_for_range, parse_naive_date, parse_nonnegative_usize, parse_tz_offset_minutes,
resolve_preset_dates, user_today,
};
pub(crate) use self::range::{list_usage_for_optional_range, parse_bounded_u32};
pub(crate) use self::responses::admin_stats_bad_request_response;
use self::responses::{
admin_stats_comparison_empty_response, admin_stats_cost_forecast_empty_response,
admin_stats_cost_savings_empty_response, admin_stats_error_distribution_empty_response,
admin_stats_leaderboard_empty_response, admin_stats_performance_percentiles_empty_response,
admin_stats_provider_quota_usage_empty_response, admin_stats_time_series_empty_response,
};
pub(crate) use self::timeseries::aggregate_usage_stats;
use self::timeseries::{
build_daily_time_series_buckets, build_time_series_payload, linear_regression,
pct_change_value, percentile_cont,
};
pub(crate) const MIN_PERCENTILE_SAMPLES: usize = 10;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AdminStatsComparisonType {
pub(crate) enum AdminStatsComparisonType {
Period,
Year,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AdminStatsGranularity {
pub(crate) enum AdminStatsGranularity {
Hour,
Day,
Week,
@@ -86,39 +48,50 @@ pub(crate) struct AdminStatsAggregate {
}
#[derive(Clone, Debug)]
struct AdminStatsForecastPoint {
date: chrono::NaiveDate,
total_cost: f64,
pub(crate) struct AdminStatsForecastPoint {
pub(crate) date: chrono::NaiveDate,
pub(crate) total_cost: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AdminStatsLeaderboardMetric {
pub(crate) enum AdminStatsLeaderboardMetric {
Requests,
Tokens,
Cost,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AdminStatsSortOrder {
pub(crate) enum AdminStatsSortOrder {
Asc,
Desc,
}
#[derive(Clone, Debug)]
struct AdminStatsLeaderboardItem {
id: String,
name: String,
requests: u64,
tokens: u64,
cost: f64,
pub(crate) struct AdminStatsLeaderboardItem {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) requests: u64,
pub(crate) tokens: u64,
pub(crate) cost: f64,
}
#[derive(Clone, Debug)]
struct AdminStatsUserMetadata {
name: String,
role: String,
is_active: bool,
is_deleted: bool,
pub(crate) struct AdminStatsUserMetadata {
pub(crate) name: String,
pub(crate) role: String,
pub(crate) is_active: bool,
pub(crate) is_deleted: bool,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct AdminStatsTimeSeriesBucket {
pub(crate) total_requests: u64,
pub(crate) input_tokens: u64,
pub(crate) output_tokens: u64,
pub(crate) cache_creation_tokens: u64,
pub(crate) cache_read_tokens: u64,
pub(crate) total_cost: f64,
pub(crate) total_response_time_ms: f64,
}
impl AdminStatsAggregate {
@@ -131,19 +104,8 @@ impl AdminStatsAggregate {
}
}
#[derive(Clone, Debug, Default)]
struct AdminStatsTimeSeriesBucket {
total_requests: u64,
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
total_cost: f64,
total_response_time_ms: f64,
}
impl AdminStatsGranularity {
fn parse(query: Option<&str>) -> Result<Self, String> {
pub(crate) fn parse(query: Option<&str>) -> Result<Self, String> {
match query_param_value(query, "granularity").as_deref() {
None | Some("day") => Ok(Self::Day),
Some("hour") => Ok(Self::Hour),
@@ -155,7 +117,7 @@ impl AdminStatsGranularity {
}
impl AdminStatsLeaderboardMetric {
fn parse(query: Option<&str>) -> Result<Self, String> {
pub(crate) fn parse(query: Option<&str>) -> Result<Self, String> {
match query_param_value(query, "metric").as_deref() {
None | Some("requests") => Ok(Self::Requests),
Some("tokens") => Ok(Self::Tokens),
@@ -164,7 +126,7 @@ impl AdminStatsLeaderboardMetric {
}
}
fn as_str(self) -> &'static str {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Requests => "requests",
Self::Tokens => "tokens",
@@ -174,7 +136,7 @@ impl AdminStatsLeaderboardMetric {
}
impl AdminStatsSortOrder {
fn parse(query: Option<&str>) -> Result<Self, String> {
pub(crate) fn parse(query: Option<&str>) -> Result<Self, String> {
match query_param_value(query, "order").as_deref() {
None | Some("desc") => Ok(Self::Desc),
Some("asc") => Ok(Self::Asc),
@@ -184,7 +146,7 @@ impl AdminStatsSortOrder {
}
impl AdminStatsUsageFilter {
fn from_query(query: Option<&str>) -> Self {
pub(crate) fn from_query(query: Option<&str>) -> Self {
Self {
user_id: query_param_value(query, "user_id"),
provider_name: query_param_value(query, "provider_name"),
@@ -194,7 +156,7 @@ impl AdminStatsUsageFilter {
}
impl AdminStatsTimeSeriesBucket {
fn add_usage(&mut self, item: &aether_data::repository::usage::StoredRequestUsageAudit) {
pub(crate) fn add_usage(&mut self, item: &StoredRequestUsageAudit) {
self.total_requests = self.total_requests.saturating_add(1);
self.input_tokens = self.input_tokens.saturating_add(item.input_tokens);
self.output_tokens = self.output_tokens.saturating_add(item.output_tokens);
@@ -208,7 +170,7 @@ impl AdminStatsTimeSeriesBucket {
self.total_response_time_ms += item.response_time_ms.unwrap_or(0) as f64;
}
fn merge(&mut self, other: &Self) {
pub(crate) fn merge(&mut self, other: &Self) {
self.total_requests = self.total_requests.saturating_add(other.total_requests);
self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
@@ -222,7 +184,7 @@ impl AdminStatsTimeSeriesBucket {
self.total_response_time_ms += other.total_response_time_ms;
}
fn avg_response_time_ms(&self) -> f64 {
pub(crate) fn avg_response_time_ms(&self) -> f64 {
if self.total_requests == 0 {
0.0
} else {
@@ -230,7 +192,7 @@ impl AdminStatsTimeSeriesBucket {
}
}
fn to_json_with_avg(&self, date: String) -> serde_json::Value {
pub(crate) fn to_json_with_avg(&self, date: String) -> serde_json::Value {
json!({
"date": date,
"total_requests": self.total_requests,
@@ -243,7 +205,7 @@ impl AdminStatsTimeSeriesBucket {
})
}
fn to_json_without_avg(&self, date: String) -> serde_json::Value {
pub(crate) fn to_json_without_avg(&self, date: String) -> serde_json::Value {
json!({
"date": date,
"total_requests": self.total_requests,
@@ -314,7 +276,7 @@ impl AdminStatsTimeRange {
}))
}
fn resolve_required(
pub(crate) fn resolve_required(
query: Option<&str>,
start_key: &str,
end_key: &str,
@@ -338,7 +300,7 @@ impl AdminStatsTimeRange {
})
}
fn to_unix_bounds(&self) -> Option<(u64, u64)> {
pub(crate) fn to_unix_bounds(&self) -> Option<(u64, u64)> {
let offset = chrono::Duration::minutes(i64::from(self.tz_offset_minutes));
let start_local = self.start_date.and_hms_opt(0, 0, 0)?;
let end_local = self
@@ -356,7 +318,9 @@ impl AdminStatsTimeRange {
Some((start_utc as u64, end_utc as u64))
}
fn to_utc_datetime_bounds(&self) -> Option<(chrono::DateTime<Utc>, chrono::DateTime<Utc>)> {
pub(crate) fn to_utc_datetime_bounds(
&self,
) -> Option<(chrono::DateTime<Utc>, chrono::DateTime<Utc>)> {
let offset = chrono::Duration::minutes(i64::from(self.tz_offset_minutes));
let start_local = self.start_date.and_hms_opt(0, 0, 0)?;
let end_local = self
@@ -369,7 +333,10 @@ impl AdminStatsTimeRange {
))
}
fn validate_for_time_series(&self, granularity: AdminStatsGranularity) -> Result<(), String> {
pub(crate) fn validate_for_time_series(
&self,
granularity: AdminStatsGranularity,
) -> Result<(), String> {
if granularity == AdminStatsGranularity::Hour && self.start_date != self.end_date {
return Err("Hour granularity only supports single day query".to_string());
}
@@ -382,7 +349,7 @@ impl AdminStatsTimeRange {
Ok(())
}
fn local_dates(&self) -> Vec<chrono::NaiveDate> {
pub(crate) fn local_dates(&self) -> Vec<chrono::NaiveDate> {
let mut current = self.start_date;
let mut dates = Vec::new();
while current <= self.end_date {
@@ -395,75 +362,25 @@ impl AdminStatsTimeRange {
dates
}
fn local_date_strings(&self) -> Vec<String> {
pub(crate) fn local_date_strings(&self) -> Vec<String> {
self.local_dates()
.into_iter()
.map(|date| date.to_string())
.collect()
}
fn local_date_for_unix_secs(&self, unix_secs: u64) -> Option<chrono::NaiveDate> {
pub(crate) fn local_date_for_unix_secs(&self, unix_secs: u64) -> Option<chrono::NaiveDate> {
let timestamp = chrono::DateTime::<Utc>::from_timestamp(i64::try_from(unix_secs).ok()?, 0)?;
let local = timestamp
.checked_add_signed(chrono::Duration::minutes(i64::from(self.tz_offset_minutes)))?;
Some(local.date_naive())
}
fn local_date_string_for_unix_secs(&self, unix_secs: u64) -> Option<String> {
pub(crate) fn local_date_string_for_unix_secs(&self, unix_secs: u64) -> Option<String> {
Some(self.local_date_for_unix_secs(unix_secs)?.to_string())
}
}
pub(crate) async fn maybe_build_local_admin_stats_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("stats_manage") {
return Ok(None);
}
if let Some(response) =
provider_quota_routes::maybe_build_local_admin_stats_provider_quota_response(
state,
request_context,
decision,
)
.await?
{
return Ok(Some(response));
}
if let Some(response) = analytics_routes::maybe_build_local_admin_stats_analytics_response(
state,
request_context,
decision,
)
.await?
{
return Ok(Some(response));
}
if let Some(response) =
cost_routes::maybe_build_local_admin_stats_cost_response(state, request_context).await?
{
return Ok(Some(response));
}
if let Some(response) = leaderboard_routes::maybe_build_local_admin_stats_leaderboard_response(
state,
request_context,
)
.await?
{
return Ok(Some(response));
}
Ok(None)
}
pub(crate) fn round_to(value: f64, decimals: u32) -> f64 {
let factor = 10_f64.powi(i32::try_from(decimals).unwrap_or(0));
(value * factor).round() / factor

View File

@@ -1,11 +1,12 @@
use super::{
use super::helpers::{
AdminStatsLeaderboardItem, AdminStatsLeaderboardMetric, AdminStatsSortOrder,
AdminStatsUserMetadata,
};
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
pub(super) fn build_model_leaderboard_items(
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
) -> Vec<AdminStatsLeaderboardItem> {
let mut grouped: std::collections::BTreeMap<String, AdminStatsLeaderboardItem> =
std::collections::BTreeMap::new();
@@ -38,7 +39,7 @@ pub(super) fn build_model_leaderboard_items(
}
pub(super) fn build_api_key_leaderboard_items(
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
snapshots: Option<&[aether_data::repository::auth::StoredAuthApiKeySnapshot]>,
include_inactive: bool,
exclude_admin: bool,
@@ -110,7 +111,7 @@ pub(super) fn build_api_key_leaderboard_items(
}
pub(super) fn build_user_leaderboard_items(
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
users: &std::collections::BTreeMap<String, AdminStatsUserMetadata>,
include_inactive: bool,
exclude_admin: bool,

View File

@@ -1,12 +1,15 @@
use super::{
admin_stats_bad_request_response, admin_stats_leaderboard_empty_response,
build_api_key_leaderboard_items, build_model_leaderboard_items, build_user_leaderboard_items,
compare_leaderboard_items, compute_dense_rank, list_usage_for_optional_range,
load_user_leaderboard_metadata, parse_bounded_u32, parse_nonnegative_usize, round_to,
AdminStatsLeaderboardMetric, AdminStatsSortOrder, AdminStatsTimeRange, AdminStatsUsageFilter,
use super::helpers::{
round_to, AdminStatsLeaderboardMetric, AdminStatsSortOrder, AdminStatsTimeRange,
AdminStatsUsageFilter,
};
use super::leaderboard::{
build_api_key_leaderboard_items, build_model_leaderboard_items, build_user_leaderboard_items,
compare_leaderboard_items, compute_dense_rank, load_user_leaderboard_metadata,
};
use super::range::{list_usage_for_optional_range, parse_bounded_u32, parse_nonnegative_usize};
use super::responses::{admin_stats_bad_request_response, admin_stats_leaderboard_empty_response};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{query_param_bool, query_param_value};
use crate::handlers::admin::shared::{query_param_bool, query_param_value};
use crate::{AppState, GatewayError};
use axum::{
body::Body,
@@ -167,8 +170,10 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
.collect();
Some(
state
.read_auth_api_key_snapshots_by_ids(&api_key_ids)
.await?,
.data
.list_auth_api_key_snapshots_by_ids(&api_key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?,
)
} else {
None

View File

@@ -0,0 +1,67 @@
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{body::Body, response::Response};
mod analytics_routes;
mod cost_routes;
mod helpers;
mod leaderboard;
mod leaderboard_routes;
mod provider_quota_routes;
mod range;
mod responses;
mod timeseries;
pub(crate) use self::helpers::{round_to, AdminStatsTimeRange, AdminStatsUsageFilter};
pub(crate) use self::range::{list_usage_for_optional_range, parse_bounded_u32};
pub(crate) use self::responses::admin_stats_bad_request_response;
pub(crate) use self::timeseries::aggregate_usage_stats;
pub(crate) async fn maybe_build_local_admin_stats_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("stats_manage") {
return Ok(None);
}
if let Some(response) =
provider_quota_routes::maybe_build_local_admin_stats_provider_quota_response(
state,
request_context,
decision,
)
.await?
{
return Ok(Some(response));
}
if let Some(response) = analytics_routes::maybe_build_local_admin_stats_analytics_response(
state,
request_context,
decision,
)
.await?
{
return Ok(Some(response));
}
if let Some(response) =
cost_routes::maybe_build_local_admin_stats_cost_response(state, request_context).await?
{
return Ok(Some(response));
}
if let Some(response) = leaderboard_routes::maybe_build_local_admin_stats_leaderboard_response(
state,
request_context,
)
.await?
{
return Ok(Some(response));
}
Ok(None)
}

View File

@@ -1,7 +1,7 @@
use super::admin_stats_provider_quota_usage_empty_response;
use super::responses::admin_stats_provider_quota_usage_empty_response;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::unix_secs_to_rfc3339;
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::{AppState, GatewayError};
use axum::{
body::Body,

View File

@@ -1,6 +1,7 @@
use super::{AdminStatsComparisonType, AdminStatsTimeRange, AdminStatsUsageFilter};
use crate::handlers::query_param_value;
use super::helpers::{AdminStatsComparisonType, AdminStatsTimeRange, AdminStatsUsageFilter};
use crate::handlers::admin::shared::query_param_value;
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery};
use chrono::{Datelike, Utc};
pub(super) fn parse_tz_offset_minutes(query: Option<&str>) -> Result<i32, String> {
@@ -183,14 +184,14 @@ pub(super) async fn list_usage_for_range(
state: &AppState,
time_range: &AdminStatsTimeRange,
filters: &AdminStatsUsageFilter,
) -> Result<Vec<aether_data::repository::usage::StoredRequestUsageAudit>, GatewayError> {
) -> Result<Vec<StoredRequestUsageAudit>, GatewayError> {
let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds()
else {
return Ok(Vec::new());
};
state
.list_usage_audits(&aether_data::repository::usage::UsageAuditListQuery {
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: Some(created_from_unix_secs),
created_until_unix_secs: Some(created_until_unix_secs),
user_id: filters.user_id.clone(),
@@ -204,12 +205,12 @@ pub(crate) async fn list_usage_for_optional_range(
state: &AppState,
time_range: Option<&AdminStatsTimeRange>,
filters: &AdminStatsUsageFilter,
) -> Result<Vec<aether_data::repository::usage::StoredRequestUsageAudit>, GatewayError> {
) -> Result<Vec<StoredRequestUsageAudit>, GatewayError> {
match time_range {
Some(time_range) => list_usage_for_range(state, time_range, filters).await,
None => {
state
.list_usage_audits(&aether_data::repository::usage::UsageAuditListQuery {
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: None,
created_until_unix_secs: None,
user_id: filters.user_id.clone(),

View File

@@ -1,4 +1,4 @@
use super::{AdminStatsLeaderboardMetric, AdminStatsTimeRange};
use super::helpers::{AdminStatsLeaderboardMetric, AdminStatsTimeRange};
use axum::{
body::Body,
http,

View File

@@ -1,14 +1,15 @@
use super::{
use super::helpers::{
round_to, AdminStatsAggregate, AdminStatsGranularity, AdminStatsTimeRange,
AdminStatsTimeSeriesBucket, MIN_PERCENTILE_SAMPLES,
};
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
use chrono::{Datelike, Utc};
use serde_json::json;
pub(super) fn build_time_series_payload(
time_range: &AdminStatsTimeRange,
granularity: AdminStatsGranularity,
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
) -> Vec<serde_json::Value> {
match granularity {
AdminStatsGranularity::Hour => build_hourly_time_series_payload(time_range, items),
@@ -20,7 +21,7 @@ pub(super) fn build_time_series_payload(
pub(super) fn build_daily_time_series_buckets(
time_range: &AdminStatsTimeRange,
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
) -> std::collections::BTreeMap<chrono::NaiveDate, AdminStatsTimeSeriesBucket> {
let mut buckets: std::collections::BTreeMap<chrono::NaiveDate, AdminStatsTimeSeriesBucket> =
time_range
@@ -44,7 +45,7 @@ pub(super) fn build_daily_time_series_buckets(
fn build_daily_time_series_payload(
time_range: &AdminStatsTimeRange,
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
) -> Vec<serde_json::Value> {
build_daily_time_series_buckets(time_range, items)
.into_iter()
@@ -54,7 +55,7 @@ fn build_daily_time_series_payload(
fn build_weekly_time_series_payload(
time_range: &AdminStatsTimeRange,
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
) -> Vec<serde_json::Value> {
let mut weekly: std::collections::BTreeMap<
(i32, u32),
@@ -78,7 +79,7 @@ fn build_weekly_time_series_payload(
fn build_monthly_time_series_payload(
time_range: &AdminStatsTimeRange,
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
) -> Vec<serde_json::Value> {
let mut monthly: std::collections::BTreeMap<
(i32, u32),
@@ -104,7 +105,7 @@ fn build_monthly_time_series_payload(
fn build_hourly_time_series_payload(
time_range: &AdminStatsTimeRange,
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
) -> Vec<serde_json::Value> {
let Some((mut current, end)) = time_range.to_utc_datetime_bounds() else {
return Vec::new();
@@ -147,9 +148,7 @@ fn build_hourly_time_series_payload(
.collect()
}
pub(crate) fn aggregate_usage_stats(
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
) -> AdminStatsAggregate {
pub(crate) fn aggregate_usage_stats(items: &[StoredRequestUsageAudit]) -> AdminStatsAggregate {
let mut aggregate = AdminStatsAggregate::default();
for item in items {
aggregate.total_requests = aggregate.total_requests.saturating_add(1);

View File

@@ -1,12 +1,11 @@
use super::super::{parse_bounded_u32, round_to};
use crate::handlers::{query_param_value, unix_secs_to_rfc3339};
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: &aether_data::repository::usage::StoredRequestUsageAudit,
) -> u64 {
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)
@@ -103,7 +102,7 @@ pub(super) fn admin_usage_token_cache_hit_rate(input_tokens: u64, cache_read_tok
}
pub(super) fn admin_usage_aggregation_by_model_json(
usage: &[aether_data::repository::usage::StoredRequestUsageAudit],
usage: &[StoredRequestUsageAudit],
limit: usize,
) -> serde_json::Value {
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64)> = BTreeMap::new();
@@ -155,7 +154,7 @@ pub(super) fn admin_usage_aggregation_by_model_json(
pub(super) async fn admin_usage_aggregation_by_user_json(
state: &AppState,
usage: &[aether_data::repository::usage::StoredRequestUsageAudit],
usage: &[StoredRequestUsageAudit],
limit: usize,
) -> Result<serde_json::Value, GatewayError> {
let mut grouped: BTreeMap<String, (u64, u64, f64)> = BTreeMap::new();
@@ -214,7 +213,7 @@ pub(super) async fn admin_usage_aggregation_by_user_json(
}
pub(super) fn admin_usage_aggregation_by_provider_json(
usage: &[aether_data::repository::usage::StoredRequestUsageAudit],
usage: &[StoredRequestUsageAudit],
limit: usize,
) -> serde_json::Value {
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64, u64)> = BTreeMap::new();
@@ -289,7 +288,7 @@ pub(super) fn admin_usage_aggregation_by_provider_json(
}
pub(super) fn admin_usage_aggregation_by_api_format_json(
usage: &[aether_data::repository::usage::StoredRequestUsageAudit],
usage: &[StoredRequestUsageAudit],
limit: usize,
) -> serde_json::Value {
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64)> = BTreeMap::new();
@@ -351,9 +350,7 @@ pub(super) fn admin_usage_aggregation_by_api_format_json(
json!(items)
}
pub(super) fn admin_usage_heatmap_json(
usage: &[aether_data::repository::usage::StoredRequestUsageAudit],
) -> serde_json::Value {
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))
@@ -407,9 +404,7 @@ pub(super) fn admin_usage_heatmap_json(
})
}
pub(super) fn admin_usage_is_success(
item: &aether_data::repository::usage::StoredRequestUsageAudit,
) -> bool {
pub(super) fn admin_usage_is_success(item: &StoredRequestUsageAudit) -> bool {
matches!(
item.status.as_str(),
"completed" | "success" | "ok" | "billed" | "settled"
@@ -427,11 +422,11 @@ pub(super) async fn list_recent_completed_usage_for_cache_affinity(
state: &AppState,
hours: u32,
user_id: Option<&str>,
) -> Result<Vec<aether_data::repository::usage::StoredRequestUsageAudit>, GatewayError> {
) -> 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(&aether_data::repository::usage::UsageAuditListQuery {
.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),
@@ -449,8 +444,8 @@ pub(super) async fn list_recent_completed_usage_for_cache_affinity(
}
pub(super) fn admin_usage_group_completed_by_user(
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
) -> BTreeMap<String, Vec<aether_data::repository::usage::StoredRequestUsageAudit>> {
items: &[StoredRequestUsageAudit],
) -> BTreeMap<String, Vec<StoredRequestUsageAudit>> {
let mut grouped = BTreeMap::new();
for item in items.iter().filter(|item| item.user_id.is_some()) {
grouped
@@ -462,9 +457,9 @@ pub(super) fn admin_usage_group_completed_by_user(
}
pub(super) fn admin_usage_group_completed_by_api_key(
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
api_key_id: Option<&str>,
) -> BTreeMap<String, Vec<aether_data::repository::usage::StoredRequestUsageAudit>> {
) -> 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) {
@@ -479,7 +474,7 @@ pub(super) fn admin_usage_group_completed_by_api_key(
}
pub(super) fn admin_usage_collect_request_intervals_minutes(
items: &[aether_data::repository::usage::StoredRequestUsageAudit],
items: &[StoredRequestUsageAudit],
) -> Vec<f64> {
let mut previous_created_at_unix_secs = None;
let mut intervals = Vec::new();
@@ -588,7 +583,7 @@ pub(super) fn admin_usage_point_sort_key(
}
pub(super) fn admin_usage_matches_search(
item: &aether_data::repository::usage::StoredRequestUsageAudit,
item: &StoredRequestUsageAudit,
search: Option<&str>,
) -> bool {
let Some(search) = search.map(str::trim).filter(|value| !value.is_empty()) else {
@@ -610,7 +605,7 @@ pub(super) fn admin_usage_matches_search(
}
pub(super) fn admin_usage_matches_username(
item: &aether_data::repository::usage::StoredRequestUsageAudit,
item: &StoredRequestUsageAudit,
username: Option<&str>,
) -> bool {
let Some(username) = username.map(str::trim).filter(|value| !value.is_empty()) else {
@@ -634,7 +629,7 @@ pub(super) fn admin_usage_matches_eq(value: &str, query: Option<&str>) -> bool {
}
pub(super) fn admin_usage_matches_api_format(
item: &aether_data::repository::usage::StoredRequestUsageAudit,
item: &StoredRequestUsageAudit,
api_format: Option<&str>,
) -> bool {
let Some(api_format) = api_format.map(str::trim).filter(|value| !value.is_empty()) else {
@@ -646,7 +641,7 @@ pub(super) fn admin_usage_matches_api_format(
}
pub(super) fn admin_usage_matches_status(
item: &aether_data::repository::usage::StoredRequestUsageAudit,
item: &StoredRequestUsageAudit,
status: Option<&str>,
) -> bool {
let Some(status) = status.map(str::trim).filter(|value| !value.is_empty()) else {
@@ -671,7 +666,7 @@ pub(super) fn admin_usage_matches_status(
pub(super) async fn admin_usage_provider_key_names(
state: &AppState,
usage: &[aether_data::repository::usage::StoredRequestUsageAudit],
usage: &[StoredRequestUsageAudit],
) -> Result<BTreeMap<String, String>, GatewayError> {
if !state.has_provider_catalog_data_reader() {
return Ok(BTreeMap::new());
@@ -696,7 +691,7 @@ pub(super) async fn admin_usage_provider_key_names(
}
fn admin_usage_request_metadata_string(
item: &aether_data::repository::usage::StoredRequestUsageAudit,
item: &StoredRequestUsageAudit,
key: &str,
) -> Option<String> {
item.request_metadata
@@ -710,7 +705,7 @@ fn admin_usage_request_metadata_string(
}
pub(super) fn admin_usage_provider_key_name(
item: &aether_data::repository::usage::StoredRequestUsageAudit,
item: &StoredRequestUsageAudit,
provider_key_names: &BTreeMap<String, String>,
) -> Option<String> {
item.provider_api_key_id
@@ -721,7 +716,7 @@ pub(super) fn admin_usage_provider_key_name(
}
pub(super) fn admin_usage_record_json(
item: &aether_data::repository::usage::StoredRequestUsageAudit,
item: &StoredRequestUsageAudit,
users_by_id: &BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
provider_key_name: Option<&str>,
) -> Value {

View File

@@ -1,21 +1,24 @@
use super::super::{
use super::super::stats::{
list_usage_for_optional_range, round_to, AdminStatsTimeRange, AdminStatsUsageFilter,
};
use super::{
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_bad_request_response, admin_usage_calculate_recommended_ttl,
admin_usage_collect_request_intervals_minutes, admin_usage_data_unavailable_response,
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::{query_param_bool, query_param_value, unix_secs_to_rfc3339};
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,
@@ -102,7 +105,7 @@ pub(super) async fn maybe_build_local_admin_usage_analytics_response(
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(&aether_data::repository::usage::UsageAuditListQuery {
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: Some(created_from_unix_secs),
..Default::default()
})

Some files were not shown because too many files have changed in this diff Show More