feat: 引入 aether-runtime/cache/data/http/testkit 基础 crate,完善并发门控与审计系统

新增 crate:
- aether-runtime: 服务运行时基础设施(并发门控、分布式并发、指标、队列、优雅关闭、tracing)
- aether-cache: 通用 TTL 缓存与命名空间抽象
- aether-data: 数据访问层(PostgreSQL/Redis 后端、repository 模式)
- aether-http: HTTP 客户端封装(重试、配置)
- aether-testkit: 集成测试工具集(gateway/executor/hub/proxy fixture、等待、负载测试)

gateway 扩展:
- 引入 audit 模块(shadow 执行审计、决策链路追踪、请求审计 bundle)
- 引入 cache 模块(AuthContext 缓存、direct-plan bypass 缓存)
- 引入 data 模块(auth/candidates/config/usage/video_tasks 数据访问)
- 集成 ConcurrencyGate/DistributedConcurrencyGate 请求门控
- 新增本地 auth 拒绝、过载响应构建器
- 补充 control/auth_cache/video/concurrency 集成测试

aether-proxy 扩展:
- AppState 集成 stream_gate / distributed_stream_gate 并发门控
- 新增 ProxyAdmissionError 及准入拒绝流程
- stream_handler 补充门控饱和/不可用场景测试
- 配置与注册客户端逻辑完善

aether-hub 扩展:
- main.rs 引入运行时初始化、指标端点、健康检查
- local_relay 重构为 lib.rs 暴露公共接口
This commit is contained in:
fawney19
2026-03-24 15:12:56 +08:00
parent eaf8475f9e
commit b5a0070023
157 changed files with 22097 additions and 448 deletions

View File

@@ -0,0 +1,107 @@
use std::collections::BTreeMap;
use std::sync::RwLock;
use async_trait::async_trait;
use super::types::{StoredRequestUsageAudit, UsageReadRepository};
use crate::DataLayerError;
#[derive(Debug, Default)]
pub struct InMemoryUsageReadRepository {
by_request_id: RwLock<BTreeMap<String, StoredRequestUsageAudit>>,
}
impl InMemoryUsageReadRepository {
pub fn seed<I>(items: I) -> Self
where
I: IntoIterator<Item = StoredRequestUsageAudit>,
{
let mut by_request_id = BTreeMap::new();
for item in items {
by_request_id.insert(item.request_id.clone(), item);
}
Self {
by_request_id: RwLock::new(by_request_id),
}
}
}
#[async_trait]
impl UsageReadRepository for InMemoryUsageReadRepository {
async fn find_by_request_id(
&self,
request_id: &str,
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
Ok(self
.by_request_id
.read()
.expect("usage repository lock")
.get(request_id)
.cloned())
}
}
#[cfg(test)]
mod tests {
use super::InMemoryUsageReadRepository;
use crate::repository::usage::{StoredRequestUsageAudit, UsageReadRepository};
fn sample_usage(request_id: &str, created_at_unix_secs: i64) -> StoredRequestUsageAudit {
StoredRequestUsageAudit::new(
"usage-1".to_string(),
request_id.to_string(),
Some("user-1".to_string()),
Some("api-key-1".to_string()),
Some("alice".to_string()),
Some("default".to_string()),
"OpenAI".to_string(),
"gpt-4.1".to_string(),
Some("gpt-4.1-mini".to_string()),
Some("provider-1".to_string()),
Some("endpoint-1".to_string()),
Some("provider-key-1".to_string()),
Some("chat".to_string()),
Some("openai:chat".to_string()),
Some("openai".to_string()),
Some("chat".to_string()),
Some("openai:chat".to_string()),
Some("openai".to_string()),
Some("chat".to_string()),
true,
false,
100,
50,
150,
0.12,
0.18,
Some(200),
None,
None,
Some(420),
Some(120),
"completed".to_string(),
"settled".to_string(),
created_at_unix_secs,
created_at_unix_secs + 1,
Some(created_at_unix_secs + 2),
)
.expect("usage should build")
}
#[tokio::test]
async fn finds_usage_by_request_id() {
let repository = InMemoryUsageReadRepository::seed(vec![
sample_usage("req-1", 100),
sample_usage("req-2", 200),
]);
let usage = repository
.find_by_request_id("req-2")
.await
.expect("find should succeed")
.expect("usage should exist");
assert_eq!(usage.request_id, "req-2");
assert_eq!(usage.total_tokens, 150);
}
}

