mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate
- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦 - 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块 - 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支 - 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合 - 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor - 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
@@ -3,7 +3,7 @@ use std::sync::RwLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{
|
||||
use super::{
|
||||
PublicHealthStatusCount, PublicHealthTimelineBucket, RequestCandidateReadRepository,
|
||||
RequestCandidateStatus, RequestCandidateWriteRepository, StoredRequestCandidate,
|
||||
UpsertRequestCandidateRecord,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
mod memory;
|
||||
mod sql;
|
||||
mod types;
|
||||
|
||||
pub use memory::InMemoryRequestCandidateRepository;
|
||||
pub use sql::SqlxRequestCandidateReadRepository;
|
||||
pub use types::{
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use aether_data_contracts::repository::candidates::{
|
||||
build_decision_trace, derive_request_candidate_final_status, DecisionTrace,
|
||||
DecisionTraceCandidate, PublicHealthStatusCount, PublicHealthTimelineBucket,
|
||||
RequestCandidateFinalStatus, RequestCandidateReadRepository, RequestCandidateRepository,
|
||||
RequestCandidateStatus, RequestCandidateTrace, RequestCandidateWriteRepository,
|
||||
StoredRequestCandidate, UpsertRequestCandidateRecord,
|
||||
};
|
||||
pub use memory::InMemoryRequestCandidateRepository;
|
||||
pub use sql::SqlxRequestCandidateReadRepository;
|
||||
|
||||
@@ -3,13 +3,13 @@ use futures_util::future::BoxFuture;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::types::{
|
||||
use super::{
|
||||
PublicHealthStatusCount, PublicHealthTimelineBucket, RequestCandidateReadRepository,
|
||||
RequestCandidateStatus, RequestCandidateWriteRepository, StoredRequestCandidate,
|
||||
UpsertRequestCandidateRecord,
|
||||
};
|
||||
use crate::postgres::PostgresTransactionRunner;
|
||||
use crate::DataLayerError;
|
||||
use crate::{error::SqlxResultExt, DataLayerError};
|
||||
|
||||
const LIST_BY_REQUEST_ID_SQL: &str = r#"
|
||||
SELECT
|
||||
@@ -309,7 +309,8 @@ impl SqlxRequestCandidateReadRepository {
|
||||
let rows = sqlx::query(LIST_BY_REQUEST_ID_SQL)
|
||||
.bind(request_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
rows.iter().map(map_request_candidate_row).collect()
|
||||
}
|
||||
|
||||
@@ -328,7 +329,8 @@ impl SqlxRequestCandidateReadRepository {
|
||||
))
|
||||
})?)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
rows.iter().map(map_request_candidate_row).collect()
|
||||
}
|
||||
|
||||
@@ -351,7 +353,8 @@ impl SqlxRequestCandidateReadRepository {
|
||||
.bind(provider_id)
|
||||
.bind(limit_value)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
rows.iter().map(map_request_candidate_row).collect()
|
||||
}
|
||||
|
||||
@@ -374,7 +377,8 @@ impl SqlxRequestCandidateReadRepository {
|
||||
))
|
||||
})?)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
rows.iter().map(map_request_candidate_row).collect()
|
||||
}
|
||||
|
||||
@@ -391,17 +395,18 @@ impl SqlxRequestCandidateReadRepository {
|
||||
.bind(endpoint_ids)
|
||||
.bind(since_unix_secs as f64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let status = RequestCandidateStatus::from_database(
|
||||
row.try_get::<String, _>("status")?.as_str(),
|
||||
row_get::<String>(row, "status")?.as_str(),
|
||||
)?;
|
||||
Ok(PublicHealthStatusCount {
|
||||
endpoint_id: row.try_get("endpoint_id")?,
|
||||
endpoint_id: row_get(row, "endpoint_id")?,
|
||||
status,
|
||||
count: u64::try_from(row.try_get::<i64, _>("count")?).map_err(|_| {
|
||||
count: u64::try_from(row_get::<i64>(row, "count")?).map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(
|
||||
"public health status count out of range".to_string(),
|
||||
)
|
||||
@@ -435,11 +440,12 @@ impl SqlxRequestCandidateReadRepository {
|
||||
.bind(until_unix_secs as f64)
|
||||
.bind(segment_seconds)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let raw_segment_idx = row.try_get::<i64, _>("segment_idx")?;
|
||||
let raw_segment_idx = row_get::<i64>(row, "segment_idx")?;
|
||||
let segment_idx = if raw_segment_idx < 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -452,49 +458,53 @@ impl SqlxRequestCandidateReadRepository {
|
||||
.min(segments.saturating_sub(1));
|
||||
|
||||
Ok(PublicHealthTimelineBucket {
|
||||
endpoint_id: row.try_get("endpoint_id")?,
|
||||
endpoint_id: row_get(row, "endpoint_id")?,
|
||||
segment_idx,
|
||||
total_count: u64::try_from(row.try_get::<i64, _>("total_count")?).map_err(
|
||||
total_count: u64::try_from(row_get::<i64>(row, "total_count")?).map_err(
|
||||
|_| {
|
||||
DataLayerError::UnexpectedValue(
|
||||
"public health total_count out of range".to_string(),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
success_count: u64::try_from(row.try_get::<i64, _>("success_count")?).map_err(
|
||||
success_count: u64::try_from(row_get::<i64>(row, "success_count")?).map_err(
|
||||
|_| {
|
||||
DataLayerError::UnexpectedValue(
|
||||
"public health success_count out of range".to_string(),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
failed_count: u64::try_from(row.try_get::<i64, _>("failed_count")?).map_err(
|
||||
failed_count: u64::try_from(row_get::<i64>(row, "failed_count")?).map_err(
|
||||
|_| {
|
||||
DataLayerError::UnexpectedValue(
|
||||
"public health failed_count out of range".to_string(),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
min_created_at_unix_secs: row
|
||||
.try_get::<Option<i64>, _>("min_created_at_unix_secs")?
|
||||
.map(|value| {
|
||||
u64::try_from(value).map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"public health min_created_at_unix_secs out of range: {value}"
|
||||
))
|
||||
})
|
||||
min_created_at_unix_secs: row_get::<Option<i64>>(
|
||||
row,
|
||||
"min_created_at_unix_secs",
|
||||
)?
|
||||
.map(|value| {
|
||||
u64::try_from(value).map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"public health min_created_at_unix_secs out of range: {value}"
|
||||
))
|
||||
})
|
||||
.transpose()?,
|
||||
max_created_at_unix_secs: row
|
||||
.try_get::<Option<i64>, _>("max_created_at_unix_secs")?
|
||||
.map(|value| {
|
||||
u64::try_from(value).map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"public health max_created_at_unix_secs out of range: {value}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
max_created_at_unix_secs: row_get::<Option<i64>>(
|
||||
row,
|
||||
"max_created_at_unix_secs",
|
||||
)?
|
||||
.map(|value| {
|
||||
u64::try_from(value).map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"public health max_created_at_unix_secs out of range: {value}"
|
||||
))
|
||||
})
|
||||
.transpose()?,
|
||||
})
|
||||
.transpose()?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -538,7 +548,8 @@ impl SqlxRequestCandidateReadRepository {
|
||||
.bind(candidate.started_at_unix_secs.map(|value| value as f64))
|
||||
.bind(candidate.finished_at_unix_secs.map(|value| value as f64))
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
map_request_candidate_row(&row)
|
||||
}) as BoxFuture<'_, Result<StoredRequestCandidate, DataLayerError>>
|
||||
})
|
||||
@@ -562,7 +573,8 @@ impl SqlxRequestCandidateReadRepository {
|
||||
))
|
||||
})?)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
}
|
||||
@@ -648,36 +660,42 @@ impl RequestCandidateWriteRepository for SqlxRequestCandidateReadRepository {
|
||||
fn map_request_candidate_row(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> Result<StoredRequestCandidate, DataLayerError> {
|
||||
let status =
|
||||
RequestCandidateStatus::from_database(row.try_get::<String, _>("status")?.as_str())?;
|
||||
let status = RequestCandidateStatus::from_database(row_get::<String>(row, "status")?.as_str())?;
|
||||
StoredRequestCandidate::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("candidate_index")?,
|
||||
row.try_get("retry_index")?,
|
||||
row.try_get("provider_id")?,
|
||||
row.try_get("endpoint_id")?,
|
||||
row.try_get("key_id")?,
|
||||
row_get(row, "id")?,
|
||||
row_get(row, "request_id")?,
|
||||
row_get(row, "user_id")?,
|
||||
row_get(row, "api_key_id")?,
|
||||
row_get(row, "username")?,
|
||||
row_get(row, "api_key_name")?,
|
||||
row_get(row, "candidate_index")?,
|
||||
row_get(row, "retry_index")?,
|
||||
row_get(row, "provider_id")?,
|
||||
row_get(row, "endpoint_id")?,
|
||||
row_get(row, "key_id")?,
|
||||
status,
|
||||
row.try_get("skip_reason")?,
|
||||
row.try_get("is_cached")?,
|
||||
row.try_get("status_code")?,
|
||||
row.try_get("error_type")?,
|
||||
row.try_get("error_message")?,
|
||||
row.try_get("latency_ms")?,
|
||||
row.try_get("concurrent_requests")?,
|
||||
row.try_get("extra_data")?,
|
||||
row.try_get("required_capabilities")?,
|
||||
row.try_get("created_at_unix_secs")?,
|
||||
row.try_get("started_at_unix_secs")?,
|
||||
row.try_get("finished_at_unix_secs")?,
|
||||
row_get(row, "skip_reason")?,
|
||||
row_get(row, "is_cached")?,
|
||||
row_get(row, "status_code")?,
|
||||
row_get(row, "error_type")?,
|
||||
row_get(row, "error_message")?,
|
||||
row_get(row, "latency_ms")?,
|
||||
row_get(row, "concurrent_requests")?,
|
||||
row_get(row, "extra_data")?,
|
||||
row_get(row, "required_capabilities")?,
|
||||
row_get(row, "created_at_unix_secs")?,
|
||||
row_get(row, "started_at_unix_secs")?,
|
||||
row_get(row, "finished_at_unix_secs")?,
|
||||
)
|
||||
}
|
||||
|
||||
fn row_get<T>(row: &sqlx::postgres::PgRow, column: &str) -> Result<T, DataLayerError>
|
||||
where
|
||||
for<'r> T: sqlx::Decode<'r, sqlx::Postgres> + sqlx::Type<sqlx::Postgres>,
|
||||
{
|
||||
row.try_get(column).map_postgres_err()
|
||||
}
|
||||
|
||||
fn status_to_database(status: RequestCandidateStatus) -> &'static str {
|
||||
match status {
|
||||
RequestCandidateStatus::Available => "available",
|
||||
|
||||
@@ -1,825 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RequestCandidateStatus {
|
||||
Available,
|
||||
Unused,
|
||||
Pending,
|
||||
Streaming,
|
||||
Success,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl RequestCandidateStatus {
|
||||
pub fn from_database(value: &str) -> Result<Self, crate::DataLayerError> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"available" => Ok(Self::Available),
|
||||
"unused" => Ok(Self::Unused),
|
||||
"pending" => Ok(Self::Pending),
|
||||
"streaming" => Ok(Self::Streaming),
|
||||
"success" => Ok(Self::Success),
|
||||
"failed" => Ok(Self::Failed),
|
||||
"cancelled" => Ok(Self::Cancelled),
|
||||
"skipped" => Ok(Self::Skipped),
|
||||
other => Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"unsupported request_candidates.status: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_attempted(self, started_at_unix_secs: Option<u64>) -> bool {
|
||||
match self {
|
||||
Self::Available | Self::Unused | Self::Skipped => false,
|
||||
Self::Pending => started_at_unix_secs.is_some(),
|
||||
Self::Streaming | Self::Success | Self::Failed | Self::Cancelled => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredRequestCandidate {
|
||||
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 candidate_index: u32,
|
||||
pub retry_index: u32,
|
||||
pub provider_id: Option<String>,
|
||||
pub endpoint_id: Option<String>,
|
||||
pub key_id: Option<String>,
|
||||
pub status: RequestCandidateStatus,
|
||||
pub skip_reason: Option<String>,
|
||||
pub is_cached: bool,
|
||||
pub status_code: Option<u16>,
|
||||
pub error_type: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub concurrent_requests: Option<u32>,
|
||||
pub extra_data: Option<serde_json::Value>,
|
||||
pub required_capabilities: Option<serde_json::Value>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub started_at_unix_secs: Option<u64>,
|
||||
pub finished_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl StoredRequestCandidate {
|
||||
#[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>,
|
||||
candidate_index: i32,
|
||||
retry_index: i32,
|
||||
provider_id: Option<String>,
|
||||
endpoint_id: Option<String>,
|
||||
key_id: Option<String>,
|
||||
status: RequestCandidateStatus,
|
||||
skip_reason: Option<String>,
|
||||
is_cached: bool,
|
||||
status_code: Option<i32>,
|
||||
error_type: Option<String>,
|
||||
error_message: Option<String>,
|
||||
latency_ms: Option<i32>,
|
||||
concurrent_requests: Option<i32>,
|
||||
extra_data: Option<serde_json::Value>,
|
||||
required_capabilities: Option<serde_json::Value>,
|
||||
created_at_unix_secs: i64,
|
||||
started_at_unix_secs: Option<i64>,
|
||||
finished_at_unix_secs: Option<i64>,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
let candidate_index = u32::try_from(candidate_index).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid request_candidates.candidate_index: {candidate_index}"
|
||||
))
|
||||
})?;
|
||||
let retry_index = u32::try_from(retry_index).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid request_candidates.retry_index: {retry_index}"
|
||||
))
|
||||
})?;
|
||||
let status_code = status_code
|
||||
.map(|value| {
|
||||
u16::try_from(value).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid request_candidates.status_code: {value}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let latency_ms = latency_ms
|
||||
.map(|value| {
|
||||
u64::try_from(value).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid request_candidates.latency_ms: {value}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let concurrent_requests = concurrent_requests
|
||||
.map(|value| {
|
||||
u32::try_from(value).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid request_candidates.concurrent_requests: {value}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let created_at_unix_secs = u64::try_from(created_at_unix_secs).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid request_candidates.created_at_unix_secs: {created_at_unix_secs}"
|
||||
))
|
||||
})?;
|
||||
let started_at_unix_secs = started_at_unix_secs
|
||||
.map(|value| {
|
||||
u64::try_from(value).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid request_candidates.started_at_unix_secs: {value}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let finished_at_unix_secs = finished_at_unix_secs
|
||||
.map(|value| {
|
||||
u64::try_from(value).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid request_candidates.finished_at_unix_secs: {value}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
request_id,
|
||||
user_id,
|
||||
api_key_id,
|
||||
username,
|
||||
api_key_name,
|
||||
candidate_index,
|
||||
retry_index,
|
||||
provider_id,
|
||||
endpoint_id,
|
||||
key_id,
|
||||
status,
|
||||
skip_reason,
|
||||
is_cached,
|
||||
status_code,
|
||||
error_type,
|
||||
error_message,
|
||||
latency_ms,
|
||||
concurrent_requests,
|
||||
extra_data,
|
||||
required_capabilities,
|
||||
created_at_unix_secs,
|
||||
started_at_unix_secs,
|
||||
finished_at_unix_secs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RequestCandidateFinalStatus {
|
||||
Success,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Streaming,
|
||||
Pending,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RequestCandidateTrace {
|
||||
pub request_id: String,
|
||||
pub total_candidates: usize,
|
||||
pub final_status: RequestCandidateFinalStatus,
|
||||
pub total_latency_ms: u64,
|
||||
pub candidates: Vec<StoredRequestCandidate>,
|
||||
}
|
||||
|
||||
impl RequestCandidateTrace {
|
||||
pub fn from_candidates(
|
||||
request_id: impl Into<String>,
|
||||
all_candidates: Vec<StoredRequestCandidate>,
|
||||
attempted_only: bool,
|
||||
) -> Option<Self> {
|
||||
if all_candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidates = if attempted_only {
|
||||
all_candidates
|
||||
.iter()
|
||||
.filter(|candidate| {
|
||||
candidate
|
||||
.status
|
||||
.is_attempted(candidate.started_at_unix_secs)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
all_candidates.clone()
|
||||
};
|
||||
|
||||
let total_latency_ms = candidates
|
||||
.iter()
|
||||
.filter(|candidate| {
|
||||
matches!(
|
||||
candidate.status,
|
||||
RequestCandidateStatus::Success
|
||||
| RequestCandidateStatus::Failed
|
||||
| RequestCandidateStatus::Cancelled
|
||||
) && candidate.latency_ms.is_some()
|
||||
})
|
||||
.map(|candidate| candidate.latency_ms.unwrap_or(0))
|
||||
.sum();
|
||||
let final_status_source = if attempted_only && candidates.is_empty() {
|
||||
&all_candidates
|
||||
} else {
|
||||
&candidates
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
request_id: request_id.into(),
|
||||
total_candidates: candidates.len(),
|
||||
final_status: derive_request_candidate_final_status(final_status_source),
|
||||
total_latency_ms,
|
||||
candidates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive_request_candidate_final_status(
|
||||
candidates: &[StoredRequestCandidate],
|
||||
) -> RequestCandidateFinalStatus {
|
||||
let has_success = candidates.iter().any(|candidate| {
|
||||
candidate.status == RequestCandidateStatus::Success
|
||||
|| matches!(candidate.status_code, Some(status_code) if (200..300).contains(&status_code))
|
||||
});
|
||||
if has_success {
|
||||
return RequestCandidateFinalStatus::Success;
|
||||
}
|
||||
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.status == RequestCandidateStatus::Streaming)
|
||||
{
|
||||
return RequestCandidateFinalStatus::Streaming;
|
||||
}
|
||||
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.status == RequestCandidateStatus::Pending)
|
||||
{
|
||||
return RequestCandidateFinalStatus::Pending;
|
||||
}
|
||||
|
||||
let has_cancelled = candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.status == RequestCandidateStatus::Cancelled);
|
||||
let has_failed = candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.status == RequestCandidateStatus::Failed);
|
||||
if has_cancelled && !has_failed {
|
||||
return RequestCandidateFinalStatus::Cancelled;
|
||||
}
|
||||
|
||||
RequestCandidateFinalStatus::Failed
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DecisionTraceCandidate {
|
||||
#[serde(flatten)]
|
||||
pub candidate: StoredRequestCandidate,
|
||||
pub provider_name: Option<String>,
|
||||
pub provider_website: Option<String>,
|
||||
pub provider_type: Option<String>,
|
||||
pub endpoint_api_format: Option<String>,
|
||||
pub endpoint_api_family: Option<String>,
|
||||
pub endpoint_kind: Option<String>,
|
||||
pub provider_key_name: Option<String>,
|
||||
pub provider_key_auth_type: Option<String>,
|
||||
pub provider_key_capabilities: Option<serde_json::Value>,
|
||||
pub provider_key_is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DecisionTrace {
|
||||
pub request_id: String,
|
||||
pub total_candidates: usize,
|
||||
pub final_status: RequestCandidateFinalStatus,
|
||||
pub total_latency_ms: u64,
|
||||
pub candidates: Vec<DecisionTraceCandidate>,
|
||||
}
|
||||
|
||||
pub fn build_decision_trace(
|
||||
trace: RequestCandidateTrace,
|
||||
providers: Vec<StoredProviderCatalogProvider>,
|
||||
endpoints: Vec<StoredProviderCatalogEndpoint>,
|
||||
keys: Vec<StoredProviderCatalogKey>,
|
||||
) -> DecisionTrace {
|
||||
let provider_map = providers
|
||||
.into_iter()
|
||||
.map(|item| (item.id.clone(), item))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let endpoint_map = endpoints
|
||||
.into_iter()
|
||||
.map(|item| (item.id.clone(), item))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let key_map = keys
|
||||
.into_iter()
|
||||
.map(|item| (item.id.clone(), item))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
DecisionTrace {
|
||||
request_id: trace.request_id,
|
||||
total_candidates: trace.total_candidates,
|
||||
final_status: trace.final_status,
|
||||
total_latency_ms: trace.total_latency_ms,
|
||||
candidates: trace
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(|candidate| {
|
||||
enrich_decision_trace_candidate(candidate, &provider_map, &endpoint_map, &key_map)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_decision_trace_candidate(
|
||||
candidate: StoredRequestCandidate,
|
||||
provider_map: &BTreeMap<String, StoredProviderCatalogProvider>,
|
||||
endpoint_map: &BTreeMap<String, StoredProviderCatalogEndpoint>,
|
||||
key_map: &BTreeMap<String, StoredProviderCatalogKey>,
|
||||
) -> DecisionTraceCandidate {
|
||||
let provider = candidate
|
||||
.provider_id
|
||||
.as_ref()
|
||||
.and_then(|provider_id| provider_map.get(provider_id));
|
||||
let endpoint = candidate
|
||||
.endpoint_id
|
||||
.as_ref()
|
||||
.and_then(|endpoint_id| endpoint_map.get(endpoint_id));
|
||||
let provider_key = candidate
|
||||
.key_id
|
||||
.as_ref()
|
||||
.and_then(|key_id| key_map.get(key_id));
|
||||
|
||||
DecisionTraceCandidate {
|
||||
provider_name: provider.map(|item| item.name.clone()),
|
||||
provider_website: provider.and_then(|item| item.website.clone()),
|
||||
provider_type: provider.map(|item| item.provider_type.clone()),
|
||||
endpoint_api_format: endpoint.map(|item| item.api_format.clone()),
|
||||
endpoint_api_family: endpoint.and_then(|item| item.api_family.clone()),
|
||||
endpoint_kind: endpoint.and_then(|item| item.endpoint_kind.clone()),
|
||||
provider_key_name: provider_key
|
||||
.map(|item| item.name.clone())
|
||||
.or_else(|| candidate.api_key_name.clone()),
|
||||
provider_key_auth_type: provider_key.map(|item| item.auth_type.clone()),
|
||||
provider_key_capabilities: provider_key.and_then(|item| item.capabilities.clone()),
|
||||
provider_key_is_active: provider_key.map(|item| item.is_active),
|
||||
candidate,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PublicHealthStatusCount {
|
||||
pub endpoint_id: String,
|
||||
pub status: RequestCandidateStatus,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PublicHealthTimelineBucket {
|
||||
pub endpoint_id: String,
|
||||
pub segment_idx: u32,
|
||||
pub total_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failed_count: u64,
|
||||
pub min_created_at_unix_secs: Option<u64>,
|
||||
pub max_created_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RequestCandidateReadRepository: Send + Sync {
|
||||
async fn list_by_request_id(
|
||||
&self,
|
||||
request_id: &str,
|
||||
) -> Result<Vec<StoredRequestCandidate>, crate::DataLayerError>;
|
||||
|
||||
async fn list_recent(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredRequestCandidate>, crate::DataLayerError>;
|
||||
|
||||
async fn list_by_provider_id(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredRequestCandidate>, crate::DataLayerError>;
|
||||
|
||||
async fn list_finalized_by_endpoint_ids_since(
|
||||
&self,
|
||||
endpoint_ids: &[String],
|
||||
since_unix_secs: u64,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredRequestCandidate>, crate::DataLayerError>;
|
||||
|
||||
async fn count_finalized_statuses_by_endpoint_ids_since(
|
||||
&self,
|
||||
endpoint_ids: &[String],
|
||||
since_unix_secs: u64,
|
||||
) -> Result<Vec<PublicHealthStatusCount>, crate::DataLayerError>;
|
||||
|
||||
async fn aggregate_finalized_timeline_by_endpoint_ids_since(
|
||||
&self,
|
||||
endpoint_ids: &[String],
|
||||
since_unix_secs: u64,
|
||||
until_unix_secs: u64,
|
||||
segments: u32,
|
||||
) -> Result<Vec<PublicHealthTimelineBucket>, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpsertRequestCandidateRecord {
|
||||
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 candidate_index: u32,
|
||||
pub retry_index: u32,
|
||||
pub provider_id: Option<String>,
|
||||
pub endpoint_id: Option<String>,
|
||||
pub key_id: Option<String>,
|
||||
pub status: RequestCandidateStatus,
|
||||
pub skip_reason: Option<String>,
|
||||
pub is_cached: Option<bool>,
|
||||
pub status_code: Option<u16>,
|
||||
pub error_type: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub concurrent_requests: Option<u32>,
|
||||
pub extra_data: Option<serde_json::Value>,
|
||||
pub required_capabilities: Option<serde_json::Value>,
|
||||
pub created_at_unix_secs: Option<u64>,
|
||||
pub started_at_unix_secs: Option<u64>,
|
||||
pub finished_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl UpsertRequestCandidateRecord {
|
||||
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
|
||||
if self.id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"request candidate upsert id cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.request_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"request candidate upsert request_id cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RequestCandidateWriteRepository: Send + Sync {
|
||||
async fn upsert(
|
||||
&self,
|
||||
candidate: UpsertRequestCandidateRecord,
|
||||
) -> Result<StoredRequestCandidate, crate::DataLayerError>;
|
||||
|
||||
async fn delete_created_before(
|
||||
&self,
|
||||
created_before_unix_secs: u64,
|
||||
limit: usize,
|
||||
) -> Result<usize, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
pub trait RequestCandidateRepository:
|
||||
RequestCandidateReadRepository + RequestCandidateWriteRepository + Send + Sync
|
||||
{
|
||||
}
|
||||
|
||||
impl<T> RequestCandidateRepository for T where
|
||||
T: RequestCandidateReadRepository + RequestCandidateWriteRepository + Send + Sync
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_decision_trace, derive_request_candidate_final_status, DecisionTrace,
|
||||
DecisionTraceCandidate, RequestCandidateFinalStatus, RequestCandidateStatus,
|
||||
RequestCandidateTrace, StoredRequestCandidate, UpsertRequestCandidateRecord,
|
||||
};
|
||||
use crate::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parses_status_from_database_text() {
|
||||
assert_eq!(
|
||||
RequestCandidateStatus::from_database("streaming").expect("status should parse"),
|
||||
RequestCandidateStatus::Streaming
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_database_status() {
|
||||
assert!(RequestCandidateStatus::from_database("mystery").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_candidate_index() {
|
||||
assert!(StoredRequestCandidate::new(
|
||||
"cand-1".to_string(),
|
||||
"req-1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
-1,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
RequestCandidateStatus::Pending,
|
||||
None,
|
||||
false,
|
||||
Some(200),
|
||||
None,
|
||||
None,
|
||||
Some(10),
|
||||
Some(1),
|
||||
None,
|
||||
None,
|
||||
100,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
fn sample_candidate(
|
||||
id: &str,
|
||||
request_id: &str,
|
||||
candidate_index: i32,
|
||||
status: RequestCandidateStatus,
|
||||
started_at_unix_secs: Option<i64>,
|
||||
latency_ms: Option<i32>,
|
||||
status_code: Option<i32>,
|
||||
) -> StoredRequestCandidate {
|
||||
StoredRequestCandidate::new(
|
||||
id.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()),
|
||||
candidate_index,
|
||||
0,
|
||||
Some("provider-1".to_string()),
|
||||
Some("endpoint-1".to_string()),
|
||||
Some("provider-key-1".to_string()),
|
||||
status,
|
||||
None,
|
||||
false,
|
||||
status_code,
|
||||
None,
|
||||
None,
|
||||
latency_ms,
|
||||
Some(1),
|
||||
None,
|
||||
None,
|
||||
100 + i64::from(candidate_index),
|
||||
started_at_unix_secs,
|
||||
started_at_unix_secs.map(|value| value + 1),
|
||||
)
|
||||
.expect("candidate should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_request_candidate_final_status_preferring_success() {
|
||||
let candidates = vec![sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Success,
|
||||
Some(100),
|
||||
Some(25),
|
||||
Some(200),
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
derive_request_candidate_final_status(&candidates),
|
||||
RequestCandidateFinalStatus::Success
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_candidate_trace_filters_attempted_rows() {
|
||||
let trace = RequestCandidateTrace::from_candidates(
|
||||
"req-1",
|
||||
vec![
|
||||
sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Pending,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
sample_candidate(
|
||||
"cand-2",
|
||||
"req-1",
|
||||
1,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(33),
|
||||
Some(502),
|
||||
),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.expect("trace should exist");
|
||||
|
||||
assert_eq!(trace.total_candidates, 1);
|
||||
assert_eq!(trace.candidates[0].id, "cand-2");
|
||||
assert_eq!(trace.final_status, RequestCandidateFinalStatus::Failed);
|
||||
assert_eq!(trace.total_latency_ms, 33);
|
||||
}
|
||||
|
||||
fn sample_provider() -> StoredProviderCatalogProvider {
|
||||
StoredProviderCatalogProvider::new(
|
||||
"provider-1".to_string(),
|
||||
"OpenAI".to_string(),
|
||||
Some("https://openai.com".to_string()),
|
||||
"custom".to_string(),
|
||||
)
|
||||
.expect("provider should build")
|
||||
}
|
||||
|
||||
fn sample_endpoint() -> StoredProviderCatalogEndpoint {
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"openai:chat".to_string(),
|
||||
Some("openai".to_string()),
|
||||
Some("chat".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
}
|
||||
|
||||
fn sample_key() -> StoredProviderCatalogKey {
|
||||
StoredProviderCatalogKey::new(
|
||||
"provider-key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"prod-key".to_string(),
|
||||
"api_key".to_string(),
|
||||
Some(serde_json::json!({"cache_1h": true})),
|
||||
true,
|
||||
)
|
||||
.expect("key should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_decision_trace_enriches_candidate_with_provider_catalog_metadata() {
|
||||
let trace = RequestCandidateTrace::from_candidates(
|
||||
"req-1",
|
||||
vec![sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(37),
|
||||
Some(502),
|
||||
)],
|
||||
true,
|
||||
)
|
||||
.expect("trace should exist");
|
||||
|
||||
assert_eq!(
|
||||
build_decision_trace(
|
||||
trace,
|
||||
vec![sample_provider()],
|
||||
vec![sample_endpoint()],
|
||||
vec![sample_key()],
|
||||
),
|
||||
DecisionTrace {
|
||||
request_id: "req-1".to_string(),
|
||||
total_candidates: 1,
|
||||
final_status: RequestCandidateFinalStatus::Failed,
|
||||
total_latency_ms: 37,
|
||||
candidates: vec![DecisionTraceCandidate {
|
||||
candidate: sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(37),
|
||||
Some(502),
|
||||
),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_website: Some("https://openai.com".to_string()),
|
||||
provider_type: Some("custom".to_string()),
|
||||
endpoint_api_format: Some("openai:chat".to_string()),
|
||||
endpoint_api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
provider_key_name: Some("prod-key".to_string()),
|
||||
provider_key_auth_type: Some("api_key".to_string()),
|
||||
provider_key_capabilities: Some(serde_json::json!({"cache_1h": true})),
|
||||
provider_key_is_active: Some(true),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_created_at() {
|
||||
assert!(StoredRequestCandidate::new(
|
||||
"cand-1".to_string(),
|
||||
"req-1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
RequestCandidateStatus::Pending,
|
||||
None,
|
||||
false,
|
||||
Some(200),
|
||||
None,
|
||||
None,
|
||||
Some(10),
|
||||
Some(1),
|
||||
None,
|
||||
None,
|
||||
-1,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_without_started_at_is_not_attempted() {
|
||||
assert!(!RequestCandidateStatus::Pending.is_attempted(None));
|
||||
assert!(RequestCandidateStatus::Pending.is_attempted(Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_upsert_payload() {
|
||||
assert!(UpsertRequestCandidateRecord {
|
||||
id: "".to_string(),
|
||||
request_id: "".to_string(),
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
candidate_index: 0,
|
||||
retry_index: 0,
|
||||
provider_id: None,
|
||||
endpoint_id: None,
|
||||
key_id: None,
|
||||
status: RequestCandidateStatus::Available,
|
||||
skip_reason: None,
|
||||
is_cached: None,
|
||||
status_code: None,
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms: None,
|
||||
concurrent_requests: None,
|
||||
extra_data: None,
|
||||
required_capabilities: None,
|
||||
created_at_unix_secs: None,
|
||||
started_at_unix_secs: None,
|
||||
finished_at_unix_secs: None,
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user