refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate

- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦
- 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块
- 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支
- 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合
- 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor
- 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
fawney19
2026-04-07 02:50:19 +08:00
parent 763ff03a7b
commit 5d96d6673b
732 changed files with 28593 additions and 20666 deletions

View File

@@ -0,0 +1,74 @@
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use axum::body::{Body, Bytes};
use axum::http::{self, Response};
use axum::response::IntoResponse;
use axum::Json;
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
const ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL: &str = "Admin Gemini files data unavailable";
const ADMIN_GEMINI_FILE_UPLOAD_DETAIL: &str = "Admin Gemini file upload requires Rust uploader";
const ADMIN_GEMINI_FILES_DEFAULT_PAGE: usize = 1;
const ADMIN_GEMINI_FILES_DEFAULT_PAGE_SIZE: usize = 20;
const ADMIN_GEMINI_FILES_MAX_PAGE_SIZE: usize = 100;
mod read_routes;
mod upload;
pub(crate) async fn maybe_build_local_admin_gemini_files_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
return Ok(None);
};
if decision.route_family.as_deref() != Some("gemini_files_manage") {
return Ok(None);
}
if let Some(response) =
read_routes::maybe_build_local_admin_gemini_files_read_response(state, request_context)
.await?
{
return Ok(Some(response));
}
if let Some(response) = upload::maybe_build_local_admin_gemini_files_upload_response(
state,
request_context,
request_body,
)
.await?
{
return Ok(Some(response));
}
Ok(None)
}
fn admin_gemini_files_key_capable(key: &StoredProviderCatalogKey) -> bool {
key.is_active
&& key
.capabilities
.as_ref()
.and_then(|value| value.get("gemini_files"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn admin_gemini_files_error_response(
status: http::StatusCode,
detail: impl Into<String>,
) -> Response<Body> {
(status, Json(json!({ "detail": detail.into() }))).into_response()
}
fn admin_gemini_files_now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}

View File

@@ -0,0 +1,306 @@
use super::{
admin_gemini_files_error_response, admin_gemini_files_key_capable,
admin_gemini_files_now_unix_secs, ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
ADMIN_GEMINI_FILES_DEFAULT_PAGE, ADMIN_GEMINI_FILES_DEFAULT_PAGE_SIZE,
ADMIN_GEMINI_FILES_MAX_PAGE_SIZE,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::{
admin_gemini_file_mapping_id_from_path, is_admin_gemini_files_capable_keys_root,
is_admin_gemini_files_mappings_root, is_admin_gemini_files_stats_root,
query_param_optional_bool, query_param_value, unix_secs_to_rfc3339,
};
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use axum::body::Body;
use axum::http::{self, Response};
use axum::response::IntoResponse;
use axum::Json;
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone)]
struct AdminGeminiFilesPageQuery {
page: usize,
page_size: usize,
include_expired: bool,
search: Option<String>,
}
fn admin_gemini_files_page_query(
request_context: &GatewayPublicRequestContext,
) -> Result<Option<AdminGeminiFilesPageQuery>, GatewayError> {
let query = request_context.request_query_string.as_deref();
let page = match query_param_value(query, "page") {
Some(raw) => raw.parse::<usize>().ok().filter(|value| *value >= 1),
None => Some(ADMIN_GEMINI_FILES_DEFAULT_PAGE),
}
.ok_or_else(|| {
GatewayError::Internal("admin gemini files page query should validate".to_string())
})?;
let page_size = match query_param_value(query, "page_size") {
Some(raw) => raw
.parse::<usize>()
.ok()
.filter(|value| (1..=ADMIN_GEMINI_FILES_MAX_PAGE_SIZE).contains(value)),
None => Some(ADMIN_GEMINI_FILES_DEFAULT_PAGE_SIZE),
}
.ok_or_else(|| {
GatewayError::Internal("admin gemini files page_size query should validate".to_string())
})?;
let include_expired = query_param_optional_bool(query, "include_expired").unwrap_or(false);
let search = query_param_value(query, "search").and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
});
Ok(Some(AdminGeminiFilesPageQuery {
page,
page_size,
include_expired,
search,
}))
}
async fn admin_gemini_files_key_name_map(
state: &AppState,
) -> Result<BTreeMap<String, String>, GatewayError> {
let capable_keys = admin_gemini_files_all_keys(state).await?;
Ok(capable_keys
.into_iter()
.map(|key| (key.id, key.name))
.collect())
}
async fn admin_gemini_files_username_map<'a, I>(
state: &AppState,
mappings: I,
) -> Result<BTreeMap<String, String>, GatewayError>
where
I: Iterator<Item = &'a aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping>,
{
let user_ids = mappings
.filter_map(|mapping| mapping.user_id.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let users = state.list_users_by_ids(&user_ids).await?;
Ok(users
.into_iter()
.map(|user| (user.id, user.username))
.collect())
}
async fn admin_gemini_files_capable_keys(
state: &AppState,
) -> Result<Vec<serde_json::Value>, GatewayError> {
let providers = state.list_provider_catalog_providers(false).await?;
let provider_name_by_id = providers
.iter()
.map(|provider| (provider.id.as_str(), provider.name.as_str()))
.collect::<BTreeMap<_, _>>();
let keys = admin_gemini_files_all_keys(state).await?;
Ok(keys
.into_iter()
.filter(admin_gemini_files_key_capable)
.map(|key| {
json!({
"id": key.id,
"name": key.name,
"provider_name": provider_name_by_id.get(key.provider_id.as_str()).copied(),
})
})
.collect())
}
async fn admin_gemini_files_all_keys(
state: &AppState,
) -> Result<Vec<StoredProviderCatalogKey>, GatewayError> {
let providers = state.list_provider_catalog_providers(false).await?;
let provider_ids = providers
.into_iter()
.map(|provider| provider.id)
.collect::<Vec<_>>();
state
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
.await
}
fn build_admin_gemini_file_mapping_payload(
mapping: &aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping,
key_name: Option<&str>,
username: Option<&str>,
now_unix_secs: u64,
) -> serde_json::Value {
json!({
"id": mapping.id,
"file_name": mapping.file_name,
"key_id": mapping.key_id,
"key_name": key_name,
"user_id": mapping.user_id,
"username": username,
"display_name": mapping.display_name,
"mime_type": mapping.mime_type,
"created_at": unix_secs_to_rfc3339(mapping.created_at_unix_secs),
"expires_at": unix_secs_to_rfc3339(mapping.expires_at_unix_secs),
"is_expired": mapping.expires_at_unix_secs <= now_unix_secs,
})
}
pub(super) async fn maybe_build_local_admin_gemini_files_read_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Option<Response<Body>>, GatewayError> {
let now_unix_secs = admin_gemini_files_now_unix_secs();
match request_context
.control_decision
.as_ref()
.and_then(|decision| decision.route_kind.as_deref())
{
Some("list_mappings")
if request_context.request_method == http::Method::GET
&& is_admin_gemini_files_mappings_root(&request_context.request_path) =>
{
if !state.has_gemini_file_mapping_data_reader() {
return Ok(Some(admin_gemini_files_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
)));
}
let page = match admin_gemini_files_page_query(request_context)? {
Some(value) => value,
None => return Ok(None),
};
let mappings = state
.list_gemini_file_mappings(
&aether_data::repository::gemini_file_mappings::GeminiFileMappingListQuery {
include_expired: page.include_expired,
search: page.search.clone(),
offset: (page.page - 1).saturating_mul(page.page_size),
limit: page.page_size,
now_unix_secs,
},
)
.await?;
let key_name_by_id = admin_gemini_files_key_name_map(state).await?;
let username_by_id =
admin_gemini_files_username_map(state, mappings.items.iter()).await?;
let items = mappings
.items
.iter()
.map(|mapping| {
build_admin_gemini_file_mapping_payload(
mapping,
key_name_by_id
.get(mapping.key_id.as_str())
.map(String::as_str),
username_by_id
.get(mapping.user_id.as_deref().unwrap_or(""))
.map(String::as_str),
now_unix_secs,
)
})
.collect::<Vec<_>>();
Ok(Some(
Json(json!({
"items": items,
"total": mappings.total,
"page": page.page,
"page_size": page.page_size,
}))
.into_response(),
))
}
Some("stats")
if request_context.request_method == http::Method::GET
&& is_admin_gemini_files_stats_root(&request_context.request_path) =>
{
if !state.has_gemini_file_mapping_data_reader() {
return Ok(Some(admin_gemini_files_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
)));
}
let stats = state.summarize_gemini_file_mappings(now_unix_secs).await?;
let capable_keys_count = admin_gemini_files_capable_keys(state).await?.len();
let by_mime_type = stats
.by_mime_type
.into_iter()
.map(|item| (item.mime_type, json!(item.count)))
.collect::<serde_json::Map<_, _>>();
Ok(Some(
Json(json!({
"total_mappings": stats.total_mappings,
"active_mappings": stats.active_mappings,
"expired_mappings": stats.expired_mappings,
"by_mime_type": by_mime_type,
"capable_keys_count": capable_keys_count,
}))
.into_response(),
))
}
Some("delete_mapping")
if request_context.request_method == http::Method::DELETE
&& request_context
.request_path
.starts_with("/api/admin/gemini-files/mappings/") =>
{
if !state.has_gemini_file_mapping_data_writer() {
return Ok(Some(admin_gemini_files_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
)));
}
let Some(mapping_id) =
admin_gemini_file_mapping_id_from_path(&request_context.request_path)
else {
return Ok(Some(admin_gemini_files_error_response(
http::StatusCode::NOT_FOUND,
"Mapping not found",
)));
};
let Some(mapping) = state.delete_gemini_file_mapping_by_id(&mapping_id).await? else {
return Ok(Some(admin_gemini_files_error_response(
http::StatusCode::NOT_FOUND,
"Mapping not found",
)));
};
Ok(Some(
Json(json!({
"message": "Mapping deleted successfully",
"file_name": mapping.file_name,
}))
.into_response(),
))
}
Some("cleanup_mappings")
if request_context.request_method == http::Method::DELETE
&& is_admin_gemini_files_mappings_root(&request_context.request_path) =>
{
if !state.has_gemini_file_mapping_data_writer() {
return Ok(Some(admin_gemini_files_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
)));
}
let deleted_count = state
.delete_expired_gemini_file_mappings(now_unix_secs)
.await?;
Ok(Some(
Json(json!({
"message": format!("Cleaned up {deleted_count} expired mappings"),
"deleted_count": deleted_count,
}))
.into_response(),
))
}
Some("capable_keys")
if request_context.request_method == http::Method::GET
&& is_admin_gemini_files_capable_keys_root(&request_context.request_path) =>
{
let capable_keys = admin_gemini_files_capable_keys(state).await?;
Ok(Some(Json(capable_keys).into_response()))
}
_ => Ok(None),
}
}