View File

@@ -0,0 +1,7 @@
mod memory;
mod sql;
mod types;
pub use memory::InMemoryUsageReadRepository;
pub use sql::SqlxUsageReadRepository;
pub use types::{StoredRequestUsageAudit, UsageReadRepository, UsageRepository};

View File

@@ -0,0 +1,150 @@
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use super::types::{StoredRequestUsageAudit, UsageReadRepository};
use crate::DataLayerError;
const FIND_BY_REQUEST_ID_SQL: &str = r#"
SELECT
id,
request_id,
user_id,
api_key_id,
username,
api_key_name,
provider_name,
model,
target_model,
provider_id,
provider_endpoint_id,
provider_api_key_id,
request_type,
api_format,
api_family,
endpoint_kind,
endpoint_api_format,
provider_api_family,
provider_endpoint_kind,
COALESCE(has_format_conversion, FALSE) AS has_format_conversion,
COALESCE(is_stream, FALSE) AS is_stream,
input_tokens,
output_tokens,
total_tokens,
COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) AS total_cost_usd,
COALESCE(CAST(actual_total_cost_usd AS DOUBLE PRECISION), 0) AS actual_total_cost_usd,
status_code,
error_message,
error_category,
response_time_ms,
first_byte_time_ms,
status,
billing_status,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs,
CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT) AS finalized_at_unix_secs
FROM "usage"
WHERE request_id = $1
LIMIT 1
"#;
#[derive(Debug, Clone)]
pub struct SqlxUsageReadRepository {
pool: PgPool,
}
impl SqlxUsageReadRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub fn pool(&self) -> &PgPool {
&self.pool
}
pub async fn find_by_request_id(
&self,
request_id: &str,
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
let row = sqlx::query(FIND_BY_REQUEST_ID_SQL)
.bind(request_id)
.fetch_optional(&self.pool)
.await?;
row.as_ref().map(map_usage_row).transpose()
}
}
#[async_trait]
impl UsageReadRepository for SqlxUsageReadRepository {
async fn find_by_request_id(
&self,
request_id: &str,
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
Self::find_by_request_id(self, request_id).await
}
}
fn map_usage_row(row: &sqlx::postgres::PgRow) -> Result<StoredRequestUsageAudit, DataLayerError> {
StoredRequestUsageAudit::new(
row.try_get("id")?,
row.try_get("request_id")?,
row.try_get("user_id")?,
row.try_get("api_key_id")?,
row.try_get("username")?,
row.try_get("api_key_name")?,
row.try_get("provider_name")?,
row.try_get("model")?,
row.try_get("target_model")?,
row.try_get("provider_id")?,
row.try_get("provider_endpoint_id")?,
row.try_get("provider_api_key_id")?,
row.try_get("request_type")?,
row.try_get("api_format")?,
row.try_get("api_family")?,
row.try_get("endpoint_kind")?,
row.try_get("endpoint_api_format")?,
row.try_get("provider_api_family")?,
row.try_get("provider_endpoint_kind")?,
row.try_get("has_format_conversion")?,
row.try_get("is_stream")?,
row.try_get("input_tokens")?,
row.try_get("output_tokens")?,
row.try_get("total_tokens")?,
row.try_get("total_cost_usd")?,
row.try_get("actual_total_cost_usd")?,
row.try_get("status_code")?,
row.try_get("error_message")?,
row.try_get("error_category")?,
row.try_get("response_time_ms")?,
row.try_get("first_byte_time_ms")?,
row.try_get("status")?,
row.try_get("billing_status")?,
row.try_get("created_at_unix_secs")?,
row.try_get("updated_at_unix_secs")?,
row.try_get("finalized_at_unix_secs")?,
)
}
#[cfg(test)]
mod tests {
use super::SqlxUsageReadRepository;
use crate::postgres::{PostgresPoolConfig, PostgresPoolFactory};
#[tokio::test]
async fn repository_constructs_from_lazy_pool() {
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
database_url: "postgres://localhost/aether".to_string(),
min_connections: 1,
max_connections: 4,
acquire_timeout_ms: 1_000,
idle_timeout_ms: 5_000,
max_lifetime_ms: 30_000,
statement_cache_capacity: 64,
require_ssl: false,
})
.expect("factory should build");
let pool = factory.connect_lazy().expect("pool should build");
let repository = SqlxUsageReadRepository::new(pool);
let _ = repository.pool();
}
}

