mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(gateway): 重构 usage 数据层、迁移系统与系统导入
数据库迁移: - 引入 baseline v2 bootstrap,空库首次启动自动初始化 - 服务启动不再自动执行迁移,需显式 `--migrate` 运行 - 新增 pending migration 检测,schema 落后时拒绝启动 Usage 数据层: - usage body 存储外部化为独立 blob 表 - 新增 HTTP audit 表拆分存储请求/响应头与 body ref - 后台清理任务支持 legacy body ref 元数据迁移 - usage runtime 写入迁移到专用 tokio runtime(独立线程池, 8MB 栈) 系统导入/导出: - 支持用户、API Keys、钱包数据的完整导入 - 兼容 legacy 与 v1.3+ 两种导出格式 其他改进: - executor outcome 增加 runtime miss 诊断上下文 - 主 tokio runtime 栈大小调整为 8MB - 前端 provider 管理支持 base URL 配置 - dev.sh 支持 --migrate 参数
This commit is contained in:
@@ -106,6 +106,12 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
allowed_models,
|
||||
rate_limit,
|
||||
concurrent_limit: 5,
|
||||
force_capabilities: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry: false,
|
||||
total_requests: 0,
|
||||
total_cost_usd: 0.0,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
|
||||
@@ -32,10 +32,12 @@ pub(super) async fn load_user_leaderboard_metadata(
|
||||
},
|
||||
);
|
||||
}
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
for user_id in user_ids {
|
||||
if metadata.contains_key(user_id) {
|
||||
continue;
|
||||
}
|
||||
let Some(user) = state.find_user_auth_by_id(user_id).await? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -130,24 +130,32 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
||||
let usage = state
|
||||
.list_admin_usage_for_optional_range(time_range.as_ref(), &filters)
|
||||
.await?;
|
||||
let api_key_ids: Vec<String> = usage
|
||||
.iter()
|
||||
.filter_map(|item| item.api_key_id.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let snapshots = if state.has_auth_api_key_data_reader() {
|
||||
let api_key_ids: Vec<String> = usage
|
||||
.iter()
|
||||
.filter_map(|item| item.api_key_id.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
Some(
|
||||
state
|
||||
.list_auth_api_key_snapshots_by_ids(&api_key_ids)
|
||||
.resolve_auth_api_key_snapshots_by_ids(&api_key_ids)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let api_key_names = if state.has_auth_api_key_data_reader() {
|
||||
state
|
||||
.resolve_auth_api_key_names_by_ids(&api_key_ids)
|
||||
.await?
|
||||
} else {
|
||||
std::collections::BTreeMap::new()
|
||||
};
|
||||
let mut leaderboard = build_api_key_leaderboard_items(
|
||||
&usage,
|
||||
snapshots.as_deref(),
|
||||
&api_key_names,
|
||||
include_inactive,
|
||||
exclude_admin,
|
||||
);
|
||||
@@ -220,8 +228,13 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
||||
.into_iter()
|
||||
.collect();
|
||||
let user_metadata = load_user_leaderboard_metadata(state, &user_ids).await?;
|
||||
let mut leaderboard =
|
||||
build_user_leaderboard_items(&usage, &user_metadata, include_inactive, exclude_admin);
|
||||
let mut leaderboard = build_user_leaderboard_items(
|
||||
&usage,
|
||||
&user_metadata,
|
||||
state.has_auth_user_data_reader(),
|
||||
include_inactive,
|
||||
exclude_admin,
|
||||
);
|
||||
leaderboard.sort_by(|left, right| compare_leaderboard_items(metric, order, left, right));
|
||||
|
||||
return Ok(Some(build_admin_stats_leaderboard_response(
|
||||
|
||||
@@ -28,3 +28,24 @@ pub(in super::super) async fn admin_usage_provider_key_names(
|
||||
.map(|key| (key.id, key.name))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(in super::super) async fn admin_usage_api_key_names(
|
||||
state: &AdminAppState<'_>,
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
) -> Result<BTreeMap<String, String>, GatewayError> {
|
||||
if !state.has_auth_api_key_data_reader() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
|
||||
let api_key_ids = usage
|
||||
.iter()
|
||||
.filter_map(|item| item.api_key_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
if api_key_ids.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
|
||||
state.resolve_auth_api_key_names_by_ids(&api_key_ids).await
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ mod filters;
|
||||
|
||||
pub(super) use aggregations::admin_usage_aggregation_by_user_json;
|
||||
pub(super) use cache_affinity::list_recent_completed_usage_for_cache_affinity;
|
||||
pub(super) use filters::admin_usage_provider_key_names;
|
||||
pub(super) use filters::{admin_usage_api_key_names, admin_usage_provider_key_names};
|
||||
|
||||
@@ -16,6 +16,20 @@ use axum::{
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
async fn load_usage_cache_affinity_usernames(
|
||||
state: &AdminAppState<'_>,
|
||||
user_ids: &[String],
|
||||
) -> Result<BTreeMap<String, String>, GatewayError> {
|
||||
Ok(state
|
||||
.resolve_auth_user_summaries_by_ids(user_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|(user_id, user)| {
|
||||
(!user.username.trim().is_empty()).then_some((user_id, user.username))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -41,6 +55,7 @@ pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
|
||||
list_recent_completed_usage_for_cache_affinity(state, hours, user_id.as_deref()).await?;
|
||||
let mut grouped: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
|
||||
let mut models = BTreeSet::new();
|
||||
let mut legacy_usernames_by_user_id = BTreeMap::new();
|
||||
let mut usernames_by_user_id = BTreeMap::new();
|
||||
|
||||
for (group_user_id, items) in admin_usage_group_completed_by_user(&usage) {
|
||||
@@ -67,7 +82,7 @@ pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
|
||||
if include_user_info && user_id.is_none() {
|
||||
point["user_id"] = json!(group_user_id.clone());
|
||||
if let Some(username) = item.username.clone() {
|
||||
usernames_by_user_id
|
||||
legacy_usernames_by_user_id
|
||||
.entry(group_user_id.clone())
|
||||
.or_insert(username);
|
||||
}
|
||||
@@ -82,17 +97,17 @@ pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
|
||||
}
|
||||
}
|
||||
|
||||
if include_user_info && user_id.is_none() && state.has_user_data_reader() {
|
||||
if include_user_info && user_id.is_none() {
|
||||
let user_ids: Vec<_> = grouped.keys().cloned().collect();
|
||||
let user_map: BTreeMap<_, _> = state
|
||||
.list_users_by_ids(&user_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.id, user.username))
|
||||
.collect();
|
||||
let user_map = load_usage_cache_affinity_usernames(state, &user_ids).await?;
|
||||
for (user_id, username) in user_map {
|
||||
usernames_by_user_id.insert(user_id, username);
|
||||
}
|
||||
if !state.has_auth_user_data_reader() {
|
||||
for (user_id, username) in legacy_usernames_by_user_id {
|
||||
usernames_by_user_id.entry(user_id).or_insert(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_points_before_limit: usize = grouped.values().map(Vec::len).sum();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::analytics::admin_usage_provider_key_names;
|
||||
use super::analytics::{admin_usage_api_key_names, admin_usage_provider_key_names};
|
||||
use super::replay::{
|
||||
admin_usage_curl_headers, admin_usage_curl_url, admin_usage_headers_from_value,
|
||||
admin_usage_id_from_action_path, admin_usage_id_from_detail_path,
|
||||
admin_usage_resolve_request_preview_body, build_admin_usage_curl_response,
|
||||
admin_usage_resolve_body_value, admin_usage_resolve_request_preview_body,
|
||||
admin_usage_resolve_request_preview_body_for_item, build_admin_usage_curl_response,
|
||||
build_admin_usage_detail_payload, build_admin_usage_replay_response,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
@@ -12,6 +13,7 @@ use aether_admin::observability::usage::{
|
||||
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
|
||||
admin_usage_provider_key_name, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use aether_data_contracts::repository::usage::UsageBodyField;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
@@ -82,10 +84,22 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
||||
.and_then(admin_usage_headers_from_value)
|
||||
.filter(|headers| !headers.is_empty())
|
||||
.unwrap_or_else(admin_usage_curl_headers);
|
||||
let body = item
|
||||
.provider_request_body
|
||||
.clone()
|
||||
.or_else(|| item.request_body.clone())
|
||||
let provider_request_body = admin_usage_resolve_body_value(
|
||||
state,
|
||||
&item,
|
||||
item.provider_request_body.as_ref(),
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
)
|
||||
.await?;
|
||||
let request_body = admin_usage_resolve_body_value(
|
||||
state,
|
||||
&item,
|
||||
item.request_body.as_ref(),
|
||||
UsageBodyField::RequestBody,
|
||||
)
|
||||
.await?;
|
||||
let body = provider_request_body
|
||||
.or(request_body)
|
||||
.unwrap_or_else(|| admin_usage_resolve_request_preview_body(&item, None));
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
build_admin_usage_curl_response(&item, url, headers_json, &headers, &body),
|
||||
@@ -146,32 +160,50 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
||||
};
|
||||
|
||||
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
|
||||
if state.has_user_data_reader() {
|
||||
if let Some(user_id) = item.user_id.as_ref() {
|
||||
state
|
||||
.list_users_by_ids(std::slice::from_ref(user_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.id.clone(), user))
|
||||
.collect()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
}
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
state
|
||||
.resolve_auth_user_summaries_by_ids(
|
||||
&item.user_id.clone().into_iter().collect::<Vec<_>>(),
|
||||
)
|
||||
.await?;
|
||||
let provider_key_names =
|
||||
admin_usage_provider_key_names(state, std::slice::from_ref(&item)).await?;
|
||||
let api_key_names =
|
||||
admin_usage_api_key_names(state, std::slice::from_ref(&item)).await?;
|
||||
let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names);
|
||||
|
||||
let request_body = item
|
||||
.request_body
|
||||
.clone()
|
||||
.unwrap_or_else(|| admin_usage_resolve_request_preview_body(&item, None));
|
||||
let request_body =
|
||||
admin_usage_resolve_request_preview_body_for_item(state, &item, None).await?;
|
||||
let mut detail_item = item.clone();
|
||||
if include_bodies {
|
||||
detail_item.provider_request_body = admin_usage_resolve_body_value(
|
||||
state,
|
||||
&item,
|
||||
item.provider_request_body.as_ref(),
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
)
|
||||
.await?;
|
||||
detail_item.response_body = admin_usage_resolve_body_value(
|
||||
state,
|
||||
&item,
|
||||
item.response_body.as_ref(),
|
||||
UsageBodyField::ResponseBody,
|
||||
)
|
||||
.await?;
|
||||
detail_item.client_response_body = admin_usage_resolve_body_value(
|
||||
state,
|
||||
&item,
|
||||
item.client_response_body.as_ref(),
|
||||
UsageBodyField::ClientResponseBody,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let default_headers = admin_usage_curl_headers();
|
||||
let payload = build_admin_usage_detail_payload(
|
||||
&item,
|
||||
&detail_item,
|
||||
&users_by_id,
|
||||
&api_key_names,
|
||||
state.has_auth_user_data_reader(),
|
||||
state.has_auth_api_key_data_reader(),
|
||||
provider_key_name.as_deref(),
|
||||
include_bodies,
|
||||
request_body,
|
||||
|
||||
@@ -5,7 +5,8 @@ use aether_admin::observability::usage::{
|
||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use aether_data_contracts::repository::{
|
||||
provider_catalog::StoredProviderCatalogEndpoint, usage::StoredRequestUsageAudit,
|
||||
provider_catalog::StoredProviderCatalogEndpoint,
|
||||
usage::{StoredRequestUsageAudit, UsageBodyField},
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
@@ -46,6 +47,50 @@ pub(super) fn admin_usage_resolve_request_preview_body(
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn admin_usage_resolve_body_value(
|
||||
state: &AdminAppState<'_>,
|
||||
item: &StoredRequestUsageAudit,
|
||||
inline_body: Option<&Value>,
|
||||
field: UsageBodyField,
|
||||
) -> Result<Option<Value>, GatewayError> {
|
||||
let resolved_ref_body = match item.body_ref(field) {
|
||||
Some(body_ref) => state.resolve_request_usage_body_ref(body_ref).await?,
|
||||
None => None,
|
||||
};
|
||||
Ok(admin_usage_body_value_from_sources(
|
||||
resolved_ref_body,
|
||||
inline_body,
|
||||
))
|
||||
}
|
||||
|
||||
fn admin_usage_body_value_from_sources(
|
||||
resolved_ref_body: Option<Value>,
|
||||
inline_body: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
resolved_ref_body.or_else(|| inline_body.cloned())
|
||||
}
|
||||
|
||||
pub(super) async fn admin_usage_resolve_request_preview_body_for_item(
|
||||
state: &AdminAppState<'_>,
|
||||
item: &StoredRequestUsageAudit,
|
||||
body_override: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, GatewayError> {
|
||||
if let Some(body_override) = body_override {
|
||||
return Ok(body_override);
|
||||
}
|
||||
if let Some(body) = admin_usage_resolve_body_value(
|
||||
state,
|
||||
item,
|
||||
item.request_body.as_ref(),
|
||||
UsageBodyField::RequestBody,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(body);
|
||||
}
|
||||
Ok(admin_usage_resolve_request_preview_body(item, None))
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_usage_curl_response(
|
||||
item: &StoredRequestUsageAudit,
|
||||
url: Option<String>,
|
||||
@@ -62,9 +107,46 @@ pub(super) fn build_admin_usage_curl_response(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::admin_usage_body_value_from_sources;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn resolved_reference_body_wins_over_inline_fallback() {
|
||||
let inline_body = json!({
|
||||
"truncated": true,
|
||||
"reason": "usage_capture_limits_exceeded"
|
||||
});
|
||||
let ref_body = json!({
|
||||
"messages": [{"role": "user", "content": "real request body"}]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
admin_usage_body_value_from_sources(Some(ref_body.clone()), Some(&inline_body)),
|
||||
Some(ref_body)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_body_is_used_when_reference_body_is_unavailable() {
|
||||
let inline_body = json!({
|
||||
"messages": [{"role": "user", "content": "fallback inline body"}]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
admin_usage_body_value_from_sources(None, Some(&inline_body)),
|
||||
Some(inline_body)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_usage_detail_payload(
|
||||
item: &StoredRequestUsageAudit,
|
||||
users_by_id: &BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
|
||||
api_key_names: &BTreeMap<String, String>,
|
||||
auth_user_reader_available: bool,
|
||||
auth_api_key_reader_available: bool,
|
||||
provider_key_name: Option<&str>,
|
||||
include_bodies: bool,
|
||||
request_body: Value,
|
||||
@@ -73,6 +155,9 @@ pub(super) fn build_admin_usage_detail_payload(
|
||||
aether_admin::observability::usage::build_admin_usage_detail_payload(
|
||||
item,
|
||||
users_by_id,
|
||||
api_key_names,
|
||||
auth_user_reader_available,
|
||||
auth_api_key_reader_available,
|
||||
provider_key_name,
|
||||
include_bodies,
|
||||
request_body,
|
||||
@@ -254,7 +339,9 @@ pub(super) async fn build_admin_usage_replay_response(
|
||||
|
||||
let same_provider = item.provider_id.as_deref() == Some(target_provider.id.as_str());
|
||||
let same_endpoint = item.provider_endpoint_id.as_deref() == Some(target_endpoint.id.as_str());
|
||||
let request_body = admin_usage_resolve_request_preview_body(&item, payload.body_override);
|
||||
let request_body =
|
||||
admin_usage_resolve_request_preview_body_for_item(state, &item, payload.body_override)
|
||||
.await?;
|
||||
|
||||
let url = admin_usage_curl_url(state, &target_endpoint, &item);
|
||||
let headers = admin_usage_curl_headers();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::super::stats::{AdminStatsTimeRange, AdminStatsUsageFilter};
|
||||
use super::analytics::admin_usage_provider_key_names;
|
||||
use super::analytics::{admin_usage_api_key_names, admin_usage_provider_key_names};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::GatewayError;
|
||||
@@ -85,10 +85,13 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
if requested_ids.is_none() && items.len() > 50 {
|
||||
items.truncate(50);
|
||||
}
|
||||
let api_key_names = admin_usage_api_key_names(state, &items).await?;
|
||||
let provider_key_names = admin_usage_provider_key_names(state, &items).await?;
|
||||
|
||||
return Ok(Some(build_admin_usage_active_requests_response(
|
||||
&items,
|
||||
&api_key_names,
|
||||
state.has_auth_api_key_data_reader(),
|
||||
&provider_key_names,
|
||||
)));
|
||||
}
|
||||
@@ -134,10 +137,30 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
|
||||
};
|
||||
|
||||
let user_ids: Vec<String> = usage
|
||||
.iter()
|
||||
.filter_map(|item| item.user_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
|
||||
state.resolve_auth_user_summaries_by_ids(&user_ids).await?;
|
||||
let api_key_names = admin_usage_api_key_names(state, &usage).await?;
|
||||
|
||||
usage.retain(|item| {
|
||||
admin_usage_matches_search(item, search.as_deref())
|
||||
&& admin_usage_matches_username(item, username_filter.as_deref())
|
||||
&& admin_usage_matches_eq(item.model.as_str(), model_filter.as_deref())
|
||||
admin_usage_matches_search(
|
||||
item,
|
||||
search.as_deref(),
|
||||
&users_by_id,
|
||||
&api_key_names,
|
||||
state.has_auth_user_data_reader(),
|
||||
state.has_auth_api_key_data_reader(),
|
||||
) && admin_usage_matches_username(
|
||||
item,
|
||||
username_filter.as_deref(),
|
||||
&users_by_id,
|
||||
state.has_auth_user_data_reader(),
|
||||
) && admin_usage_matches_eq(item.model.as_str(), model_filter.as_deref())
|
||||
&& admin_usage_matches_eq(
|
||||
item.provider_name.as_str(),
|
||||
provider_filter.as_deref(),
|
||||
@@ -153,24 +176,6 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
});
|
||||
let total = usage.len();
|
||||
|
||||
let user_ids: Vec<String> = usage
|
||||
.iter()
|
||||
.filter_map(|item| item.user_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
|
||||
if state.has_user_data_reader() && !user_ids.is_empty() {
|
||||
state
|
||||
.list_users_by_ids(&user_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.id.clone(), user))
|
||||
.collect()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
|
||||
let provider_key_names = admin_usage_provider_key_names(state, &usage).await?;
|
||||
|
||||
let records = usage
|
||||
@@ -182,6 +187,9 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
return Ok(Some(build_admin_usage_records_response(
|
||||
&records,
|
||||
&users_by_id,
|
||||
&api_key_names,
|
||||
state.has_auth_user_data_reader(),
|
||||
state.has_auth_api_key_data_reader(),
|
||||
&provider_key_names,
|
||||
total,
|
||||
limit,
|
||||
|
||||
@@ -18,6 +18,23 @@ pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String>
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_pool_advanced_config(
|
||||
value: Option<serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
match value {
|
||||
serde_json::Value::Null => Ok(None),
|
||||
// `pool_advanced: {}` still means "enable pool mode with defaults".
|
||||
serde_json::Value::Object(map) => Ok(Some(serde_json::Value::Object(map))),
|
||||
// Backward compatibility for older boolean payloads.
|
||||
serde_json::Value::Bool(true) => Ok(Some(serde_json::json!({}))),
|
||||
serde_json::Value::Bool(false) => Ok(None),
|
||||
_ => Err("pool_advanced 必须是 JSON 对象".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_vertex_api_formats(
|
||||
provider_type: &str,
|
||||
auth_type: &str,
|
||||
@@ -46,3 +63,29 @@ pub(crate) fn validate_vertex_api_formats(
|
||||
allowed.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_pool_advanced_config;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn normalize_pool_advanced_preserves_empty_object() {
|
||||
assert_eq!(
|
||||
normalize_pool_advanced_config(Some(json!({}))).expect("empty object should normalize"),
|
||||
Some(json!({}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_pool_advanced_accepts_legacy_booleans() {
|
||||
assert_eq!(
|
||||
normalize_pool_advanced_config(Some(json!(true))).expect("true should normalize"),
|
||||
Some(json!({}))
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_pool_advanced_config(Some(json!(false))).expect("false should normalize"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ use crate::handlers::admin::provider::shared::payloads::AdminProviderCreateReque
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
normalize_provider_billing_type, parse_optional_rfc3339_unix_secs,
|
||||
};
|
||||
use crate::handlers::admin::provider::write::normalize::normalize_provider_type_input;
|
||||
use crate::handlers::admin::provider::write::normalize::{
|
||||
normalize_pool_advanced_config, normalize_provider_type_input,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::normalize_json_object;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
@@ -118,7 +120,7 @@ pub(crate) async fn build_admin_create_provider_record(
|
||||
let mut config_map = normalize_json_object(payload.config, "config")?
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
if let Some(value) = normalize_json_object(payload.pool_advanced, "pool_advanced")? {
|
||||
if let Some(value) = normalize_pool_advanced_config(payload.pool_advanced)? {
|
||||
config_map.insert("pool_advanced".to_string(), value);
|
||||
}
|
||||
if let Some(value) = normalize_json_object(payload.failover_rules, "failover_rules")? {
|
||||
|
||||
@@ -2,7 +2,9 @@ use crate::handlers::admin::provider::shared::payloads::AdminProviderUpdatePatch
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
normalize_provider_billing_type, parse_optional_rfc3339_unix_secs,
|
||||
};
|
||||
use crate::handlers::admin::provider::write::normalize::normalize_provider_type_input;
|
||||
use crate::handlers::admin::provider::write::normalize::{
|
||||
normalize_pool_advanced_config, normalize_provider_type_input,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::normalize_json_object;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
@@ -252,7 +254,7 @@ pub(crate) async fn build_admin_update_provider_record(
|
||||
if fields.is_null("pool_advanced") {
|
||||
config_map.remove("pool_advanced");
|
||||
} else {
|
||||
let value = normalize_json_object(payload.pool_advanced, "pool_advanced")?
|
||||
let value = normalize_pool_advanced_config(payload.pool_advanced)?
|
||||
.ok_or_else(|| "pool_advanced 必须是 JSON 对象".to_string())?;
|
||||
config_map.insert("pool_advanced".to_string(), value);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ impl<'a> AdminAppState<'a> {
|
||||
self.app.has_user_data_reader()
|
||||
}
|
||||
|
||||
pub(crate) fn has_auth_user_data_reader(&self) -> bool {
|
||||
self.app.has_auth_user_data_reader()
|
||||
}
|
||||
|
||||
pub(crate) fn has_proxy_node_reader(&self) -> bool {
|
||||
self.app.has_proxy_node_reader()
|
||||
}
|
||||
|
||||
@@ -54,6 +54,17 @@ impl<'a> AdminAppState<'a> {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_request_usage_body_ref(
|
||||
&self,
|
||||
body_ref: &str,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
self.app
|
||||
.data
|
||||
.resolve_request_usage_body_ref(body_ref)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_usage_for_range(
|
||||
&self,
|
||||
time_range: &crate::handlers::admin::observability::AdminStatsTimeRange,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,16 @@ impl<'a> AdminAppState<'a> {
|
||||
self.app.list_users_by_ids(user_ids).await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_auth_user_summaries_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<
|
||||
std::collections::BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.app.resolve_auth_user_summaries_by_ids(user_ids).await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_export_users_page(
|
||||
&self,
|
||||
query: &aether_data::repository::users::UserExportListQuery,
|
||||
@@ -103,6 +113,17 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn initialize_auth_api_key_wallet(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
initial_gift_usd: f64,
|
||||
unlimited: bool,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
||||
self.app
|
||||
.initialize_auth_api_key_wallet(api_key_id, initial_gift_usd, unlimited)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_local_auth_user_profile(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -165,10 +186,94 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_auth_api_key_wallet_limit_mode(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
limit_mode: &str,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
||||
self.app
|
||||
.update_auth_api_key_wallet_limit_mode(api_key_id, limit_mode)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn update_auth_user_wallet_snapshot(
|
||||
&self,
|
||||
user_id: &str,
|
||||
balance: f64,
|
||||
gift_balance: f64,
|
||||
limit_mode: &str,
|
||||
currency: &str,
|
||||
status: &str,
|
||||
total_recharged: f64,
|
||||
total_consumed: f64,
|
||||
total_refunded: f64,
|
||||
total_adjusted: f64,
|
||||
updated_at_unix_secs: Option<u64>,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
||||
self.app
|
||||
.update_auth_user_wallet_snapshot(
|
||||
user_id,
|
||||
balance,
|
||||
gift_balance,
|
||||
limit_mode,
|
||||
currency,
|
||||
status,
|
||||
total_recharged,
|
||||
total_consumed,
|
||||
total_refunded,
|
||||
total_adjusted,
|
||||
updated_at_unix_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn update_auth_api_key_wallet_snapshot(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
balance: f64,
|
||||
gift_balance: f64,
|
||||
limit_mode: &str,
|
||||
currency: &str,
|
||||
status: &str,
|
||||
total_recharged: f64,
|
||||
total_consumed: f64,
|
||||
total_refunded: f64,
|
||||
total_adjusted: f64,
|
||||
updated_at_unix_secs: Option<u64>,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
||||
self.app
|
||||
.update_auth_api_key_wallet_snapshot(
|
||||
api_key_id,
|
||||
balance,
|
||||
gift_balance,
|
||||
limit_mode,
|
||||
currency,
|
||||
status,
|
||||
total_recharged,
|
||||
total_consumed,
|
||||
total_refunded,
|
||||
total_adjusted,
|
||||
updated_at_unix_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn count_active_admin_users(&self) -> Result<u64, GatewayError> {
|
||||
self.app.count_active_admin_users().await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_user_model_capability_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
self.app
|
||||
.update_user_model_capability_settings(user_id, settings)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn count_user_pending_refunds(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -236,6 +341,24 @@ impl<'a> AdminAppState<'a> {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_auth_api_key_snapshots_by_ids(
|
||||
&self,
|
||||
api_key_ids: &[String],
|
||||
) -> Result<Vec<aether_data::repository::auth::StoredAuthApiKeySnapshot>, GatewayError> {
|
||||
self.app
|
||||
.resolve_auth_api_key_snapshots_by_ids(api_key_ids)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_auth_api_key_names_by_ids(
|
||||
&self,
|
||||
api_key_ids: &[String],
|
||||
) -> Result<std::collections::BTreeMap<String, String>, GatewayError> {
|
||||
self.app
|
||||
.resolve_auth_api_key_names_by_ids(api_key_ids)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_auth_api_key_export_records_by_user_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
@@ -339,6 +462,18 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_active(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
is_active: bool,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
self.app
|
||||
.set_user_api_key_active(user_id, api_key_id, is_active)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_locked(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -362,6 +497,18 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_force_capabilities(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
force_capabilities: Option<serde_json::Value>,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
self.app
|
||||
.set_user_api_key_force_capabilities(user_id, api_key_id, force_capabilities)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_user_api_key(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -137,11 +137,46 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
||||
)));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("users_import")
|
||||
&& request_method == http::Method::POST
|
||||
&& request_path == "/api/admin/system/users/import"
|
||||
{
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求数据验证失败" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(
|
||||
match state
|
||||
.import_admin_system_users(
|
||||
request_body,
|
||||
decision
|
||||
.admin_principal
|
||||
.as_ref()
|
||||
.map(|principal| principal.user_id.as_str()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_users_imported",
|
||||
"import_system_users",
|
||||
"system_users_import",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(
|
||||
decision.route_kind.as_deref(),
|
||||
Some(
|
||||
"users_import"
|
||||
| "smtp_test"
|
||||
"smtp_test"
|
||||
| "cleanup"
|
||||
| "purge_config"
|
||||
| "purge_users"
|
||||
|
||||
@@ -120,8 +120,17 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
key_hash: hash_admin_user_api_key(&plaintext_key),
|
||||
key_encrypted: Some(key_encrypted),
|
||||
name: Some(name.clone()),
|
||||
allowed_providers: None,
|
||||
allowed_api_formats: None,
|
||||
allowed_models: None,
|
||||
rate_limit,
|
||||
concurrent_limit: 5,
|
||||
force_capabilities: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry: false,
|
||||
total_requests: 0,
|
||||
total_cost_usd: 0.0,
|
||||
})
|
||||
.await?
|
||||
else {
|
||||
|
||||
@@ -31,8 +31,9 @@ use crate::control::{
|
||||
GatewayPublicRequestContext,
|
||||
};
|
||||
use crate::executor::{
|
||||
maybe_execute_stream_request, maybe_execute_sync_request,
|
||||
record_failed_usage_for_exhausted_request, LocalExecutionRequestOutcome,
|
||||
build_local_execution_runtime_miss_context, maybe_execute_stream_request,
|
||||
maybe_execute_sync_request, record_failed_usage_for_exhausted_request,
|
||||
record_failed_usage_for_runtime_miss_request, LocalExecutionRequestOutcome,
|
||||
};
|
||||
use crate::frontdoor_loop_guard::{
|
||||
frontdoor_self_loop_public_ai_path, request_has_execution_runtime_loop_guard,
|
||||
@@ -46,7 +47,7 @@ use crate::headers::{extract_or_generate_trace_id, should_skip_request_header};
|
||||
use crate::router::RequestAdmissionError;
|
||||
use crate::{
|
||||
AppState, FrontdoorUserRpmOutcome, GatewayError, GatewayFallbackMetricKind,
|
||||
GatewayFallbackReason,
|
||||
GatewayFallbackReason, LocalExecutionRuntimeMissDiagnostic,
|
||||
};
|
||||
use axum::body::{to_bytes, Body, Bytes};
|
||||
use axum::extract::{ConnectInfo, Request, State};
|
||||
@@ -1013,9 +1014,16 @@ pub(crate) async fn proxy_request(
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
}
|
||||
let local_execution_runtime_miss_detail =
|
||||
local_execution_runtime_miss_detail(control_decision)
|
||||
.unwrap_or("AI public execution runtime miss did not match a Rust execution path");
|
||||
let local_execution_runtime_miss_diagnostic =
|
||||
state.take_local_execution_runtime_miss_diagnostic(&trace_id);
|
||||
let local_execution_runtime_miss_detail = local_execution_runtime_miss_detail(
|
||||
control_decision,
|
||||
local_execution_runtime_miss_diagnostic.as_ref(),
|
||||
stream_request,
|
||||
)
|
||||
.unwrap_or_else(|| {
|
||||
"AI public execution runtime miss did not match a Rust execution path".to_string()
|
||||
});
|
||||
state.record_fallback_metric(
|
||||
GatewayFallbackMetricKind::LocalExecutionRuntimeMiss,
|
||||
control_decision,
|
||||
@@ -1023,38 +1031,95 @@ pub(crate) async fn proxy_request(
|
||||
Some(EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS),
|
||||
GatewayFallbackReason::LocalExecutionPathRequired,
|
||||
);
|
||||
let local_execution_runtime_miss_diagnostic =
|
||||
state.take_local_execution_runtime_miss_diagnostic(&trace_id);
|
||||
if let Some(diagnostic) = local_execution_runtime_miss_diagnostic.as_ref() {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
local_execution_runtime_miss_reason = %diagnostic.reason,
|
||||
route_family = diagnostic.route_family.as_deref().unwrap_or_default(),
|
||||
route_kind = diagnostic.route_kind.as_deref().unwrap_or_default(),
|
||||
public_path = diagnostic.public_path.as_deref().unwrap_or_default(),
|
||||
plan_kind = diagnostic.plan_kind.as_deref().unwrap_or_default(),
|
||||
requested_model = diagnostic.requested_model.as_deref().unwrap_or_default(),
|
||||
candidate_count = diagnostic.candidate_count.unwrap_or(0),
|
||||
skipped_candidate_count = diagnostic.skipped_candidate_count.unwrap_or(0),
|
||||
skip_reasons = diagnostic.skip_reasons_summary().unwrap_or_default(),
|
||||
"gateway local execution runtime miss"
|
||||
);
|
||||
}
|
||||
let local_execution_runtime_miss_context =
|
||||
build_local_execution_runtime_miss_context(&state, &trace_id, control_decision).await;
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
local_execution_runtime_miss_reason = local_execution_runtime_miss_diagnostic
|
||||
.as_ref()
|
||||
.map(|value| value.reason.as_str())
|
||||
.unwrap_or("unknown"),
|
||||
route_family = local_execution_runtime_miss_diagnostic
|
||||
.as_ref()
|
||||
.and_then(|value| value.route_family.as_deref())
|
||||
.or_else(|| control_decision.and_then(|value| value.route_family.as_deref()))
|
||||
.unwrap_or_default(),
|
||||
route_kind = local_execution_runtime_miss_diagnostic
|
||||
.as_ref()
|
||||
.and_then(|value| value.route_kind.as_deref())
|
||||
.or_else(|| control_decision.and_then(|value| value.route_kind.as_deref()))
|
||||
.unwrap_or_default(),
|
||||
public_path = local_execution_runtime_miss_diagnostic
|
||||
.as_ref()
|
||||
.and_then(|value| value.public_path.as_deref())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| control_decision.map(GatewayControlDecision::proxy_path_and_query))
|
||||
.unwrap_or_default(),
|
||||
plan_kind = local_execution_runtime_miss_diagnostic
|
||||
.as_ref()
|
||||
.and_then(|value| value.plan_kind.as_deref())
|
||||
.unwrap_or_default(),
|
||||
requested_model = local_execution_runtime_miss_diagnostic
|
||||
.as_ref()
|
||||
.and_then(|value| value.requested_model.as_deref())
|
||||
.unwrap_or_default(),
|
||||
candidate_count = local_execution_runtime_miss_diagnostic
|
||||
.as_ref()
|
||||
.and_then(|value| value.candidate_count)
|
||||
.unwrap_or(0),
|
||||
persisted_candidate_count = local_execution_runtime_miss_context.persisted_candidate_count(),
|
||||
skipped_candidate_count = local_execution_runtime_miss_diagnostic
|
||||
.as_ref()
|
||||
.and_then(|value| value.skipped_candidate_count)
|
||||
.unwrap_or(0),
|
||||
skip_reasons = local_execution_runtime_miss_diagnostic
|
||||
.as_ref()
|
||||
.and_then(|value| value.skip_reasons_summary())
|
||||
.unwrap_or_default(),
|
||||
auth_user_id = local_execution_runtime_miss_context
|
||||
.auth_user_id
|
||||
.as_deref()
|
||||
.unwrap_or_default(),
|
||||
auth_api_key_id = local_execution_runtime_miss_context
|
||||
.auth_api_key_id
|
||||
.as_deref()
|
||||
.unwrap_or_default(),
|
||||
auth_api_key_name = local_execution_runtime_miss_context
|
||||
.auth_api_key_name
|
||||
.as_deref()
|
||||
.unwrap_or_default(),
|
||||
request_candidates = local_execution_runtime_miss_context
|
||||
.candidate_summary()
|
||||
.unwrap_or_default(),
|
||||
"gateway local execution runtime miss"
|
||||
);
|
||||
if let Some(exhaustion) = local_execution_exhaustion {
|
||||
record_failed_usage_for_exhausted_request(
|
||||
&state,
|
||||
exhaustion,
|
||||
&started_at,
|
||||
local_execution_runtime_miss_detail,
|
||||
local_execution_runtime_miss_detail.as_str(),
|
||||
local_execution_runtime_miss_diagnostic.as_ref(),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
record_failed_usage_for_runtime_miss_request(
|
||||
&state,
|
||||
&trace_id,
|
||||
&started_at,
|
||||
local_execution_runtime_miss_detail.as_str(),
|
||||
control_decision,
|
||||
local_execution_runtime_miss_diagnostic.as_ref(),
|
||||
&local_execution_runtime_miss_context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let mut response = build_local_http_error_response(
|
||||
&trace_id,
|
||||
control_decision,
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
local_execution_runtime_miss_detail,
|
||||
local_execution_runtime_miss_detail.as_str(),
|
||||
)?;
|
||||
if let Some(diagnostic) = local_execution_runtime_miss_diagnostic {
|
||||
if !diagnostic.reason.trim().is_empty() {
|
||||
@@ -1095,6 +1160,41 @@ pub(crate) async fn proxy_request(
|
||||
|
||||
fn local_execution_runtime_miss_detail(
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
|
||||
stream_request: bool,
|
||||
) -> Option<String> {
|
||||
if let Some(detail) = local_execution_runtime_miss_model_detail(diagnostic, stream_request) {
|
||||
return Some(detail);
|
||||
}
|
||||
|
||||
local_execution_runtime_miss_route_detail(decision).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_model_detail(
|
||||
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
|
||||
stream_request: bool,
|
||||
) -> Option<String> {
|
||||
let diagnostic = diagnostic?;
|
||||
if !matches!(
|
||||
diagnostic.reason.as_str(),
|
||||
"candidate_list_empty" | "all_candidates_skipped"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let requested_model = diagnostic
|
||||
.requested_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let request_mode = if stream_request { "流式" } else { "同步" };
|
||||
Some(format!(
|
||||
"没有可用的提供商支持模型 {requested_model} 的{request_mode}请求"
|
||||
))
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_route_detail(
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
) -> Option<&'static str> {
|
||||
let decision = decision?;
|
||||
if decision.route_class.as_deref() != Some("ai_public") {
|
||||
@@ -1121,6 +1221,60 @@ fn local_execution_runtime_miss_detail(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
local_execution_runtime_miss_detail, GatewayControlDecision,
|
||||
LocalExecutionRuntimeMissDiagnostic,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn runtime_miss_detail_returns_model_specific_stream_message_when_candidates_are_unavailable() {
|
||||
let decision = GatewayControlDecision::synthetic(
|
||||
"/v1/chat/completions",
|
||||
Some("ai_public".to_string()),
|
||||
Some("openai".to_string()),
|
||||
Some("chat".to_string()),
|
||||
Some("openai:chat".to_string()),
|
||||
);
|
||||
let diagnostic = LocalExecutionRuntimeMissDiagnostic {
|
||||
reason: "candidate_list_empty".to_string(),
|
||||
requested_model: Some("gpt-5.4".to_string()),
|
||||
..LocalExecutionRuntimeMissDiagnostic::default()
|
||||
};
|
||||
|
||||
let detail = local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), true);
|
||||
|
||||
assert_eq!(
|
||||
detail.as_deref(),
|
||||
Some("没有可用的提供商支持模型 gpt-5.4 的流式请求")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_miss_detail_falls_back_to_route_default_when_reason_is_not_model_unavailable() {
|
||||
let decision = GatewayControlDecision::synthetic(
|
||||
"/v1/messages",
|
||||
Some("ai_public".to_string()),
|
||||
Some("claude".to_string()),
|
||||
Some("chat".to_string()),
|
||||
Some("claude:chat".to_string()),
|
||||
);
|
||||
let diagnostic = LocalExecutionRuntimeMissDiagnostic {
|
||||
reason: "missing_auth_context".to_string(),
|
||||
requested_model: Some("claude-sonnet-4-5".to_string()),
|
||||
..LocalExecutionRuntimeMissDiagnostic::default()
|
||||
};
|
||||
|
||||
let detail = local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), false);
|
||||
|
||||
assert_eq!(
|
||||
detail.as_deref(),
|
||||
Some("Claude messages execution runtime miss did not match a Rust execution path")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "finalize.rs"]
|
||||
mod finalize;
|
||||
|
||||
|
||||
@@ -1060,23 +1060,21 @@ pub(super) async fn handle_dashboard_recent_requests_get(
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
|
||||
if state.has_user_data_reader() && !user_ids.is_empty() {
|
||||
match state.list_users_by_ids(&user_ids).await {
|
||||
Ok(value) => value
|
||||
.into_iter()
|
||||
.map(|user| (user.id.clone(), user))
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("dashboard user lookup failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
match state.resolve_auth_user_summaries_by_ids(&user_ids).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("dashboard user lookup failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
let mut usernames_by_id: BTreeMap<String, String> = users_by_id
|
||||
.iter()
|
||||
.filter(|(_, user)| !user.username.trim().is_empty())
|
||||
.map(|(user_id, user)| (user_id.clone(), user.username.clone()))
|
||||
.collect();
|
||||
|
||||
let requests = usage
|
||||
.into_iter()
|
||||
@@ -1084,9 +1082,13 @@ pub(super) async fn handle_dashboard_recent_requests_get(
|
||||
let username = item
|
||||
.user_id
|
||||
.as_ref()
|
||||
.and_then(|user_id| users_by_id.get(user_id))
|
||||
.map(|user| user.username.clone())
|
||||
.or(item.username.clone())
|
||||
.and_then(|user_id| usernames_by_id.get(user_id))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
(!state.has_auth_user_data_reader())
|
||||
.then(|| item.username.clone())
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
json!({
|
||||
"id": item.id,
|
||||
|
||||
@@ -530,8 +530,17 @@ pub(super) async fn handle_users_me_api_key_create(
|
||||
key_hash: hash_users_me_api_key(&plaintext_key),
|
||||
key_encrypted: Some(key_encrypted),
|
||||
name: Some(name.clone()),
|
||||
allowed_providers: None,
|
||||
allowed_api_formats: None,
|
||||
allowed_models: None,
|
||||
rate_limit,
|
||||
concurrent_limit: 5,
|
||||
force_capabilities: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry: false,
|
||||
total_requests: 0,
|
||||
total_cost_usd: 0.0,
|
||||
};
|
||||
let Some(created) = (match state.create_user_api_key(record).await {
|
||||
Ok(value) => value,
|
||||
|
||||
@@ -145,32 +145,56 @@ fn users_me_usage_cache_hit_rate(total_input_context: u64, cache_read_tokens: u6
|
||||
}
|
||||
}
|
||||
|
||||
fn users_me_usage_matches_search(item: &StoredRequestUsageAudit, search: Option<&str>) -> bool {
|
||||
fn users_me_usage_api_key_name(
|
||||
item: &StoredRequestUsageAudit,
|
||||
api_key_names: &BTreeMap<String, String>,
|
||||
auth_api_key_reader_available: bool,
|
||||
) -> Option<String> {
|
||||
item.api_key_id
|
||||
.as_ref()
|
||||
.and_then(|api_key_id| api_key_names.get(api_key_id))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
(!auth_api_key_reader_available)
|
||||
.then(|| item.api_key_name.clone())
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
fn users_me_usage_matches_search(
|
||||
item: &StoredRequestUsageAudit,
|
||||
search: Option<&str>,
|
||||
api_key_names: &BTreeMap<String, String>,
|
||||
auth_api_key_reader_available: bool,
|
||||
) -> bool {
|
||||
let Some(search) = search.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let model = item.model.to_ascii_lowercase();
|
||||
let api_key_name = item
|
||||
.api_key_name
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let api_key_name =
|
||||
users_me_usage_api_key_name(item, api_key_names, auth_api_key_reader_available)
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
search.split_whitespace().all(|keyword| {
|
||||
let keyword = keyword.to_ascii_lowercase();
|
||||
model.contains(&keyword) || api_key_name.contains(&keyword)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_users_me_usage_api_key_payload(item: &StoredRequestUsageAudit) -> serde_json::Value {
|
||||
fn build_users_me_usage_api_key_payload(
|
||||
item: &StoredRequestUsageAudit,
|
||||
api_key_names: &BTreeMap<String, String>,
|
||||
auth_api_key_reader_available: bool,
|
||||
) -> serde_json::Value {
|
||||
let api_key_name =
|
||||
users_me_usage_api_key_name(item, api_key_names, auth_api_key_reader_available);
|
||||
match item.api_key_id.as_deref() {
|
||||
Some(api_key_id) => json!({
|
||||
"id": api_key_id,
|
||||
"name": item.api_key_name.clone(),
|
||||
"display": item
|
||||
.api_key_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| api_key_id.to_string()),
|
||||
"name": api_key_name.clone(),
|
||||
"display": api_key_name.unwrap_or_else(|| api_key_id.to_string()),
|
||||
}),
|
||||
None => serde_json::Value::Null,
|
||||
}
|
||||
@@ -179,7 +203,14 @@ fn build_users_me_usage_api_key_payload(item: &StoredRequestUsageAudit) -> serde
|
||||
fn build_users_me_usage_record_payload(
|
||||
item: &StoredRequestUsageAudit,
|
||||
include_actual_cost: bool,
|
||||
api_key_names: &BTreeMap<String, String>,
|
||||
auth_api_key_reader_available: bool,
|
||||
) -> serde_json::Value {
|
||||
let input_price_per_1m = item.settlement_input_price_per_1m();
|
||||
let output_price_per_1m = item.settlement_output_price_per_1m();
|
||||
let cache_creation_price_per_1m = item.settlement_cache_creation_price_per_1m();
|
||||
let cache_read_price_per_1m = item.settlement_cache_read_price_per_1m();
|
||||
let rate_multiplier = item.settlement_rate_multiplier();
|
||||
let mut payload = json!({
|
||||
"id": item.id,
|
||||
"model": item.model,
|
||||
@@ -203,11 +234,15 @@ fn build_users_me_usage_record_payload(
|
||||
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||
"status_code": item.status_code,
|
||||
"error_message": item.error_message,
|
||||
"input_price_per_1m": serde_json::Value::Null,
|
||||
"output_price_per_1m": item.output_price_per_1m,
|
||||
"cache_creation_price_per_1m": serde_json::Value::Null,
|
||||
"cache_read_price_per_1m": serde_json::Value::Null,
|
||||
"api_key": build_users_me_usage_api_key_payload(item),
|
||||
"input_price_per_1m": input_price_per_1m,
|
||||
"output_price_per_1m": output_price_per_1m,
|
||||
"cache_creation_price_per_1m": cache_creation_price_per_1m,
|
||||
"cache_read_price_per_1m": cache_read_price_per_1m,
|
||||
"api_key": build_users_me_usage_api_key_payload(
|
||||
item,
|
||||
api_key_names,
|
||||
auth_api_key_reader_available,
|
||||
),
|
||||
});
|
||||
|
||||
if item.target_model.is_some() {
|
||||
@@ -215,7 +250,7 @@ fn build_users_me_usage_record_payload(
|
||||
}
|
||||
if include_actual_cost {
|
||||
payload["actual_cost"] = json!(round_to(item.actual_total_cost_usd, 6));
|
||||
payload["rate_multiplier"] = serde_json::Value::Null;
|
||||
payload["rate_multiplier"] = json!(rate_multiplier);
|
||||
}
|
||||
payload
|
||||
}
|
||||
@@ -233,7 +268,7 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
|
||||
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||
"cost": round_to(item.total_cost_usd, 6),
|
||||
"actual_cost": round_to(item.actual_total_cost_usd, 6),
|
||||
"rate_multiplier": serde_json::Value::Null,
|
||||
"rate_multiplier": item.settlement_rate_multiplier(),
|
||||
"response_time_ms": item.response_time_ms,
|
||||
"first_byte_time_ms": item.first_byte_time_ms,
|
||||
"api_format": item.api_format,
|
||||
@@ -675,6 +710,30 @@ pub(super) async fn handle_users_me_usage_get(
|
||||
)
|
||||
}
|
||||
};
|
||||
let api_key_names = if state.has_auth_api_key_data_reader() {
|
||||
let api_key_ids = usage
|
||||
.iter()
|
||||
.filter_map(|item| item.api_key_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
if api_key_ids.is_empty() {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
match state.resolve_auth_api_key_names_by_ids(&api_key_ids).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("user api key name lookup failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
|
||||
let summary_items = usage
|
||||
.iter()
|
||||
@@ -738,7 +797,14 @@ pub(super) async fn handle_users_me_usage_get(
|
||||
|
||||
let mut records = usage
|
||||
.into_iter()
|
||||
.filter(|item| users_me_usage_matches_search(item, search.as_deref()))
|
||||
.filter(|item| {
|
||||
users_me_usage_matches_search(
|
||||
item,
|
||||
search.as_deref(),
|
||||
&api_key_names,
|
||||
state.has_auth_api_key_data_reader(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
records.sort_by(|left, right| {
|
||||
right
|
||||
@@ -751,7 +817,14 @@ pub(super) async fn handle_users_me_usage_get(
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|item| build_users_me_usage_record_payload(&item, include_actual_cost))
|
||||
.map(|item| {
|
||||
build_users_me_usage_record_payload(
|
||||
&item,
|
||||
include_actual_cost,
|
||||
&api_key_names,
|
||||
state.has_auth_api_key_data_reader(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let wallet = state
|
||||
|
||||
@@ -245,6 +245,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
||||
| (Some("provider_oauth_manage"), http::Method::POST, Some("device_authorize"))
|
||||
| (Some("provider_oauth_manage"), http::Method::POST, Some("device_poll"))
|
||||
| (Some("system_manage"), http::Method::POST, Some("config_import"))
|
||||
| (Some("system_manage"), http::Method::POST, Some("users_import"))
|
||||
| (Some("system_manage"), http::Method::PUT, Some("settings_set"))
|
||||
| (Some("system_manage"), http::Method::PUT, Some("config_set"))
|
||||
| (Some("system_manage"), http::Method::PUT, Some("email_template_set"))
|
||||
|
||||
Reference in New Issue
Block a user