View File

@@ -0,0 +1,586 @@
use super::{
admin_gemini_files_error_response, admin_gemini_files_key_capable,
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::{is_admin_gemini_files_upload_root, query_param_value};
use crate::{AppState, GatewayError};
use aether_contracts::{ExecutionPlan, ExecutionResult, RequestBody};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use axum::body::{Body, Bytes};
use axum::http::{self, Response};
use axum::response::IntoResponse;
use axum::Json;
use base64::Engine as _;
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone)]
struct AdminGeminiFilesUploadRequest {
display_name: String,
mime_type: String,
body_bytes: Vec<u8>,
body_bytes_b64: String,
}
#[derive(Debug, Clone)]
struct AdminGeminiFilesUploadExecutionSuccess {
file_name: String,
display_name: Option<String>,
mime_type: Option<String>,
}
pub(super) async fn maybe_build_local_admin_gemini_files_upload_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
match request_context
.control_decision
.as_ref()
.and_then(|decision| decision.route_kind.as_deref())
{
Some("upload")
if request_context.request_method == http::Method::POST
&& is_admin_gemini_files_upload_root(&request_context.request_path) =>
{
if !state.has_gemini_file_mapping_data_writer() {
return Ok(Some(admin_gemini_files_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
)));
}
let upload =
match admin_gemini_files_parse_upload_request(request_context, request_body) {
Ok(upload) => upload,
Err(detail) => {
return Ok(Some(admin_gemini_files_error_response(
http::StatusCode::BAD_REQUEST,
detail,
)));
}
};
let key_ids = admin_gemini_files_query_key_ids(request_context);
if key_ids.is_empty() {
return Ok(Some(admin_gemini_files_error_response(
http::StatusCode::BAD_REQUEST,
"key_ids 不能为空",
)));
}
let response = admin_gemini_files_upload_across_keys(
state,
"",
request_context.trace_id.as_str(),
&upload,
&key_ids,
)
.await?;
Ok(Some(Json(response).into_response()))
}
_ => Ok(None),
}
}
fn admin_gemini_files_query_key_ids(request_context: &GatewayPublicRequestContext) -> Vec<String> {
let mut key_ids = Vec::new();
let mut seen = BTreeSet::new();
let Some(raw) = query_param_value(request_context.request_query_string.as_deref(), "key_ids")
else {
return key_ids;
};
for key_id in raw.split(',') {
let trimmed = key_id.trim();
if trimmed.is_empty() || !seen.insert(trimmed.to_string()) {
continue;
}
key_ids.push(trimmed.to_string());
}
key_ids
}
fn admin_gemini_files_parse_upload_request(
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<AdminGeminiFilesUploadRequest, String> {
let content_type = request_context
.request_content_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "Content-Type 缺失".to_string())?;
let boundary = admin_gemini_files_multipart_boundary(content_type)?;
let body = request_body
.filter(|body| !body.is_empty())
.ok_or_else(|| "上传文件不能为空".to_string())?;
let (display_name, mime_type, body_bytes) =
admin_gemini_files_extract_file_part(body.as_ref(), &boundary)?;
Ok(AdminGeminiFilesUploadRequest {
display_name,
mime_type,
body_bytes_b64: base64::engine::general_purpose::STANDARD.encode(&body_bytes),
body_bytes,
})
}
fn admin_gemini_files_multipart_boundary(content_type: &str) -> Result<String, String> {
let normalized = content_type.trim();
if !normalized
.to_ascii_lowercase()
.starts_with("multipart/form-data")
{
return Err("Content-Type 必须是 multipart/form-data".to_string());
}
for part in normalized.split(';').skip(1) {
let Some((key, value)) = part.trim().split_once('=') else {
continue;
};
if !key.trim().eq_ignore_ascii_case("boundary") {
continue;
}
let boundary = value.trim().trim_matches('"').trim();
if !boundary.is_empty() {
return Ok(boundary.to_string());
}
}
Err("multipart boundary 缺失".to_string())
}
fn admin_gemini_files_extract_file_part(
body: &[u8],
boundary: &str,
) -> Result<(String, String, Vec<u8>), String> {
let boundary_marker = format!("--{boundary}");
let next_boundary_marker = format!("\r\n--{boundary}");
let boundary_bytes = boundary_marker.as_bytes();
let next_boundary_bytes = next_boundary_marker.as_bytes();
let mut cursor = 0usize;
while cursor < body.len() {
if !body[cursor..].starts_with(boundary_bytes) {
return Err("multipart body 格式无效".to_string());
}
cursor += boundary_bytes.len();
if body[cursor..].starts_with(b"--") {
break;
}
if !body[cursor..].starts_with(b"\r\n") {
return Err("multipart body 缺少头部分隔符".to_string());
}
cursor += 2;
let Some(headers_end_rel) = admin_gemini_files_find_subslice(&body[cursor..], b"\r\n\r\n")
else {
return Err("multipart part 缺少头部".to_string());
};
let headers_end = cursor + headers_end_rel;
let headers_text = std::str::from_utf8(&body[cursor..headers_end])
.map_err(|_| "multipart part 头部编码无效".to_string())?;
cursor = headers_end + 4;
let Some(next_boundary_rel) =
admin_gemini_files_find_subslice(&body[cursor..], next_boundary_bytes)
else {
return Err("multipart body 缺少结束边界".to_string());
};
let content_end = cursor + next_boundary_rel;
let content = &body[cursor..content_end];
cursor = content_end + 2;
let Some((field_name, file_name, mime_type)) =
admin_gemini_files_parse_part_headers(headers_text)
else {
continue;
};
if field_name != "file" {
continue;
}
return Ok((
file_name.unwrap_or_else(|| "uploaded-file".to_string()),
mime_type.unwrap_or_else(|| "application/octet-stream".to_string()),
content.to_vec(),
));
}
Err("multipart body 中缺少 file 字段".to_string())
}
fn admin_gemini_files_parse_part_headers(
headers_text: &str,
) -> Option<(String, Option<String>, Option<String>)> {
let mut field_name = None;
let mut file_name = None;
let mut mime_type = None;
for line in headers_text.split("\r\n") {
let Some((header_name, header_value)) = line.split_once(':') else {
continue;
};
let header_name = header_name.trim();
let header_value = header_value.trim();
if header_name.eq_ignore_ascii_case("content-disposition") {
for part in header_value.split(';').skip(1) {
let Some((key, value)) = part.trim().split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"').trim();
if key.eq_ignore_ascii_case("name") && !value.is_empty() {
field_name = Some(value.to_string());
} else if key.eq_ignore_ascii_case("filename") && !value.is_empty() {
file_name = Some(value.to_string());
}
}
} else if header_name.eq_ignore_ascii_case("content-type") && !header_value.is_empty() {
mime_type = Some(header_value.to_string());
}
}
field_name.map(|field_name| (field_name, file_name, mime_type))
}
fn admin_gemini_files_find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if haystack.is_empty() || needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
async fn admin_gemini_files_upload_across_keys(
state: &AppState,
execution_runtime_base_url: &str,
trace_id: &str,
upload: &AdminGeminiFilesUploadRequest,
requested_key_ids: &[String],
) -> Result<serde_json::Value, GatewayError> {
let keys = state
.read_provider_catalog_keys_by_ids(requested_key_ids)
.await?;
let key_by_id = keys
.iter()
.map(|key| (key.id.as_str(), key))
.collect::<BTreeMap<_, _>>();
let provider_ids = keys
.iter()
.map(|key| key.provider_id.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
.await?;
let endpoints_by_provider_id = endpoints.into_iter().fold(
BTreeMap::<String, Vec<StoredProviderCatalogEndpoint>>::new(),
|mut out, endpoint| {
out.entry(endpoint.provider_id.clone())
.or_default()
.push(endpoint);
out
},
);
let mut results = Vec::new();
let mut success_count = 0usize;
let mut fail_count = 0usize;
for key_id in requested_key_ids {
let Some(key) = key_by_id.get(key_id.as_str()) else {
fail_count += 1;
results.push(json!({
"key_id": key_id,
"key_name": serde_json::Value::Null,
"success": false,
"file_name": serde_json::Value::Null,
"error": "Key 不存在",
}));
continue;
};
let key_name = Some(key.name.clone());
let outcome = admin_gemini_files_upload_single_key(
state,
execution_runtime_base_url,
trace_id,
upload,
key,
endpoints_by_provider_id.get(&key.provider_id),
)
.await;
match outcome {
Ok(success) => {
success_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key_name,
"success": true,
"file_name": success.file_name,
"error": serde_json::Value::Null,
}));
}
Err(error) => {
fail_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key_name,
"success": false,
"file_name": serde_json::Value::Null,
"error": error,
}));
}
}
}
Ok(json!({
"display_name": upload.display_name,
"mime_type": upload.mime_type,
"size_bytes": upload.body_bytes.len(),
"results": results,
"success_count": success_count,
"fail_count": fail_count,
}))
}
async fn admin_gemini_files_upload_single_key(
state: &AppState,
_execution_runtime_base_url: &str,
trace_id: &str,
upload: &AdminGeminiFilesUploadRequest,
key: &StoredProviderCatalogKey,
endpoints: Option<&Vec<StoredProviderCatalogEndpoint>>,
) -> Result<AdminGeminiFilesUploadExecutionSuccess, String> {
if !admin_gemini_files_key_capable(key) {
return Err("Key 不支持 Gemini Files".to_string());
}
let Some(endpoint) = endpoints.and_then(|endpoints| {
endpoints.iter().find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:chat")
})
}) else {
return Err("找不到有效的 gemini:chat 端点".to_string());
};
let transport = state
.read_provider_transport_snapshot(&key.provider_id, &endpoint.id, &key.id)
.await
.map_err(|err| format!("{err:?}"))?
.ok_or_else(|| "无法读取 Key 传输配置".to_string())?;
if !crate::provider_transport::policy::supports_local_gemini_transport_with_network(
&transport,
"gemini:chat",
) {
return Err("Key 传输配置不支持 Gemini Files 上传".to_string());
}
if transport.endpoint.body_rules.is_some() {
return Err("Gemini Files 二进制上传暂不支持 endpoint body_rules".to_string());
}
let (auth_header, auth_value) =
crate::provider_transport::auth::resolve_local_gemini_auth(&transport)
.ok_or_else(|| "Key 缺少可用的 Gemini 认证信息".to_string())?;
let mut provider_request_headers =
crate::provider_transport::auth::build_passthrough_headers_with_auth(
&http::HeaderMap::new(),
&auth_header,
&auth_value,
&BTreeMap::new(),
);
provider_request_headers.insert("content-type".to_string(), upload.mime_type.clone());
let original_request_body = json!({
"body_bytes_b64": upload.body_bytes_b64,
});
if !crate::provider_transport::apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&[auth_header.as_str(), "content-type"],
&original_request_body,
Some(&original_request_body),
) {
return Err("Key 端点 header_rules 应用失败".to_string());
}
let upload_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("/upload/v1beta/files");
let upload_query = if upload_path.contains("uploadType=") {
None
} else {
Some("uploadType=resumable")
};
let upstream_url = crate::provider_transport::url::build_gemini_files_passthrough_url(
&transport.endpoint.base_url,
upload_path,
upload_query,
)
.ok_or_else(|| "无法构建 Gemini Files 上传地址".to_string())?;
let plan = ExecutionPlan {
request_id: format!("{trace_id}:admin-gemini-upload:{}", key.id),
candidate_id: None,
provider_name: Some(transport.provider.name.clone()),
provider_id: transport.provider.id.clone(),
endpoint_id: transport.endpoint.id.clone(),
key_id: transport.key.id.clone(),
method: "POST".to_string(),
url: upstream_url,
headers: provider_request_headers,
content_type: Some(upload.mime_type.clone()),
content_encoding: None,
body: RequestBody {
json_body: None,
body_bytes_b64: Some(upload.body_bytes_b64.clone()),
body_ref: None,
},
stream: false,
client_api_format: "gemini:files".to_string(),
provider_api_format: "gemini:files".to_string(),
model_name: Some("gemini-files".to_string()),
proxy: crate::provider_transport::resolve_transport_proxy_snapshot_with_tunnel_affinity(
state, &transport,
)
.await,
tls_profile: crate::provider_transport::resolve_transport_tls_profile(&transport),
timeouts: crate::provider_transport::resolve_transport_execution_timeouts(&transport),
};
let result = admin_gemini_files_execute_upload_plan(state, trace_id, &plan)
.await
.map_err(|error| format!("{error:?}"))?;
if result.status_code >= 400 {
return Err(admin_gemini_files_execution_error_message(&result));
}
let body_json = admin_gemini_files_execution_json_body(&result)
.ok_or_else(|| "上传成功但上游响应缺少 JSON body".to_string())?;
let success = admin_gemini_files_upload_success_from_body(&body_json, upload)
.ok_or_else(|| admin_gemini_files_execution_error_message(&result))?;
crate::usage::reporting::store_local_gemini_file_mapping(
state,
success.file_name.as_str(),
key.id.as_str(),
None,
success
.display_name
.as_deref()
.or(Some(upload.display_name.as_str())),
success
.mime_type
.as_deref()
.or(Some(upload.mime_type.as_str())),
)
.await
.map_err(|err| format!("上传成功但本地映射写入失败: {err:?}"))?;
Ok(success)
}
async fn admin_gemini_files_execute_upload_plan(
state: &AppState,
trace_id: &str,
plan: &ExecutionPlan,
) -> Result<ExecutionResult, GatewayError> {
crate::execution_runtime::execute_execution_runtime_sync_plan(state, Some(trace_id), plan).await
}
fn admin_gemini_files_execution_json_body(result: &ExecutionResult) -> Option<serde_json::Value> {
if let Some(body_json) = result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
{
return Some(body_json.clone());
}
let content_type = result
.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case("content-type"))
.map(|(_, value)| value.trim().to_ascii_lowercase());
if !content_type
.as_deref()
.is_some_and(|value| value.starts_with("application/json"))
{
return None;
}
let body_bytes_b64 = result
.body
.as_ref()
.and_then(|body| body.body_bytes_b64.as_deref())?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(body_bytes_b64)
.ok()?;
serde_json::from_slice(&decoded).ok()
}
fn admin_gemini_files_upload_success_from_body(
body_json: &serde_json::Value,
upload: &AdminGeminiFilesUploadRequest,
) -> Option<AdminGeminiFilesUploadExecutionSuccess> {
let file_object = body_json
.get("file")
.and_then(serde_json::Value::as_object)
.or_else(|| body_json.as_object())?;
let file_name = file_object
.get("name")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let display_name = file_object
.get("displayName")
.or_else(|| file_object.get("display_name"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| Some(upload.display_name.clone()));
let mime_type = file_object
.get("mimeType")
.or_else(|| file_object.get("mime_type"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| Some(upload.mime_type.clone()));
Some(AdminGeminiFilesUploadExecutionSuccess {
file_name: file_name.to_string(),
display_name,
mime_type,
})
}
fn admin_gemini_files_execution_error_message(result: &ExecutionResult) -> String {
if let Some(body_json) = admin_gemini_files_execution_json_body(result) {
if let Some(message) = body_json
.get("error")
.and_then(serde_json::Value::as_object)
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(message) = body_json
.get("message")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
}
if let Some(error) = result
.error
.as_ref()
.map(|error| error.message.trim())
.filter(|value| !value.is_empty())
{
return error.to_string();
}
format!("上传失败,状态码 {}", result.status_code)
}

View File

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

View File

@@ -0,0 +1,127 @@
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::video_tasks::{StoredVideoTask, VideoTaskStatus};
use axum::http;
use chrono::{SecondsFormat, Utc};
use serde_json::json;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::time::{SystemTime, UNIX_EPOCH};
pub(super) fn admin_video_task_status_name(status: VideoTaskStatus) -> &'static str {
match status {
VideoTaskStatus::Pending => "pending",
VideoTaskStatus::Submitted => "submitted",
VideoTaskStatus::Queued => "queued",
VideoTaskStatus::Processing => "processing",
VideoTaskStatus::Completed => "completed",
VideoTaskStatus::Failed => "failed",
VideoTaskStatus::Cancelled => "cancelled",
VideoTaskStatus::Expired => "expired",
VideoTaskStatus::Deleted => "deleted",
}
}
pub(super) fn admin_video_task_timestamp(unix_secs: Option<u64>) -> Option<String> {
unix_secs.and_then(|value| {
chrono::DateTime::<Utc>::from_timestamp(value as i64, 0)
.map(|timestamp| timestamp.to_rfc3339_opts(SecondsFormat::Secs, true))
})
}
pub(super) fn truncate_admin_video_task_prompt(prompt: Option<&str>) -> Option<String> {
prompt.map(|value| {
if value.chars().count() <= 100 {
value.to_string()
} else {
let mut truncated = value.chars().take(100).collect::<String>();
truncated.push_str("...");
truncated
}
})
}
pub(super) async fn build_admin_video_task_provider_names(
state: &AppState,
tasks: &[StoredVideoTask],
) -> Result<BTreeMap<String, String>, GatewayError> {
let provider_ids = tasks
.iter()
.filter_map(|task| task.provider_id.as_ref())
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if provider_ids.is_empty() {
return Ok(BTreeMap::new());
}
Ok(state
.read_provider_catalog_providers_by_ids(&provider_ids)
.await?
.into_iter()
.map(|provider| (provider.id, provider.name))
.collect())
}
pub(super) fn build_admin_video_task_list_item(
task: &StoredVideoTask,
provider_names: &BTreeMap<String, String>,
) -> Value {
let provider_name = task
.provider_id
.as_deref()
.and_then(|provider_id| provider_names.get(provider_id))
.cloned()
.unwrap_or_else(|| "Unknown".to_string());
json!({
"id": task.id,
"external_task_id": task.external_task_id,
"user_id": task.user_id,
"username": task.username.clone().unwrap_or_else(|| "Unknown".to_string()),
"model": task.model,
"prompt": truncate_admin_video_task_prompt(task.prompt.as_deref()),
"status": admin_video_task_status_name(task.status),
"progress_percent": task.progress_percent,
"progress_message": task.progress_message,
"provider_id": task.provider_id,
"provider_name": provider_name,
"duration_seconds": task.duration_seconds,
"resolution": task.resolution,
"aspect_ratio": task.aspect_ratio,
"video_url": task.video_url,
"error_code": task.error_code,
"error_message": task.error_message,
"poll_count": task.poll_count,
"max_poll_count": task.max_poll_count,
"created_at": admin_video_task_timestamp(Some(task.created_at_unix_secs)),
"completed_at": admin_video_task_timestamp(task.completed_at_unix_secs),
"submitted_at": admin_video_task_timestamp(task.submitted_at_unix_secs),
})
}
pub(super) fn admin_video_task_detail_id_from_path(request_path: &str) -> Option<&str> {
let task_id = request_path.strip_prefix("/api/admin/video-tasks/")?;
if task_id.is_empty() || task_id.contains('/') || task_id == "stats" {
return None;
}
Some(task_id)
}
pub(super) fn admin_video_task_nested_id_from_path<'a>(
request_path: &'a str,
suffix: &str,
) -> Option<&'a str> {
let task_id = request_path
.strip_prefix("/api/admin/video-tasks/")?
.strip_suffix(suffix)?;
if task_id.is_empty() || task_id.contains('/') {
return None;
}
Some(task_id)
}
pub(super) fn current_admin_video_task_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}