View File

@@ -0,0 +1,304 @@
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredRequestUsageAudit {
pub id: String,
pub request_id: String,
pub user_id: Option<String>,
pub api_key_id: Option<String>,
pub username: Option<String>,
pub api_key_name: Option<String>,
pub provider_name: String,
pub model: String,
pub target_model: Option<String>,
pub provider_id: Option<String>,
pub provider_endpoint_id: Option<String>,
pub provider_api_key_id: Option<String>,
pub request_type: Option<String>,
pub api_format: Option<String>,
pub api_family: Option<String>,
pub endpoint_kind: Option<String>,
pub endpoint_api_format: Option<String>,
pub provider_api_family: Option<String>,
pub provider_endpoint_kind: Option<String>,
pub has_format_conversion: bool,
pub is_stream: bool,
pub input_tokens: u64,
pub output_tokens: u64,
pub total_tokens: u64,
pub total_cost_usd: f64,
pub actual_total_cost_usd: f64,
pub status_code: Option<u16>,
pub error_message: Option<String>,
pub error_category: Option<String>,
pub response_time_ms: Option<u64>,
pub first_byte_time_ms: Option<u64>,
pub status: String,
pub billing_status: String,
pub created_at_unix_secs: u64,
pub updated_at_unix_secs: u64,
pub finalized_at_unix_secs: Option<u64>,
}
impl StoredRequestUsageAudit {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
request_id: String,
user_id: Option<String>,
api_key_id: Option<String>,
username: Option<String>,
api_key_name: Option<String>,
provider_name: String,
model: String,
target_model: Option<String>,
provider_id: Option<String>,
provider_endpoint_id: Option<String>,
provider_api_key_id: Option<String>,
request_type: Option<String>,
api_format: Option<String>,
api_family: Option<String>,
endpoint_kind: Option<String>,
endpoint_api_format: Option<String>,
provider_api_family: Option<String>,
provider_endpoint_kind: Option<String>,
has_format_conversion: bool,
is_stream: bool,
input_tokens: i32,
output_tokens: i32,
total_tokens: i32,
total_cost_usd: f64,
actual_total_cost_usd: f64,
status_code: Option<i32>,
error_message: Option<String>,
error_category: Option<String>,
response_time_ms: Option<i32>,
first_byte_time_ms: Option<i32>,
status: String,
billing_status: String,
created_at_unix_secs: i64,
updated_at_unix_secs: i64,
finalized_at_unix_secs: Option<i64>,
) -> Result<Self, crate::DataLayerError> {
if request_id.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"usage.request_id is empty".to_string(),
));
}
if provider_name.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"usage.provider_name is empty".to_string(),
));
}
if model.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"usage.model is empty".to_string(),
));
}
if status.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"usage.status is empty".to_string(),
));
}
if billing_status.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"usage.billing_status is empty".to_string(),
));
}
if !total_cost_usd.is_finite() {
return Err(crate::DataLayerError::UnexpectedValue(
"usage.total_cost_usd is not finite".to_string(),
));
}
if !actual_total_cost_usd.is_finite() {
return Err(crate::DataLayerError::UnexpectedValue(
"usage.actual_total_cost_usd is not finite".to_string(),
));
}
Ok(Self {
id,
request_id,
user_id,
api_key_id,
username,
api_key_name,
provider_name,
model,
target_model,
provider_id,
provider_endpoint_id,
provider_api_key_id,
request_type,
api_format,
api_family,
endpoint_kind,
endpoint_api_format,
provider_api_family,
provider_endpoint_kind,
has_format_conversion,
is_stream,
input_tokens: parse_u64(input_tokens, "usage.input_tokens")?,
output_tokens: parse_u64(output_tokens, "usage.output_tokens")?,
total_tokens: parse_u64(total_tokens, "usage.total_tokens")?,
total_cost_usd,
actual_total_cost_usd,
status_code: parse_u16(status_code, "usage.status_code")?,
error_message,
error_category,
response_time_ms: parse_optional_u64(response_time_ms, "usage.response_time_ms")?,
first_byte_time_ms: parse_optional_u64(first_byte_time_ms, "usage.first_byte_time_ms")?,
status,
billing_status,
created_at_unix_secs: parse_timestamp(
created_at_unix_secs,
"usage.created_at_unix_secs",
)?,
updated_at_unix_secs: parse_timestamp(
updated_at_unix_secs,
"usage.updated_at_unix_secs",
)?,
finalized_at_unix_secs: finalized_at_unix_secs
.map(|value| parse_timestamp(value, "usage.finalized_at_unix_secs"))
.transpose()?,
})
}
}
#[async_trait]
pub trait UsageReadRepository: Send + Sync {
async fn find_by_request_id(
&self,
request_id: &str,
) -> Result<Option<StoredRequestUsageAudit>, crate::DataLayerError>;
}
pub trait UsageRepository: UsageReadRepository + Send + Sync {}
impl<T> UsageRepository for T where T: UsageReadRepository + Send + Sync {}
fn parse_u64(value: i32, field_name: &str) -> Result<u64, crate::DataLayerError> {
u64::try_from(value).map_err(|_| {
crate::DataLayerError::UnexpectedValue(format!("invalid {field_name}: {value}"))
})
}
fn parse_optional_u64(
value: Option<i32>,
field_name: &str,
) -> Result<Option<u64>, crate::DataLayerError> {
value
.map(|value| {
u64::try_from(value).map_err(|_| {
crate::DataLayerError::UnexpectedValue(format!("invalid {field_name}: {value}"))
})
})
.transpose()
}
fn parse_u16(value: Option<i32>, field_name: &str) -> Result<Option<u16>, crate::DataLayerError> {
value
.map(|value| {
u16::try_from(value).map_err(|_| {
crate::DataLayerError::UnexpectedValue(format!("invalid {field_name}: {value}"))
})
})
.transpose()
}
fn parse_timestamp(value: i64, field_name: &str) -> Result<u64, crate::DataLayerError> {
u64::try_from(value).map_err(|_| {
crate::DataLayerError::UnexpectedValue(format!("invalid {field_name}: {value}"))
})
}
#[cfg(test)]
mod tests {
use super::StoredRequestUsageAudit;
#[test]
fn rejects_empty_request_id() {
assert!(StoredRequestUsageAudit::new(
"usage-1".to_string(),
"".to_string(),
None,
None,
None,
None,
"OpenAI".to_string(),
"gpt-4.1".to_string(),
None,
None,
None,
None,
Some("chat".to_string()),
Some("openai:chat".to_string()),
Some("openai".to_string()),
Some("chat".to_string()),
Some("openai:chat".to_string()),
Some("openai".to_string()),
Some("chat".to_string()),
false,
false,
10,
20,
30,
0.1,
0.1,
Some(200),
None,
None,
Some(120),
Some(80),
"completed".to_string(),
"settled".to_string(),
100,
101,
Some(102),
)
.is_err());
}
#[test]
fn rejects_negative_token_count() {
assert!(StoredRequestUsageAudit::new(
"usage-1".to_string(),
"req-1".to_string(),
None,
None,
None,
None,
"OpenAI".to_string(),
"gpt-4.1".to_string(),
None,
None,
None,
None,
Some("chat".to_string()),
Some("openai:chat".to_string()),
Some("openai".to_string()),
Some("chat".to_string()),
Some("openai:chat".to_string()),
Some("openai".to_string()),
Some("chat".to_string()),
false,
false,
-1,
20,
30,
0.1,
0.1,
Some(200),
None,
None,
Some(120),
Some(80),
"completed".to_string(),
"settled".to_string(),
100,
101,
Some(102),
)
.is_err());
}
}