refactor: 移除独立 hub/proxy/executor/gateway crate,统一为 gateway tunnel 架构

- 删除 aether-hub、aether-proxy 独立项目及其 Dockerfile/配置
- 删除 crates/aether-executor 和 crates/aether-gateway 全部模块
- 新增 apps/ 目录作为应用入口
- 将 hub 概念重构为 gateway tunnel transport
- 将 executor 重构为 execution runtime
- 新增 tunnel.rs 合约定义和 testkit tunnel/execution_runtime 模块
- 更新 Python 服务层和测试适配新架构命名
This commit is contained in:
fawney19
2026-04-03 14:59:58 +08:00
parent ddf18fed9a
commit 8f26e1a31f
983 changed files with 103097 additions and 105836 deletions
@@ -0,0 +1,506 @@
use super::*;
fn default_admin_billing_collector_value_type() -> String {
"float".to_string()
}
#[derive(Debug, Deserialize)]
struct AdminBillingCollectorUpsertRequest {
api_format: String,
task_type: String,
dimension_name: String,
source_type: String,
#[serde(default)]
source_path: Option<String>,
#[serde(default = "default_admin_billing_collector_value_type")]
value_type: String,
#[serde(default)]
transform_expression: Option<String>,
#[serde(default)]
default_value: Option<String>,
#[serde(default)]
priority: i32,
#[serde(default = "default_admin_billing_true")]
is_enabled: bool,
}
fn build_admin_billing_collector_payload_from_record(
record: &crate::gateway::AdminBillingCollectorRecord,
) -> serde_json::Value {
json!({
"id": record.id,
"api_format": record.api_format,
"task_type": record.task_type,
"dimension_name": record.dimension_name,
"source_type": record.source_type,
"source_path": record.source_path,
"value_type": record.value_type,
"transform_expression": record.transform_expression,
"default_value": record.default_value,
"priority": record.priority,
"is_enabled": record.is_enabled,
"created_at": unix_secs_to_rfc3339(record.created_at_unix_secs),
"updated_at": unix_secs_to_rfc3339(record.updated_at_unix_secs),
})
}
fn admin_billing_collector_id_from_path(request_path: &str) -> Option<String> {
let value = request_path
.strip_prefix("/api/admin/billing/collectors/")?
.trim()
.trim_matches('/')
.to_string();
if value.is_empty() || value.contains('/') {
None
} else {
Some(value)
}
}
fn admin_billing_collector_payload(
row: &sqlx::postgres::PgRow,
) -> Result<serde_json::Value, GatewayError> {
Ok(json!({
"id": row.try_get::<String, _>("id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"api_format": row.try_get::<String, _>("api_format").map_err(|err| GatewayError::Internal(err.to_string()))?,
"task_type": row.try_get::<String, _>("task_type").map_err(|err| GatewayError::Internal(err.to_string()))?,
"dimension_name": row.try_get::<String, _>("dimension_name").map_err(|err| GatewayError::Internal(err.to_string()))?,
"source_type": row.try_get::<String, _>("source_type").map_err(|err| GatewayError::Internal(err.to_string()))?,
"source_path": row.try_get::<Option<String>, _>("source_path").map_err(|err| GatewayError::Internal(err.to_string()))?,
"value_type": row.try_get::<String, _>("value_type").map_err(|err| GatewayError::Internal(err.to_string()))?,
"transform_expression": row.try_get::<Option<String>, _>("transform_expression").map_err(|err| GatewayError::Internal(err.to_string()))?,
"default_value": row.try_get::<Option<String>, _>("default_value").map_err(|err| GatewayError::Internal(err.to_string()))?,
"priority": row.try_get::<i32, _>("priority").map_err(|err| GatewayError::Internal(err.to_string()))?,
"is_enabled": row.try_get::<bool, _>("is_enabled").map_err(|err| GatewayError::Internal(err.to_string()))?,
"created_at": admin_billing_optional_epoch_value(row, "created_at_unix_secs")?,
"updated_at": admin_billing_optional_epoch_value(row, "updated_at_unix_secs")?,
}))
}
async fn build_admin_list_dimension_collectors_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let query = request_context.request_query_string.as_deref();
let page = match admin_billing_parse_page(query) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_billing_bad_request_response(detail)),
};
let page_size = match admin_billing_parse_page_size(query) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_billing_bad_request_response(detail)),
};
let api_format = admin_billing_optional_filter(query, "api_format");
let task_type = admin_billing_optional_filter(query, "task_type");
let dimension_name = admin_billing_optional_filter(query, "dimension_name");
let is_enabled = match admin_billing_optional_bool_filter(query, "is_enabled") {
Ok(value) => value,
Err(detail) => return Ok(build_admin_billing_bad_request_response(detail)),
};
if let Some((items, total)) = state
.list_admin_billing_collectors(
api_format.as_deref(),
task_type.as_deref(),
dimension_name.as_deref(),
is_enabled,
page,
page_size,
)
.await?
{
return Ok(Json(json!({
"items": items
.iter()
.map(build_admin_billing_collector_payload_from_record)
.collect::<Vec<_>>(),
"total": total,
"page": page,
"page_size": page_size,
"pages": admin_billing_pages(total, page_size),
}))
.into_response());
}
let mut total = 0_u64;
let mut items = Vec::new();
if let Some(pool) = state.postgres_pool() {
let count_row = sqlx::query(
r#"
SELECT COUNT(*) AS total
FROM dimension_collectors
WHERE ($1::TEXT IS NULL OR api_format = $1)
AND ($2::TEXT IS NULL OR task_type = $2)
AND ($3::TEXT IS NULL OR dimension_name = $3)
AND ($4::BOOL IS NULL OR is_enabled = $4)
"#,
)
.bind(api_format.as_deref())
.bind(task_type.as_deref())
.bind(dimension_name.as_deref())
.bind(is_enabled)
.fetch_one(&pool)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
total = count_row
.try_get::<i64, _>("total")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.max(0) as u64;
let offset = u64::from(page.saturating_sub(1) * page_size);
let rows = sqlx::query(
r#"
SELECT
id,
api_format,
task_type,
dimension_name,
source_type,
source_path,
value_type,
transform_expression,
default_value,
priority,
is_enabled,
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
FROM dimension_collectors
WHERE ($1::TEXT IS NULL OR api_format = $1)
AND ($2::TEXT IS NULL OR task_type = $2)
AND ($3::TEXT IS NULL OR dimension_name = $3)
AND ($4::BOOL IS NULL OR is_enabled = $4)
ORDER BY updated_at DESC, priority DESC, id ASC
OFFSET $5
LIMIT $6
"#,
)
.bind(api_format.as_deref())
.bind(task_type.as_deref())
.bind(dimension_name.as_deref())
.bind(is_enabled)
.bind(i64::try_from(offset).map_err(|err| GatewayError::Internal(err.to_string()))?)
.bind(i64::from(page_size))
.fetch_all(&pool)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
items = rows
.iter()
.map(admin_billing_collector_payload)
.collect::<Result<Vec<_>, GatewayError>>()?;
}
Ok(Json(json!({
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": admin_billing_pages(total, page_size),
}))
.into_response())
}
async fn build_admin_get_dimension_collector_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(collector_id) = admin_billing_collector_id_from_path(&request_context.request_path)
else {
return Ok(build_admin_billing_bad_request_response(
"缺少 collector_id",
));
};
if let Some(record) = state.read_admin_billing_collector(&collector_id).await? {
return Ok(
Json(build_admin_billing_collector_payload_from_record(&record)).into_response(),
);
}
let Some(pool) = state.postgres_pool() else {
return Ok(build_admin_billing_not_found_response(
"Dimension collector not found",
));
};
let row = sqlx::query(
r#"
SELECT
id,
api_format,
task_type,
dimension_name,
source_type,
source_path,
value_type,
transform_expression,
default_value,
priority,
is_enabled,
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
FROM dimension_collectors
WHERE id = $1
"#,
)
.bind(&collector_id)
.fetch_optional(&pool)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
match row {
Some(row) => Ok(Json(admin_billing_collector_payload(&row)?).into_response()),
None => Ok(build_admin_billing_not_found_response(
"Dimension collector not found",
)),
}
}
async fn parse_admin_billing_collector_request(
state: &AppState,
request_body: Option<&axum::body::Bytes>,
existing_id: Option<&str>,
) -> Result<crate::gateway::AdminBillingCollectorWriteInput, Response<Body>> {
let Some(request_body) = request_body else {
return Err(build_admin_billing_bad_request_response("请求体不能为空"));
};
let request = match serde_json::from_slice::<AdminBillingCollectorUpsertRequest>(request_body) {
Ok(value) => value,
Err(err) => {
return Err(build_admin_billing_bad_request_response(format!(
"Invalid request body: {err}"
)))
}
};
let api_format =
match normalize_admin_billing_required_text(&request.api_format, "api_format", 50) {
Ok(value) => value.to_ascii_uppercase(),
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
let task_type = match normalize_admin_billing_required_text(&request.task_type, "task_type", 20)
{
Ok(value) => value.to_ascii_lowercase(),
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
let dimension_name =
match normalize_admin_billing_required_text(&request.dimension_name, "dimension_name", 100)
{
Ok(value) => value,
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
let source_type = request.source_type.trim().to_ascii_lowercase();
if !matches!(
source_type.as_str(),
"request" | "response" | "metadata" | "computed"
) {
return Err(build_admin_billing_bad_request_response(
"source_type must be one of request, response, metadata, computed",
));
}
let value_type = request.value_type.trim().to_ascii_lowercase();
if !matches!(value_type.as_str(), "float" | "int" | "string") {
return Err(build_admin_billing_bad_request_response(
"value_type must be one of float, int, string",
));
}
let source_path = match normalize_admin_billing_optional_text(request.source_path, 200) {
Ok(value) => value,
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
let transform_expression =
match normalize_admin_billing_optional_text(request.transform_expression, 4096) {
Ok(value) => value,
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
let default_value = match normalize_admin_billing_optional_text(request.default_value, 100) {
Ok(value) => value,
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
if source_type == "computed" {
if source_path.is_some() {
return Err(build_admin_billing_bad_request_response(
"computed collector must have source_path=null",
));
}
if transform_expression.is_none() {
return Err(build_admin_billing_bad_request_response(
"computed collector must have transform_expression",
));
}
} else if source_path.is_none() {
return Err(build_admin_billing_bad_request_response(
"non-computed collector must have source_path",
));
}
if let Some(transform_expression) = transform_expression.as_deref() {
if let Err(detail) = admin_billing_validate_safe_expression(transform_expression) {
return Err(build_admin_billing_bad_request_response(format!(
"Invalid transform_expression: {detail}"
)));
}
}
if default_value.is_some() && request.is_enabled {
match state
.admin_billing_enabled_default_value_exists(
&api_format,
&task_type,
&dimension_name,
existing_id,
)
.await
{
Ok(true) => {
return Err(build_admin_billing_bad_request_response(
"default_value already exists for this (api_format, task_type, dimension_name)",
))
}
Ok(false) => {}
Err(err) => {
let detail = match err {
GatewayError::Internal(message) => message,
other => format!("{other:?}"),
};
return Err((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": detail })),
)
.into_response());
}
}
}
Ok(crate::gateway::AdminBillingCollectorWriteInput {
api_format,
task_type,
dimension_name,
source_type,
source_path,
value_type,
transform_expression,
default_value,
priority: request.priority,
is_enabled: request.is_enabled,
})
}
async fn build_admin_create_dimension_collector_response(
state: &AppState,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
let input = match parse_admin_billing_collector_request(state, request_body, None).await {
Ok(value) => value,
Err(response) => return Ok(response),
};
match state.create_admin_billing_collector(&input).await? {
crate::gateway::LocalMutationOutcome::Applied(record) => {
Ok(Json(build_admin_billing_collector_payload_from_record(&record)).into_response())
}
crate::gateway::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::gateway::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Dimension collector not found"),
),
crate::gateway::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法创建维度采集器"),
),
}
}
async fn build_admin_update_dimension_collector_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
let Some(collector_id) = admin_billing_collector_id_from_path(&request_context.request_path)
else {
return Ok(build_admin_billing_bad_request_response(
"缺少 collector_id",
));
};
let input =
match parse_admin_billing_collector_request(state, request_body, Some(&collector_id)).await
{
Ok(value) => value,
Err(response) => return Ok(response),
};
match state
.update_admin_billing_collector(&collector_id, &input)
.await?
{
crate::gateway::LocalMutationOutcome::Applied(record) => {
Ok(Json(build_admin_billing_collector_payload_from_record(&record)).into_response())
}
crate::gateway::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Dimension collector not found"),
),
crate::gateway::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::gateway::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法更新维度采集器"),
),
}
}
pub(super) async fn maybe_build_local_admin_billing_collectors_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
return Ok(None);
};
let path = request_context.request_path.as_str();
match decision.route_kind.as_deref() {
Some("list_collectors")
if request_context.request_method == http::Method::GET
&& matches!(
path,
"/api/admin/billing/collectors" | "/api/admin/billing/collectors/"
) =>
{
Ok(Some(
build_admin_list_dimension_collectors_response(state, request_context).await?,
))
}
Some("get_collector")
if request_context.request_method == http::Method::GET
&& path.starts_with("/api/admin/billing/collectors/") =>
{
Ok(Some(
build_admin_get_dimension_collector_response(state, request_context).await?,
))
}
Some("create_collector")
if request_context.request_method == http::Method::POST
&& matches!(
path,
"/api/admin/billing/collectors" | "/api/admin/billing/collectors/"
) =>
{
Ok(Some(
build_admin_create_dimension_collector_response(state, request_body).await?,
))
}
Some("update_collector")
if request_context.request_method == http::Method::PUT
&& path.starts_with("/api/admin/billing/collectors/") =>
{
Ok(Some(
build_admin_update_dimension_collector_response(
state,
request_context,
request_body,
)
.await?,
))
}
_ => Ok(None),
}
}
@@ -0,0 +1,350 @@
use super::*;
fn default_admin_billing_preset_mode() -> String {
"merge".to_string()
}
#[derive(Debug, Deserialize)]
struct AdminBillingPresetApplyRequest {
preset: String,
#[serde(default = "default_admin_billing_preset_mode")]
mode: String,
}
fn build_admin_billing_presets_payload() -> serde_json::Value {
json!({
"items": [
{
"name": "aether-core",
"version": "1.0",
"description": "Aether built-in dimension collectors for common api_formats/task_types.",
"collector_count": build_admin_billing_aether_core_collectors().len(),
}
],
})
}
fn build_admin_billing_aether_core_collectors(
) -> Vec<crate::gateway::AdminBillingCollectorWriteInput> {
vec![
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "OPENAI:CHAT".to_string(),
task_type: "chat".to_string(),
dimension_name: "input_tokens".to_string(),
source_type: "response".to_string(),
source_path: Some("usage.prompt_tokens".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "OPENAI:CHAT".to_string(),
task_type: "chat".to_string(),
dimension_name: "output_tokens".to_string(),
source_type: "response".to_string(),
source_path: Some("usage.completion_tokens".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "CLAUDE:CHAT".to_string(),
task_type: "chat".to_string(),
dimension_name: "input_tokens".to_string(),
source_type: "response".to_string(),
source_path: Some("usage.input_tokens".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "CLAUDE:CHAT".to_string(),
task_type: "chat".to_string(),
dimension_name: "output_tokens".to_string(),
source_type: "response".to_string(),
source_path: Some("usage.output_tokens".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "GEMINI:CHAT".to_string(),
task_type: "chat".to_string(),
dimension_name: "input_tokens".to_string(),
source_type: "response".to_string(),
source_path: Some("usageMetadata.promptTokenCount".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "GEMINI:CHAT".to_string(),
task_type: "chat".to_string(),
dimension_name: "output_tokens".to_string(),
source_type: "response".to_string(),
source_path: Some("usageMetadata.candidatesTokenCount".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "OPENAI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_resolution_key".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.size".to_string()),
value_type: "string".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "OPENAI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_resolution_key".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.resolution".to_string()),
value_type: "string".to_string(),
transform_expression: None,
default_value: None,
priority: 0,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "OPENAI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_size_bytes".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.video_size_bytes".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 0,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "OPENAI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_duration_seconds".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.video_duration_seconds".to_string()),
value_type: "float".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "OPENAI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_duration_seconds".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.duration_seconds".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 0,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "GEMINI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_resolution_key".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.size".to_string()),
value_type: "string".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "GEMINI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_resolution_key".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.resolution".to_string()),
value_type: "string".to_string(),
transform_expression: None,
default_value: None,
priority: 0,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "GEMINI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_size_bytes".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.video_size_bytes".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 0,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "GEMINI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_duration_seconds".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.video_duration_seconds".to_string()),
value_type: "float".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
},
crate::gateway::AdminBillingCollectorWriteInput {
api_format: "GEMINI:CHAT".to_string(),
task_type: "video".to_string(),
dimension_name: "video_duration_seconds".to_string(),
source_type: "metadata".to_string(),
source_path: Some("task.duration_seconds".to_string()),
value_type: "int".to_string(),
transform_expression: None,
default_value: None,
priority: 0,
is_enabled: true,
},
]
}
fn resolve_admin_billing_preset_collectors(
preset: &str,
) -> Option<(
&'static str,
Vec<crate::gateway::AdminBillingCollectorWriteInput>,
)> {
let normalized = preset.trim().to_ascii_lowercase();
match normalized.as_str() {
"aether-core" | "default" => {
Some(("aether-core", build_admin_billing_aether_core_collectors()))
}
_ => None,
}
}
fn parse_admin_billing_preset_apply_request(
request_body: Option<&axum::body::Bytes>,
) -> Result<(String, String), Response<Body>> {
let Some(request_body) = request_body else {
return Err(build_admin_billing_bad_request_response("请求体不能为空"));
};
let request = match serde_json::from_slice::<AdminBillingPresetApplyRequest>(request_body) {
Ok(value) => value,
Err(err) => {
return Err(build_admin_billing_bad_request_response(format!(
"Invalid request body: {err}"
)))
}
};
let preset = match normalize_admin_billing_required_text(&request.preset, "preset", 100) {
Ok(value) => value,
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
let mode = request.mode.trim().to_ascii_lowercase();
if !matches!(mode.as_str(), "merge" | "overwrite") {
return Err(build_admin_billing_bad_request_response(
"mode must be one of merge, overwrite",
));
}
Ok((preset, mode))
}
async fn build_admin_apply_billing_preset_response(
state: &AppState,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
let (preset, mode) = match parse_admin_billing_preset_apply_request(request_body) {
Ok(value) => value,
Err(response) => return Ok(response),
};
let Some((resolved_preset, collectors)) = resolve_admin_billing_preset_collectors(&preset)
else {
let payload = json!({
"ok": false,
"preset": preset,
"mode": mode,
"created": 0,
"updated": 0,
"skipped": 0,
"errors": ["Unknown preset: available presets are aether-core"],
});
return Ok(Json(payload).into_response());
};
match state
.apply_admin_billing_preset(resolved_preset, &mode, &collectors)
.await?
{
crate::gateway::LocalMutationOutcome::Applied(result) => Ok(Json(json!({
"ok": result.errors.is_empty(),
"preset": result.preset,
"mode": result.mode,
"created": result.created,
"updated": result.updated,
"skipped": result.skipped,
"errors": result.errors,
}))
.into_response()),
crate::gateway::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法应用计费预设"),
),
crate::gateway::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::gateway::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Billing preset not found"),
),
}
}
pub(super) async fn maybe_build_local_admin_billing_presets_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
return Ok(None);
};
let path = request_context.request_path.as_str();
match decision.route_kind.as_deref() {
Some("list_presets")
if request_context.request_method == http::Method::GET
&& matches!(
path,
"/api/admin/billing/presets" | "/api/admin/billing/presets/"
) =>
{
Ok(Some(
Json(build_admin_billing_presets_payload()).into_response(),
))
}
Some("apply_preset")
if request_context.request_method == http::Method::POST
&& matches!(
path,
"/api/admin/billing/presets/apply" | "/api/admin/billing/presets/apply/"
) =>
{
Ok(Some(
build_admin_apply_billing_preset_response(state, request_body).await?,
))
}
_ => Ok(None),
}
}
@@ -0,0 +1,419 @@
use super::*;
fn default_admin_billing_rule_task_type() -> String {
"chat".to_string()
}
#[derive(Debug, Deserialize)]
struct AdminBillingRuleUpsertRequest {
name: String,
#[serde(default = "default_admin_billing_rule_task_type")]
task_type: String,
#[serde(default)]
global_model_id: Option<String>,
#[serde(default)]
model_id: Option<String>,
expression: String,
#[serde(default = "default_admin_billing_json_object")]
variables: serde_json::Value,
#[serde(default = "default_admin_billing_json_object")]
dimension_mappings: serde_json::Value,
#[serde(default = "default_admin_billing_true")]
is_enabled: bool,
}
fn build_admin_billing_rule_payload_from_record(
record: &crate::gateway::AdminBillingRuleRecord,
) -> serde_json::Value {
json!({
"id": record.id,
"name": record.name,
"task_type": record.task_type,
"global_model_id": record.global_model_id,
"model_id": record.model_id,
"expression": record.expression,
"variables": record.variables,
"dimension_mappings": record.dimension_mappings,
"is_enabled": record.is_enabled,
"created_at": unix_secs_to_rfc3339(record.created_at_unix_secs),
"updated_at": unix_secs_to_rfc3339(record.updated_at_unix_secs),
})
}
fn admin_billing_rule_id_from_path(request_path: &str) -> Option<String> {
let value = request_path
.strip_prefix("/api/admin/billing/rules/")?
.trim()
.trim_matches('/')
.to_string();
if value.is_empty() || value.contains('/') {
None
} else {
Some(value)
}
}
fn parse_admin_billing_rule_request(
request_body: Option<&axum::body::Bytes>,
) -> Result<crate::gateway::AdminBillingRuleWriteInput, Response<Body>> {
let Some(request_body) = request_body else {
return Err(build_admin_billing_bad_request_response("请求体不能为空"));
};
let request = match serde_json::from_slice::<AdminBillingRuleUpsertRequest>(request_body) {
Ok(value) => value,
Err(err) => {
return Err(build_admin_billing_bad_request_response(format!(
"Invalid request body: {err}"
)))
}
};
let name = match normalize_admin_billing_required_text(&request.name, "name", 100) {
Ok(value) => value,
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
let task_type = request.task_type.trim().to_ascii_lowercase();
if !matches!(task_type.as_str(), "chat" | "video" | "image" | "audio") {
return Err(build_admin_billing_bad_request_response(
"task_type must be one of chat, video, image, audio",
));
}
let global_model_id = match normalize_admin_billing_optional_text(request.global_model_id, 64) {
Ok(value) => value,
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
let model_id = match normalize_admin_billing_optional_text(request.model_id, 64) {
Ok(value) => value,
Err(detail) => return Err(build_admin_billing_bad_request_response(detail)),
};
if global_model_id.is_some() == model_id.is_some() {
return Err(build_admin_billing_bad_request_response(
"Exactly one of global_model_id or model_id must be provided",
));
}
let expression = request.expression.trim().to_string();
if let Err(detail) = admin_billing_validate_safe_expression(&expression) {
return Err(build_admin_billing_bad_request_response(format!(
"Invalid expression: {detail}"
)));
}
let Some(variables) = request.variables.as_object() else {
return Err(build_admin_billing_bad_request_response(
"variables must be a JSON object",
));
};
for (key, value) in variables {
if key.trim().is_empty() {
return Err(build_admin_billing_bad_request_response(
"variables keys must be non-empty strings",
));
}
if value.is_boolean() || !value.is_number() {
return Err(build_admin_billing_bad_request_response(format!(
"variables['{key}'] must be a number"
)));
}
}
let Some(dimension_mappings) = request.dimension_mappings.as_object() else {
return Err(build_admin_billing_bad_request_response(
"dimension_mappings must be a JSON object",
));
};
for (key, value) in dimension_mappings {
if key.trim().is_empty() {
return Err(build_admin_billing_bad_request_response(
"dimension_mappings keys must be non-empty strings",
));
}
let Some(mapping) = value.as_object() else {
return Err(build_admin_billing_bad_request_response(format!(
"dimension_mappings['{key}'] must be an object"
)));
};
if !mapping.contains_key("source") {
return Err(build_admin_billing_bad_request_response(format!(
"dimension_mappings['{key}'].source is required"
)));
}
}
Ok(crate::gateway::AdminBillingRuleWriteInput {
name,
task_type,
global_model_id,
model_id,
expression,
variables: serde_json::Value::Object(variables.clone()),
dimension_mappings: serde_json::Value::Object(dimension_mappings.clone()),
is_enabled: request.is_enabled,
})
}
fn admin_billing_rule_payload(
row: &sqlx::postgres::PgRow,
) -> Result<serde_json::Value, GatewayError> {
Ok(json!({
"id": row.try_get::<String, _>("id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"name": row.try_get::<String, _>("name").map_err(|err| GatewayError::Internal(err.to_string()))?,
"task_type": row.try_get::<String, _>("task_type").map_err(|err| GatewayError::Internal(err.to_string()))?,
"global_model_id": row.try_get::<Option<String>, _>("global_model_id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"model_id": row.try_get::<Option<String>, _>("model_id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"expression": row.try_get::<String, _>("expression").map_err(|err| GatewayError::Internal(err.to_string()))?,
"variables": row.try_get::<Option<serde_json::Value>, _>("variables").map_err(|err| GatewayError::Internal(err.to_string()))?.unwrap_or_else(|| json!({})),
"dimension_mappings": row.try_get::<Option<serde_json::Value>, _>("dimension_mappings").map_err(|err| GatewayError::Internal(err.to_string()))?.unwrap_or_else(|| json!({})),
"is_enabled": row.try_get::<bool, _>("is_enabled").map_err(|err| GatewayError::Internal(err.to_string()))?,
"created_at": admin_billing_optional_epoch_value(row, "created_at_unix_secs")?,
"updated_at": admin_billing_optional_epoch_value(row, "updated_at_unix_secs")?,
}))
}
async fn build_admin_list_billing_rules_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let query = request_context.request_query_string.as_deref();
let page = match admin_billing_parse_page(query) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_billing_bad_request_response(detail)),
};
let page_size = match admin_billing_parse_page_size(query) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_billing_bad_request_response(detail)),
};
let task_type = admin_billing_optional_filter(query, "task_type");
let is_enabled = match admin_billing_optional_bool_filter(query, "is_enabled") {
Ok(value) => value,
Err(detail) => return Ok(build_admin_billing_bad_request_response(detail)),
};
let mut total = 0_u64;
let mut items = Vec::new();
if let Some((records, record_total)) = state
.list_admin_billing_rules(task_type.as_deref(), is_enabled, page, page_size)
.await?
{
total = record_total;
items = records
.iter()
.map(build_admin_billing_rule_payload_from_record)
.collect::<Vec<_>>();
} else if let Some(pool) = state.postgres_pool() {
let count_row = sqlx::query(
r#"
SELECT COUNT(*) AS total
FROM billing_rules
WHERE ($1::TEXT IS NULL OR task_type = $1)
AND ($2::BOOL IS NULL OR is_enabled = $2)
"#,
)
.bind(task_type.as_deref())
.bind(is_enabled)
.fetch_one(&pool)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
total = count_row
.try_get::<i64, _>("total")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.max(0) as u64;
let offset = u64::from(page.saturating_sub(1) * page_size);
let rows = sqlx::query(
r#"
SELECT
id,
name,
task_type,
global_model_id,
model_id,
expression,
variables,
dimension_mappings,
is_enabled,
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
FROM billing_rules
WHERE ($1::TEXT IS NULL OR task_type = $1)
AND ($2::BOOL IS NULL OR is_enabled = $2)
ORDER BY updated_at DESC
OFFSET $3
LIMIT $4
"#,
)
.bind(task_type.as_deref())
.bind(is_enabled)
.bind(i64::try_from(offset).map_err(|err| GatewayError::Internal(err.to_string()))?)
.bind(i64::from(page_size))
.fetch_all(&pool)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
items = rows
.iter()
.map(admin_billing_rule_payload)
.collect::<Result<Vec<_>, GatewayError>>()?;
}
Ok(Json(json!({
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": admin_billing_pages(total, page_size),
}))
.into_response())
}
async fn build_admin_get_billing_rule_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(rule_id) = admin_billing_rule_id_from_path(&request_context.request_path) else {
return Ok(build_admin_billing_bad_request_response("缺少 rule_id"));
};
if let Some(record) = state.read_admin_billing_rule(&rule_id).await? {
return Ok(Json(build_admin_billing_rule_payload_from_record(&record)).into_response());
}
let Some(pool) = state.postgres_pool() else {
return Ok(build_admin_billing_not_found_response(
"Billing rule not found",
));
};
let row = sqlx::query(
r#"
SELECT
id,
name,
task_type,
global_model_id,
model_id,
expression,
variables,
dimension_mappings,
is_enabled,
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
FROM billing_rules
WHERE id = $1
"#,
)
.bind(&rule_id)
.fetch_optional(&pool)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
match row {
Some(row) => Ok(Json(admin_billing_rule_payload(&row)?).into_response()),
None => Ok(build_admin_billing_not_found_response(
"Billing rule not found",
)),
}
}
async fn build_admin_create_billing_rule_response(
state: &AppState,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
let input = match parse_admin_billing_rule_request(request_body) {
Ok(value) => value,
Err(response) => return Ok(response),
};
match state.create_admin_billing_rule(&input).await? {
crate::gateway::LocalMutationOutcome::Applied(record) => {
Ok(Json(build_admin_billing_rule_payload_from_record(&record)).into_response())
}
crate::gateway::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::gateway::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Billing rule not found"),
),
crate::gateway::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法创建计费规则"),
),
}
}
async fn build_admin_update_billing_rule_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
let Some(rule_id) = admin_billing_rule_id_from_path(&request_context.request_path) else {
return Ok(build_admin_billing_bad_request_response("缺少 rule_id"));
};
let input = match parse_admin_billing_rule_request(request_body) {
Ok(value) => value,
Err(response) => return Ok(response),
};
match state.update_admin_billing_rule(&rule_id, &input).await? {
crate::gateway::LocalMutationOutcome::Applied(record) => {
Ok(Json(build_admin_billing_rule_payload_from_record(&record)).into_response())
}
crate::gateway::LocalMutationOutcome::NotFound => Ok(
build_admin_billing_not_found_response("Billing rule not found"),
),
crate::gateway::LocalMutationOutcome::Invalid(detail) => {
Ok(build_admin_billing_bad_request_response(detail))
}
crate::gateway::LocalMutationOutcome::Unavailable => Ok(
build_admin_billing_read_only_response("当前为只读模式,无法更新计费规则"),
),
}
}
pub(super) async fn maybe_build_local_admin_billing_rules_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
return Ok(None);
};
let path = request_context.request_path.as_str();
match decision.route_kind.as_deref() {
Some("list_rules")
if request_context.request_method == http::Method::GET
&& matches!(
path,
"/api/admin/billing/rules" | "/api/admin/billing/rules/"
) =>
{
Ok(Some(
build_admin_list_billing_rules_response(state, request_context).await?,
))
}
Some("get_rule")
if request_context.request_method == http::Method::GET
&& path.starts_with("/api/admin/billing/rules/") =>
{
Ok(Some(
build_admin_get_billing_rule_response(state, request_context).await?,
))
}
Some("create_rule")
if request_context.request_method == http::Method::POST
&& matches!(
path,
"/api/admin/billing/rules" | "/api/admin/billing/rules/"
) =>
{
Ok(Some(
build_admin_create_billing_rule_response(state, request_body).await?,
))
}
Some("update_rule")
if request_context.request_method == http::Method::PUT
&& path.starts_with("/api/admin/billing/rules/") =>
{
Ok(Some(
build_admin_update_billing_rule_response(state, request_context, request_body)
.await?,
))
}
_ => Ok(None),
}
}