View File

@@ -0,0 +1,13 @@
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{body::Body, response::Response};
mod builders;
mod routes;
pub(crate) async fn maybe_build_local_admin_video_tasks_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Option<Response<Body>>, GatewayError> {
routes::maybe_build_local_admin_video_tasks_response(state, request_context).await
}

View File

@@ -0,0 +1,351 @@
use crate::async_task;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value};
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::video_tasks::{VideoTaskQueryFilter, VideoTaskStatus};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use super::super::super::auth::build_proxy_error_response;
use super::builders::{
admin_video_task_detail_id_from_path, admin_video_task_nested_id_from_path,
admin_video_task_status_name, admin_video_task_timestamp, build_admin_video_task_list_item,
build_admin_video_task_provider_names, current_admin_video_task_unix_secs,
};
pub(super) async fn maybe_build_local_admin_video_tasks_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
return Ok(None);
};
if decision.route_family.as_deref() != Some("video_tasks_manage") {
return Ok(None);
}
if request_context.request_method == http::Method::GET
&& matches!(
request_context.request_path.as_str(),
"/api/admin/video-tasks" | "/api/admin/video-tasks/"
)
{
let status =
match query_param_value(request_context.request_query_string.as_deref(), "status") {
Some(value) => match VideoTaskStatus::from_database(&value) {
Ok(status) => Some(status),
Err(err) => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
err.to_string(),
None,
)));
}
},
None => None,
};
let filter = VideoTaskQueryFilter {
user_id: query_param_value(request_context.request_query_string.as_deref(), "user_id"),
status,
model_substring: query_param_value(
request_context.request_query_string.as_deref(),
"model",
),
client_api_format: None,
};
let page = query_param_value(request_context.request_query_string.as_deref(), "page")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(1);
let page_size =
query_param_value(request_context.request_query_string.as_deref(), "page_size")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(20);
let response = async_task::read_video_task_page(state, &filter, page, page_size).await?;
let provider_names = build_admin_video_task_provider_names(state, &response.items).await?;
return Ok(Some(
Json(json!({
"items": response
.items
.iter()
.map(|task| build_admin_video_task_list_item(task, &provider_names))
.collect::<Vec<_>>(),
"total": response.total,
"page": response.page,
"page_size": response.page_size,
"pages": response.pages,
}))
.into_response(),
));
}
if request_context.request_method == http::Method::GET
&& matches!(
request_context.request_path.as_str(),
"/api/admin/video-tasks/stats" | "/api/admin/video-tasks/stats/"
)
{
let filter = VideoTaskQueryFilter {
user_id: None,
status: None,
model_substring: None,
client_api_format: None,
};
let stats =
async_task::read_video_task_stats(state, &filter, current_admin_video_task_unix_secs())
.await?;
let active_users = state.count_distinct_video_task_users(&filter).await?;
return Ok(Some(
Json(json!({
"total": stats.total,
"by_status": stats.by_status,
"by_model": stats.by_model,
"today_count": stats.today_count,
"active_users": active_users,
"processing_count": stats.processing_count,
}))
.into_response(),
));
}
if request_context.request_method == http::Method::POST {
let Some(task_id) =
admin_video_task_nested_id_from_path(&request_context.request_path, "/cancel")
else {
return Ok(None);
};
let stored = match async_task::cancel_video_task_record(state, task_id).await {
Ok(stored) => stored,
Err(async_task::CancelVideoTaskError::NotFound) => {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Video task not found" })),
)
.into_response(),
));
}
Err(async_task::CancelVideoTaskError::InvalidStatus(status)) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({
"detail": format!(
"Cannot cancel task with status: {}",
admin_video_task_status_name(status),
),
})),
)
.into_response(),
));
}
Err(async_task::CancelVideoTaskError::Response(response)) => {
return Ok(Some(response));
}
Err(async_task::CancelVideoTaskError::Gateway(err)) => {
return Err(err);
}
};
return Ok(Some(attach_admin_audit_response(
Json(json!({
"id": stored.id,
"status": "cancelled",
"message": "Task cancelled successfully",
}))
.into_response(),
"admin_video_task_cancelled",
"cancel_video_task",
"video_task",
&stored.id,
)));
}
if request_context.request_method == http::Method::GET {
let Some(task_id) =
admin_video_task_nested_id_from_path(&request_context.request_path, "/video")
else {
let Some(task_id) = admin_video_task_detail_id_from_path(&request_context.request_path)
else {
return Ok(None);
};
let Some(task) = async_task::read_video_task_detail(state, task_id).await? else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Video task not found" })),
)
.into_response(),
));
};
let provider_names =
build_admin_video_task_provider_names(state, std::slice::from_ref(&task)).await?;
let provider_name = task
.provider_id
.as_deref()
.and_then(|provider_id| provider_names.get(provider_id))
.cloned()
.unwrap_or_else(|| "Unknown".to_string());
let endpoint = if let Some(endpoint_id) = task.endpoint_id.as_ref() {
let endpoints = state
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(endpoint_id))
.await?;
endpoints.into_iter().next()
} else {
None
};
let endpoint_payload = endpoint.map(|endpoint| {
json!({
"id": endpoint.id,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
})
});
let mut payload = serde_json::Map::new();
payload.insert("id".to_string(), json!(task.id));
payload.insert("external_task_id".to_string(), json!(task.external_task_id));
payload.insert("user_id".to_string(), json!(task.user_id));
payload.insert(
"username".to_string(),
json!(task
.username
.clone()
.unwrap_or_else(|| "Unknown".to_string())),
);
payload.insert("api_key_id".to_string(), json!(task.api_key_id));
payload.insert("provider_id".to_string(), json!(task.provider_id));
payload.insert("provider_name".to_string(), json!(provider_name));
payload.insert("endpoint_id".to_string(), json!(task.endpoint_id));
payload.insert(
"endpoint".to_string(),
endpoint_payload.unwrap_or(serde_json::Value::Null),
);
payload.insert("key_id".to_string(), json!(task.key_id));
payload.insert(
"client_api_format".to_string(),
json!(task.client_api_format),
);
payload.insert(
"provider_api_format".to_string(),
json!(task.provider_api_format),
);
payload.insert("format_converted".to_string(), json!(task.format_converted));
payload.insert("model".to_string(), json!(task.model));
payload.insert("prompt".to_string(), json!(task.prompt));
payload.insert(
"original_request_body".to_string(),
json!(task.original_request_body),
);
payload.insert(
"converted_request_body".to_string(),
serde_json::Value::Null,
);
payload.insert("duration_seconds".to_string(), json!(task.duration_seconds));
payload.insert("resolution".to_string(), json!(task.resolution));
payload.insert("aspect_ratio".to_string(), json!(task.aspect_ratio));
payload.insert("size".to_string(), json!(task.size));
payload.insert(
"status".to_string(),
json!(admin_video_task_status_name(task.status)),
);
payload.insert("progress_percent".to_string(), json!(task.progress_percent));
payload.insert("progress_message".to_string(), json!(task.progress_message));
payload.insert("video_url".to_string(), json!(task.video_url));
payload.insert("video_urls".to_string(), serde_json::Value::Null);
payload.insert("thumbnail_url".to_string(), serde_json::Value::Null);
payload.insert("video_size_bytes".to_string(), serde_json::Value::Null);
payload.insert(
"video_duration_seconds".to_string(),
serde_json::Value::Null,
);
payload.insert("video_expires_at".to_string(), serde_json::Value::Null);
payload.insert("stored_video_path".to_string(), serde_json::Value::Null);
payload.insert("storage_provider".to_string(), serde_json::Value::Null);
payload.insert("error_code".to_string(), json!(task.error_code));
payload.insert("error_message".to_string(), json!(task.error_message));
payload.insert("retry_count".to_string(), json!(task.retry_count));
payload.insert("max_retries".to_string(), serde_json::Value::Null);
payload.insert(
"poll_interval_seconds".to_string(),
json!(task.poll_interval_seconds),
);
payload.insert(
"next_poll_at".to_string(),
json!(admin_video_task_timestamp(task.next_poll_at_unix_secs)),
);
payload.insert("poll_count".to_string(), json!(task.poll_count));
payload.insert("max_poll_count".to_string(), json!(task.max_poll_count));
payload.insert(
"created_at".to_string(),
json!(admin_video_task_timestamp(Some(task.created_at_unix_secs))),
);
payload.insert(
"updated_at".to_string(),
json!(admin_video_task_timestamp(Some(task.updated_at_unix_secs))),
);
payload.insert(
"submitted_at".to_string(),
json!(admin_video_task_timestamp(task.submitted_at_unix_secs)),
);
payload.insert(
"completed_at".to_string(),
json!(admin_video_task_timestamp(task.completed_at_unix_secs)),
);
payload.insert("request_metadata".to_string(), json!(task.request_metadata));
return Ok(Some(attach_admin_audit_response(
Json(serde_json::Value::Object(payload)).into_response(),
"admin_video_task_detail_viewed",
"view_video_task_detail",
"video_task",
&task.id,
)));
};
let Some(task) = async_task::read_video_task_detail(state, task_id).await? else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Video task not found" })),
)
.into_response(),
));
};
if task
.video_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
{
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Video not available" })),
)
.into_response(),
));
}
let Some(source) = async_task::read_video_task_video_source(state, task_id).await? else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Video not available" })),
)
.into_response(),
));
};
return Ok(Some(attach_admin_audit_response(
async_task::build_video_task_video_response(state, task_id, source).await?,
"admin_video_task_video_viewed",
"view_video_task_video",
"video_task_video",
task_id,
)));
}
Ok(None)
}