refactor: 拆分 admin handler 大文件为模块目录,消除 shared.rs 反模式

- endpoint_keys/pool/architecture 等大文件拆分为独立模块目录
- 删除 crud/query/strategy/system 中的 shared.rs,内容归位到各自模块
- endpoints_admin/models/oauth/write 等模块拆出 payloads/responses/support 子文件
- system/core 下多个 *_routes.rs 合并到 system_routes.rs
- 更新 refactor-execution-plan 文档进度
This commit is contained in:
fawney19
2026-04-07 12:15:19 +08:00
parent 29055c575f
commit 4fb9882b54
109 changed files with 8151 additions and 7047 deletions

View File

@@ -9,7 +9,7 @@ use crate::handlers::admin::shared::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, query_param_bool,
query_param_optional_bool, query_param_value,
};
use crate::handlers::admin::system::shared::serialize_admin_system_users_export_wallet;
use crate::handlers::admin::system::shared::configs::serialize_admin_system_users_export_wallet;
use crate::{AppState, GatewayError};
use axum::{
body::Body,

View File

@@ -1 +0,0 @@
pub(super) use crate::handlers::admin::provider::endpoint_keys::maybe_build_local_admin_endpoints_keys_response;

View File

@@ -1,4 +1,5 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::{endpoint_keys, endpoints_admin};
use crate::{AppState, GatewayError};
use axum::body::{Body, Bytes};
use axum::http::Response;
@@ -6,8 +7,6 @@ use axum::http::Response;
mod extractors;
mod health;
mod health_builders;
mod keys;
mod routes;
mod rpm;
pub(crate) use self::health_builders::build_admin_endpoint_health_status_payload;
@@ -29,14 +28,17 @@ pub(crate) async fn maybe_build_local_admin_endpoints_response(
return Ok(Some(response));
}
if let Some(response) =
keys::maybe_build_local_admin_endpoints_keys_response(state, request_context, request_body)
.await?
if let Some(response) = endpoint_keys::maybe_build_local_admin_endpoints_keys_response(
state,
request_context,
request_body,
)
.await?
{
return Ok(Some(response));
}
if let Some(response) = routes::maybe_build_local_admin_endpoints_routes_response(
if let Some(response) = endpoints_admin::maybe_build_local_admin_endpoints_routes_response(
state,
request_context,
request_body,

View File

@@ -1 +0,0 @@
pub(super) use crate::handlers::admin::provider::endpoints_admin::maybe_build_local_admin_endpoints_routes_response;

View File

@@ -18,19 +18,9 @@ pub(crate) use self::global::{
build_admin_model_catalog_payload, resolve_admin_global_model_by_id_or_err,
};
pub(crate) use self::global_models::maybe_build_local_admin_global_models_response;
pub(crate) use self::payloads::{
admin_provider_model_effective_capability, admin_provider_model_effective_input_price,
admin_provider_model_effective_output_price, admin_provider_model_name_exists,
build_admin_provider_model_payload, build_admin_provider_model_response,
build_admin_provider_models_payload, normalize_optional_price,
normalize_required_trimmed_string,
};
pub(crate) use self::routing::{
build_admin_assign_global_model_to_providers_payload, build_admin_global_model_routing_payload,
};
pub(crate) use self::write::{
build_admin_batch_assign_global_models_payload, build_admin_global_model_create_record,
build_admin_global_model_update_record, build_admin_import_provider_models_payload,
build_admin_provider_available_source_models_payload, build_admin_provider_model_create_record,
build_admin_provider_model_update_record,
build_admin_global_model_create_record, build_admin_global_model_update_record,
};

View File

@@ -1,10 +1,5 @@
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, StoredAdminProviderModel,
};
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
use aether_data_contracts::repository::global_models::StoredAdminProviderModel;
pub(crate) fn model_tiered_pricing_first_tier_value(
tiered_pricing: Option<&serde_json::Value>,
@@ -31,42 +26,6 @@ fn model_effective_capability(
})
}
fn merge_json_values(base: &mut serde_json::Value, overlay: serde_json::Value) {
match (base, overlay) {
(serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
for (key, value) in overlay_map {
match base_map.get_mut(&key) {
Some(existing) => merge_json_values(existing, value),
None => {
base_map.insert(key, value);
}
}
}
}
(base, overlay) => *base = overlay,
}
}
fn merge_admin_provider_model_effective_config(
model: &StoredAdminProviderModel,
) -> Option<serde_json::Value> {
let mut merged = match model.global_model_config.clone() {
Some(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
Some(other) => other,
None => serde_json::Value::Object(serde_json::Map::new()),
};
if let Some(config) = model.config.clone() {
merge_json_values(&mut merged, config);
}
match merged {
serde_json::Value::Null => None,
serde_json::Value::Object(ref map) if map.is_empty() => None,
value => Some(value),
}
}
pub(crate) fn timestamp_or_now(value: Option<u64>, now_unix_secs: u64) -> serde_json::Value {
unix_secs_to_rfc3339(value.unwrap_or(now_unix_secs))
.map(serde_json::Value::String)
@@ -154,144 +113,3 @@ pub(crate) fn admin_provider_model_effective_capability(
_ => false,
}
}
pub(crate) fn build_admin_provider_model_response(
model: &StoredAdminProviderModel,
now_unix_secs: u64,
) -> serde_json::Value {
let effective_tiered_pricing = model
.tiered_pricing
.clone()
.or_else(|| model.global_model_default_tiered_pricing.clone());
let effective_config = merge_admin_provider_model_effective_config(model);
json!({
"id": &model.id,
"provider_id": &model.provider_id,
"global_model_id": &model.global_model_id,
"provider_model_name": &model.provider_model_name,
"provider_model_mappings": model.provider_model_mappings.clone(),
"price_per_request": model.price_per_request,
"tiered_pricing": model.tiered_pricing.clone(),
"effective_tiered_pricing": effective_tiered_pricing,
"effective_input_price": admin_provider_model_effective_input_price(model),
"effective_output_price": admin_provider_model_effective_output_price(model),
"effective_price_per_request": model
.price_per_request
.or(model.global_model_default_price_per_request),
"supports_vision": model.supports_vision,
"supports_function_calling": model.supports_function_calling,
"supports_streaming": model.supports_streaming,
"supports_extended_thinking": model.supports_extended_thinking,
"supports_image_generation": model.supports_image_generation,
"effective_supports_vision": admin_provider_model_effective_capability(model, "vision"),
"effective_supports_function_calling": admin_provider_model_effective_capability(
model,
"function_calling",
),
"effective_supports_streaming": admin_provider_model_effective_capability(model, "streaming"),
"effective_supports_extended_thinking": admin_provider_model_effective_capability(
model,
"extended_thinking",
),
"effective_supports_image_generation": admin_provider_model_effective_capability(
model,
"image_generation",
),
"is_active": model.is_active,
"is_available": model.is_available,
"config": model.config.clone(),
"effective_config": effective_config,
"global_model_name": model.global_model_name.clone(),
"global_model_display_name": model.global_model_display_name.clone(),
"created_at": timestamp_or_now(model.created_at_unix_secs, now_unix_secs),
"updated_at": timestamp_or_now(model.updated_at_unix_secs, now_unix_secs),
})
}
pub(crate) async fn build_admin_provider_models_payload(
state: &AppState,
provider_id: &str,
skip: usize,
limit: usize,
is_active: Option<bool>,
) -> Option<serde_json::Value> {
if !state.has_provider_catalog_data_reader() || !state.has_global_model_data_reader() {
return None;
}
let provider = state
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
.await
.ok()?
.into_iter()
.next()?;
let mut models = state
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: provider.id,
is_active,
offset: skip,
limit,
})
.await
.ok()?;
models.sort_by(|left, right| {
left.provider_model_name
.cmp(&right.provider_model_name)
.then_with(|| left.id.cmp(&right.id))
});
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
Some(serde_json::Value::Array(
models
.iter()
.map(|model| build_admin_provider_model_response(model, now_unix_secs))
.collect(),
))
}
pub(crate) async fn build_admin_provider_model_payload(
state: &AppState,
provider_id: &str,
model_id: &str,
) -> Option<serde_json::Value> {
if !state.has_global_model_data_reader() {
return None;
}
let model = state
.get_admin_provider_model(provider_id, model_id)
.await
.ok()??;
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
Some(build_admin_provider_model_response(&model, now_unix_secs))
}
pub(crate) async fn admin_provider_model_name_exists(
state: &AppState,
provider_id: &str,
provider_model_name: &str,
exclude_model_id: Option<&str>,
) -> Result<bool, GatewayError> {
let target = provider_model_name.trim();
if target.is_empty() {
return Ok(false);
}
let models = state
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: provider_id.to_string(),
is_active: None,
offset: 0,
limit: 10_000,
})
.await?;
Ok(models.into_iter().any(|model| {
model.provider_model_name == target
&& exclude_model_id.is_none_or(|exclude| model.id != exclude)
}))
}

View File

@@ -1,26 +1,13 @@
use super::{
admin_provider_model_effective_capability, admin_provider_model_effective_input_price,
admin_provider_model_effective_output_price, admin_provider_model_name_exists,
normalize_optional_price, normalize_required_trimmed_string,
resolve_admin_global_model_by_id_or_err,
};
use super::payloads::{normalize_optional_price, normalize_required_trimmed_string};
use crate::handlers::admin::model::shared::{
AdminGlobalModelCreateRequest, AdminGlobalModelUpdateRequest,
};
use crate::handlers::admin::provider::shared::{
AdminImportProviderModelsRequest, AdminProviderModelCreateRequest,
AdminProviderModelUpdateRequest,
};
use crate::handlers::admin::shared::{
normalize_json_array, normalize_json_object, normalize_string_list,
};
use crate::handlers::admin::shared::{normalize_json_object, normalize_string_list};
use crate::AppState;
use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, CreateAdminGlobalModelRecord, StoredAdminGlobalModel,
StoredAdminProviderModel, UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
CreateAdminGlobalModelRecord, StoredAdminGlobalModel, UpdateAdminGlobalModelRecord,
};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
pub(crate) async fn build_admin_global_model_create_record(
@@ -116,419 +103,3 @@ pub(crate) async fn build_admin_global_model_update_record(
)
.map_err(|err| err.to_string())
}
pub(crate) async fn build_admin_provider_model_create_record(
state: &AppState,
provider_id: &str,
payload: AdminProviderModelCreateRequest,
) -> Result<UpsertAdminProviderModelRecord, String> {
let provider_model_name =
normalize_required_trimmed_string(&payload.provider_model_name, "provider_model_name")?;
if admin_provider_model_name_exists(state, provider_id, &provider_model_name, None)
.await
.map_err(|err| format!("{err:?}"))?
{
return Err(format!("模型 '{provider_model_name}' 已存在"));
}
let global_model_id =
normalize_required_trimmed_string(&payload.global_model_id, "global_model_id")?;
resolve_admin_global_model_by_id_or_err(state, &global_model_id).await?;
let price_per_request =
normalize_optional_price(payload.price_per_request, "price_per_request")?;
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
let provider_model_mappings =
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?;
let config = normalize_json_object(payload.config, "config")?;
UpsertAdminProviderModelRecord::new(
Uuid::new_v4().to_string(),
provider_id.to_string(),
global_model_id,
provider_model_name,
provider_model_mappings,
price_per_request,
tiered_pricing,
payload.supports_vision,
payload.supports_function_calling,
payload.supports_streaming,
payload.supports_extended_thinking,
None,
payload.is_active.unwrap_or(true),
true,
config,
)
.map_err(|err| err.to_string())
}
pub(crate) async fn build_admin_provider_model_update_record(
state: &AppState,
existing: &StoredAdminProviderModel,
raw_payload: &serde_json::Map<String, serde_json::Value>,
payload: AdminProviderModelUpdateRequest,
) -> Result<UpsertAdminProviderModelRecord, String> {
let provider_model_name = if let Some(value) = raw_payload.get("provider_model_name") {
let Some(name) = payload.provider_model_name.as_deref() else {
return Err(if value.is_null() {
"provider_model_name 不能为空".to_string()
} else {
"provider_model_name 必须是字符串".to_string()
});
};
let name = normalize_required_trimmed_string(name, "provider_model_name")?;
if admin_provider_model_name_exists(state, &existing.provider_id, &name, Some(&existing.id))
.await
.map_err(|err| format!("{err:?}"))?
{
return Err(format!("模型 '{name}' 已存在"));
}
name
} else {
existing.provider_model_name.clone()
};
let global_model_id = if let Some(value) = raw_payload.get("global_model_id") {
let Some(global_model_id) = payload.global_model_id.as_deref() else {
return Err(if value.is_null() {
"global_model_id 不能为空".to_string()
} else {
"global_model_id 必须是字符串".to_string()
});
};
let global_model_id =
normalize_required_trimmed_string(global_model_id, "global_model_id")?;
resolve_admin_global_model_by_id_or_err(state, &global_model_id).await?;
global_model_id
} else {
existing.global_model_id.clone()
};
let price_per_request = if raw_payload.contains_key("price_per_request") {
normalize_optional_price(payload.price_per_request, "price_per_request")?
} else {
existing.price_per_request
};
let tiered_pricing = if raw_payload.contains_key("tiered_pricing") {
normalize_json_object(payload.tiered_pricing, "tiered_pricing")?
} else {
existing.tiered_pricing.clone()
};
let provider_model_mappings = if raw_payload.contains_key("provider_model_mappings") {
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?
} else {
existing.provider_model_mappings.clone()
};
let config = if raw_payload.contains_key("config") {
normalize_json_object(payload.config, "config")?
} else {
existing.config.clone()
};
UpsertAdminProviderModelRecord::new(
existing.id.clone(),
existing.provider_id.clone(),
global_model_id,
provider_model_name,
provider_model_mappings,
price_per_request,
tiered_pricing,
if raw_payload.contains_key("supports_vision") {
payload.supports_vision
} else {
existing.supports_vision
},
if raw_payload.contains_key("supports_function_calling") {
payload.supports_function_calling
} else {
existing.supports_function_calling
},
if raw_payload.contains_key("supports_streaming") {
payload.supports_streaming
} else {
existing.supports_streaming
},
if raw_payload.contains_key("supports_extended_thinking") {
payload.supports_extended_thinking
} else {
existing.supports_extended_thinking
},
existing.supports_image_generation,
payload.is_active.unwrap_or(existing.is_active),
payload.is_available.unwrap_or(existing.is_available),
config,
)
.map_err(|err| err.to_string())
}
pub(crate) async fn build_admin_provider_available_source_models_payload(
state: &AppState,
provider_id: &str,
) -> Option<serde_json::Value> {
if !state.has_global_model_data_reader() || !state.has_provider_catalog_data_reader() {
return None;
}
let provider = state
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
.await
.ok()?
.into_iter()
.next()?;
let models = state
.list_admin_provider_available_source_models(&provider.id)
.await
.ok()?;
let mut by_global_model = BTreeMap::<String, StoredAdminProviderModel>::new();
for model in models {
by_global_model
.entry(model.global_model_id.clone())
.or_insert(model);
}
let mut payload_models = by_global_model
.into_values()
.map(|model| {
json!({
"global_model_name": model.global_model_name,
"display_name": model.global_model_display_name,
"provider_model_name": model.provider_model_name,
"model_id": model.id,
"price": {
"input_price_per_1m": admin_provider_model_effective_input_price(&model),
"output_price_per_1m": admin_provider_model_effective_output_price(&model),
"cache_creation_price_per_1m": serde_json::Value::Null,
"cache_read_price_per_1m": serde_json::Value::Null,
"price_per_request": model.price_per_request.or(model.global_model_default_price_per_request),
},
"capabilities": json!({
"supports_vision": admin_provider_model_effective_capability(&model, "vision"),
"supports_function_calling": admin_provider_model_effective_capability(&model, "function_calling"),
"supports_streaming": admin_provider_model_effective_capability(&model, "streaming"),
}),
"is_active": model.is_active,
})
})
.collect::<Vec<_>>();
let total = payload_models.len();
payload_models.sort_by(|left, right| {
left.get("global_model_name")
.and_then(serde_json::Value::as_str)
.cmp(
&right
.get("global_model_name")
.and_then(serde_json::Value::as_str),
)
});
Some(json!({
"models": payload_models,
"total": total,
}))
}
pub(crate) async fn build_admin_batch_assign_global_models_payload(
state: &AppState,
provider_id: &str,
global_model_ids: Vec<String>,
) -> Result<serde_json::Value, String> {
let existing_models = state
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: provider_id.to_string(),
is_active: None,
offset: 0,
limit: 10_000,
})
.await
.map_err(|err| format!("{err:?}"))?;
let existing_global_model_ids = existing_models
.into_iter()
.map(|model| model.global_model_id)
.collect::<BTreeSet<_>>();
let mut success = Vec::new();
let mut errors = Vec::new();
for global_model_id in global_model_ids {
let global_model_id = global_model_id.trim().to_string();
if global_model_id.is_empty() {
continue;
}
let global_model =
match resolve_admin_global_model_by_id_or_err(state, &global_model_id).await {
Ok(model) => model,
Err(detail) => {
errors.push(json!({
"global_model_id": global_model_id,
"error": detail,
}));
continue;
}
};
if existing_global_model_ids.contains(&global_model.id) {
errors.push(json!({
"global_model_id": global_model.id,
"error": "Model already exists",
}));
continue;
}
let record = UpsertAdminProviderModelRecord::new(
Uuid::new_v4().to_string(),
provider_id.to_string(),
global_model.id.clone(),
global_model.name.clone(),
None,
None,
None,
None,
None,
None,
None,
None,
true,
true,
None,
)
.map_err(|err| err.to_string())?;
match state.create_admin_provider_model(&record).await {
Ok(Some(created)) => success.push(json!({
"global_model_id": global_model.id,
"global_model_name": global_model.name,
"provider_model_id": created.id,
})),
Ok(None) => errors.push(json!({
"global_model_id": global_model.id,
"error": "Create provider model failed",
})),
Err(err) => errors.push(json!({
"global_model_id": global_model.id,
"error": format!("{err:?}"),
})),
}
}
Ok(json!({
"success": success,
"errors": errors,
}))
}
pub(crate) async fn build_admin_import_provider_models_payload(
state: &AppState,
provider_id: &str,
payload: AdminImportProviderModelsRequest,
) -> Result<serde_json::Value, String> {
let default_pricing = json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 0.0,
"output_price_per_1m": 0.0,
}]
});
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
let existing_models = state
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: provider_id.to_string(),
is_active: None,
offset: 0,
limit: 10_000,
})
.await
.map_err(|err| format!("{err:?}"))?;
let mut existing_by_name = existing_models
.iter()
.map(|model| (model.provider_model_name.clone(), model.clone()))
.collect::<BTreeMap<_, _>>();
let mut success = Vec::new();
let mut errors = Vec::new();
for model_id in payload.model_ids {
let trimmed = model_id.trim();
if trimmed.is_empty() || trimmed.len() > 100 {
errors.push(json!({
"model_id": if trimmed.is_empty() { "<empty>" } else { trimmed },
"error": "Invalid model_id: must be 1-100 characters",
}));
continue;
}
if let Some(existing) = existing_by_name.get(trimmed) {
success.push(json!({
"model_id": trimmed,
"global_model_id": existing.global_model_id,
"global_model_name": existing.global_model_name,
"provider_model_id": existing.id,
"created_global_model": false,
}));
continue;
}
let mut created_global_model = false;
let global_model = if let Some(existing) = state
.get_admin_global_model_by_name(trimmed)
.await
.map_err(|err| format!("{err:?}"))?
{
existing
} else {
let created = state
.create_admin_global_model(
&CreateAdminGlobalModelRecord::new(
Uuid::new_v4().to_string(),
trimmed.to_string(),
trimmed.to_string(),
true,
payload.price_per_request,
tiered_pricing
.clone()
.or_else(|| Some(default_pricing.clone())),
None,
None,
)
.map_err(|err| err.to_string())?,
)
.await
.map_err(|err| format!("{err:?}"))?;
let Some(created) = created else {
errors.push(json!({"model_id": trimmed, "error": "Create GlobalModel failed"}));
continue;
};
created_global_model = true;
created
};
let record = UpsertAdminProviderModelRecord::new(
Uuid::new_v4().to_string(),
provider_id.to_string(),
global_model.id.clone(),
trimmed.to_string(),
None,
payload.price_per_request,
tiered_pricing.clone(),
None,
None,
None,
None,
None,
true,
true,
None,
)
.map_err(|err| err.to_string())?;
match state.create_admin_provider_model(&record).await {
Ok(Some(created)) => {
existing_by_name.insert(trimmed.to_string(), created.clone());
success.push(json!({
"model_id": trimmed,
"global_model_id": global_model.id,
"global_model_name": global_model.name,
"provider_model_id": created.id,
"created_global_model": created_global_model,
}));
}
Ok(None) => {
errors.push(json!({"model_id": trimmed, "error": "Create model failed"}));
}
Err(err) => {
errors.push(json!({"model_id": trimmed, "error": format!("{err:?}")}));
}
}
}
Ok(json!({ "success": success, "errors": errors }))
}

View File

@@ -1,5 +1,4 @@
mod responses;
mod routes;
mod shared;
pub(crate) use routes::maybe_build_local_admin_providers_response;
use shared::*;

View File

@@ -1,26 +1,30 @@
use super::shared::build_admin_providers_data_unavailable_response;
use super::responses::build_admin_providers_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::delete_task::{
build_admin_provider_mapping_preview_payload, run_admin_provider_delete_task,
};
use crate::handlers::admin::provider::pool::{
use crate::handlers::admin::provider::pool::runtime::{
build_admin_provider_pool_status_payload, clear_admin_provider_pool_cooldown,
reset_admin_provider_pool_cost,
};
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::paths::{
admin_provider_clear_pool_cooldown_parts, admin_provider_delete_task_parts,
admin_provider_id_for_health_monitor, admin_provider_id_for_manage_path,
admin_provider_id_for_mapping_preview, admin_provider_id_for_pool_status,
admin_provider_id_for_summary, admin_provider_reset_pool_cost_parts,
build_admin_provider_delete_task_payload, is_admin_providers_root,
put_admin_provider_delete_task, AdminProviderCreateRequest, AdminProviderUpdateRequest,
admin_provider_id_for_summary, admin_provider_reset_pool_cost_parts, is_admin_providers_root,
};
use crate::handlers::admin::provider::shared::payloads::{
AdminProviderCreateRequest, AdminProviderUpdateRequest,
};
use crate::handlers::admin::provider::shared::support::{
build_admin_provider_delete_task_payload, put_admin_provider_delete_task,
};
use crate::handlers::admin::provider::summary::{
build_admin_provider_health_monitor_payload, build_admin_provider_summary_payload,
build_admin_provider_summary_value, build_admin_providers_payload,
build_admin_providers_summary_payload,
};
use crate::handlers::admin::provider::write::{
use crate::handlers::admin::provider::write::provider::{
build_admin_create_provider_record, build_admin_fixed_provider_endpoint_record,
build_admin_update_provider_record,
};

View File

@@ -1,4 +1,4 @@
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::support::{
put_admin_provider_delete_task, ADMIN_PROVIDER_MAPPING_PREVIEW_FETCH_LIMIT,
ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_KEYS, ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_MODELS,
};

View File

@@ -1,754 +1,29 @@
use super::oauth::{
normalize_string_id_list, refresh_antigravity_provider_quota_locally,
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
};
use super::write::{
build_admin_create_provider_key_record, build_admin_export_key_payload,
build_admin_provider_keys_payload, build_admin_reveal_key_payload,
build_admin_update_provider_key_record,
};
mod mutations;
mod quota;
mod reads;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_keys,
admin_provider_id_for_refresh_quota, admin_reveal_key_id, admin_update_key_id,
AdminProviderKeyBatchDeleteRequest, AdminProviderKeyCreateRequest,
AdminProviderKeyUpdateRequest, AdminProviderQuotaRefreshRequest, OAUTH_ACCOUNT_BLOCK_PREFIX,
};
use crate::handlers::admin::shared::{
attach_admin_audit_response, build_admin_provider_key_response, query_param_value,
};
use crate::handlers::public::build_admin_keys_grouped_by_format_payload;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
response::Response,
};
use serde_json::json;
use std::collections::BTreeSet;
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) async fn maybe_build_local_admin_endpoints_keys_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("endpoints_manage")
&& decision.route_kind.as_deref() == Some("keys_grouped_by_format")
&& request_context.request_path == "/api/admin/endpoints/keys/grouped-by-format"
{
let Some(payload) = build_admin_keys_grouped_by_format_payload(state).await else {
return Ok(None);
};
return Ok(Some(Json(payload).into_response()));
if let Some(response) = reads::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("reveal_key")
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
&& request_context.request_path.ends_with("/reveal")
{
let Some(key_id) = admin_reveal_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
return Ok(Some(match build_admin_reveal_key_payload(state, &key) {
Ok(payload) => attach_admin_audit_response(
Json(payload).into_response(),
"admin_provider_key_revealed",
"reveal_provider_key",
"provider_key",
&key_id,
),
Err(detail) => (
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
}));
if let Some(response) = mutations::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("export_key")
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
&& request_context.request_path.ends_with("/export")
{
let Some(key_id) = admin_export_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
return Ok(Some(
match build_admin_export_key_payload(state, &key).await {
Ok(payload) => attach_admin_audit_response(
Json(payload).into_response(),
"admin_provider_key_exported",
"export_provider_key",
"provider_key_export",
&key_id,
),
Err(detail) => (
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
},
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("update_key")
&& request_context.request_method == http::Method::PUT
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
{
let Some(key_id) = admin_update_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
if !state.has_provider_catalog_data_reader() {
return Ok(None);
}
let raw_value = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(value) => value,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(raw_payload) = raw_value.as_object().cloned() else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
};
let payload = match serde_json::from_value::<AdminProviderKeyUpdateRequest>(raw_value) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(existing_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&existing_key.provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {} 不存在", existing_key.provider_id) })),
)
.into_response(),
));
};
let updated_record = match build_admin_update_provider_key_record(
state,
&provider,
&existing_key,
&raw_payload,
payload,
)
.await
{
Ok(record) => record,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
let Some(updated) = state.update_provider_catalog_key(&updated_record).await? else {
return Ok(None);
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
return Ok(Some(
Json(build_admin_provider_key_response(
state,
&updated,
now_unix_secs,
))
.into_response(),
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("delete_key")
&& request_context.request_method == http::Method::DELETE
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
{
let Some(key_id) = admin_update_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(_existing_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
if !state.delete_provider_catalog_key(&key_id).await? {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
}
return Ok(Some(
Json(json!({
"message": format!("Key {key_id} 已删除")
}))
.into_response(),
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("batch_delete_keys")
&& request_context.request_method == http::Method::POST
&& request_context.request_path == "/api/admin/endpoints/keys/batch-delete"
{
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
let payload =
match serde_json::from_slice::<AdminProviderKeyBatchDeleteRequest>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
if payload.ids.len() > 100 {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "ids 最多 100 个" })),
)
.into_response(),
));
}
if payload.ids.is_empty() {
return Ok(Some(
Json(json!({
"success_count": 0,
"failed_count": 0,
"failed": []
}))
.into_response(),
));
}
let found_keys = state
.read_provider_catalog_keys_by_ids(&payload.ids)
.await?;
let found_ids = found_keys
.iter()
.map(|key| key.id.clone())
.collect::<BTreeSet<_>>();
let mut failed = payload
.ids
.iter()
.filter(|key_id| !found_ids.contains(*key_id))
.map(|key_id| json!({ "id": key_id, "error": "not found" }))
.collect::<Vec<_>>();
let mut success_count = 0usize;
for key_id in found_ids {
if state.delete_provider_catalog_key(&key_id).await? {
success_count += 1;
} else {
failed.push(json!({ "id": key_id, "error": "not found" }));
}
}
return Ok(Some(
Json(json!({
"success_count": success_count,
"failed_count": failed.len(),
"failed": failed,
}))
.into_response(),
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("clear_oauth_invalid")
&& request_context.request_method == http::Method::POST
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
&& request_context
.request_path
.ends_with("/clear-oauth-invalid")
{
let Some(key_id) = admin_clear_oauth_invalid_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
if key.oauth_invalid_at_unix_secs.is_none() {
return Ok(Some(
Json(json!({
"message": "该 Key 当前无失效标记,无需清除"
}))
.into_response(),
));
}
state
.clear_provider_catalog_key_oauth_invalid_marker(&key_id)
.await?;
return Ok(Some(
Json(json!({
"message": "已清除 OAuth 失效标记"
}))
.into_response(),
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("refresh_quota")
&& request_context.request_method == http::Method::POST
&& request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
&& request_context.request_path.ends_with("/refresh-quota")
{
let Some(provider_id) = admin_provider_id_for_refresh_quota(&request_context.request_path)
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
));
};
let normalized_provider_type = provider.provider_type.trim().to_ascii_lowercase();
let payload = if let Some(request_body) = request_body {
match serde_json::from_slice::<AdminProviderQuotaRefreshRequest>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
}
} else {
AdminProviderQuotaRefreshRequest { key_ids: None }
};
let raw_key_ids = payload.key_ids;
let selected_key_ids = normalize_string_id_list(raw_key_ids.clone());
let explicit_key_ids_requested = raw_key_ids.is_some();
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let endpoint = match normalized_provider_type.as_str() {
"codex" => endpoints.into_iter().find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("openai:cli")
}),
"antigravity" => endpoints.into_iter().find(|endpoint| {
endpoint.is_active
&& (endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:chat")
|| endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:cli"))
}),
"kiro" => endpoints
.iter()
.find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("claude:cli")
})
.cloned()
.or_else(|| endpoints.into_iter().find(|endpoint| endpoint.is_active)),
_ => return Ok(None),
};
let Some(endpoint) = endpoint else {
let detail = match normalized_provider_type.as_str() {
"codex" => "找不到有效的 openai:cli 端点",
"antigravity" => "找不到有效的 gemini:chat/gemini:cli 端点",
"kiro" => "找不到有效的 Kiro 端点",
_ => "找不到有效端点",
};
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
};
let mut keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
keys = if let Some(selected_key_ids) = selected_key_ids.as_ref() {
if selected_key_ids.is_empty() {
Vec::new()
} else {
let selected = selected_key_ids.iter().cloned().collect::<BTreeSet<_>>();
keys.into_iter()
.filter(|key| selected.contains(&key.id))
.collect()
}
} else {
keys.into_iter()
.filter(|key| {
key.is_active
|| key
.oauth_invalid_reason
.as_deref()
.map(str::trim)
.is_some_and(|value| value.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX))
})
.collect()
};
if explicit_key_ids_requested && selected_key_ids.is_none() {
return Ok(Some(
Json(json!({
"success": 0,
"failed": 0,
"total": 0,
"results": [],
"message": "未提供可刷新的 Key",
"auto_removed": 0,
}))
.into_response(),
));
}
if keys.is_empty() {
let message = if explicit_key_ids_requested {
"未提供可刷新的 Key"
} else {
"没有可刷新的 Key"
};
return Ok(Some(
Json(json!({
"success": 0,
"failed": 0,
"total": 0,
"results": [],
"message": message,
"auto_removed": 0,
}))
.into_response(),
));
}
let Some(payload) = (match normalized_provider_type.as_str() {
"codex" => {
refresh_codex_provider_quota_locally(state, &provider, &endpoint, keys).await?
}
"kiro" => {
refresh_kiro_provider_quota_locally(state, &provider, &endpoint, keys).await?
}
"antigravity" => {
refresh_antigravity_provider_quota_locally(state, &provider, &endpoint, keys)
.await?
}
_ => None,
}) else {
return Ok(None);
};
return Ok(Some(Json(payload).into_response()));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("create_provider_key")
&& request_context.request_method == http::Method::POST
&& request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
&& request_context.request_path.ends_with("/keys")
{
let Some(provider_id) = admin_provider_id_for_keys(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
if !state.has_provider_catalog_data_reader() {
return Ok(None);
}
let payload = match serde_json::from_slice::<AdminProviderKeyCreateRequest>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
));
};
let record = match build_admin_create_provider_key_record(state, &provider, payload).await {
Ok(record) => record,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
let Some(created) = state.create_provider_catalog_key(&record).await? else {
return Ok(None);
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
return Ok(Some(
Json(build_admin_provider_key_response(
state,
&created,
now_unix_secs,
))
.into_response(),
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("list_provider_keys")
&& request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
&& request_context.request_path.ends_with("/keys")
{
let Some(provider_id) = admin_provider_id_for_keys(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let skip = query_param_value(request_context.request_query_string.as_deref(), "skip")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let limit = query_param_value(request_context.request_query_string.as_deref(), "limit")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(100);
return Ok(Some(
match build_admin_provider_keys_payload(state, &provider_id, skip, limit).await {
Some(payload) => Json(payload).into_response(),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
},
));
if let Some(response) = quota::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
Ok(None)

View File

@@ -0,0 +1,426 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::paths::{
admin_clear_oauth_invalid_key_id, admin_provider_id_for_keys, admin_update_key_id,
};
use crate::handlers::admin::provider::shared::payloads::{
AdminProviderKeyBatchDeleteRequest, AdminProviderKeyCreateRequest,
AdminProviderKeyUpdateRequest,
};
use crate::handlers::admin::shared::build_admin_provider_key_response;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::collections::BTreeSet;
use std::time::{SystemTime, UNIX_EPOCH};
use super::super::write::keys::{
build_admin_create_provider_key_record, build_admin_update_provider_key_record,
};
pub(super) async fn maybe_handle(
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("endpoints_manage")
&& decision.route_kind.as_deref() == Some("update_key")
&& request_context.request_method == http::Method::PUT
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
{
let Some(key_id) = admin_update_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
if !state.has_provider_catalog_data_reader() {
return Ok(None);
}
let raw_value = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(value) => value,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(raw_payload) = raw_value.as_object().cloned() else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
};
let payload = match serde_json::from_value::<AdminProviderKeyUpdateRequest>(raw_value) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(existing_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&existing_key.provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {} 不存在", existing_key.provider_id) })),
)
.into_response(),
));
};
let updated_record = match build_admin_update_provider_key_record(
state,
&provider,
&existing_key,
&raw_payload,
payload,
)
.await
{
Ok(record) => record,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
let Some(updated) = state.update_provider_catalog_key(&updated_record).await? else {
return Ok(None);
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
return Ok(Some(
Json(build_admin_provider_key_response(
state,
&updated,
now_unix_secs,
))
.into_response(),
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("delete_key")
&& request_context.request_method == http::Method::DELETE
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
{
let Some(key_id) = admin_update_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(_existing_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
if !state.delete_provider_catalog_key(&key_id).await? {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
}
return Ok(Some(
Json(json!({
"message": format!("Key {key_id} 已删除")
}))
.into_response(),
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("batch_delete_keys")
&& request_context.request_method == http::Method::POST
&& request_context.request_path == "/api/admin/endpoints/keys/batch-delete"
{
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
let payload =
match serde_json::from_slice::<AdminProviderKeyBatchDeleteRequest>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
if payload.ids.len() > 100 {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "ids 最多 100 个" })),
)
.into_response(),
));
}
if payload.ids.is_empty() {
return Ok(Some(
Json(json!({
"success_count": 0,
"failed_count": 0,
"failed": []
}))
.into_response(),
));
}
let found_keys = state
.read_provider_catalog_keys_by_ids(&payload.ids)
.await?;
let found_ids = found_keys
.iter()
.map(|key| key.id.clone())
.collect::<BTreeSet<_>>();
let mut failed = payload
.ids
.iter()
.filter(|key_id| !found_ids.contains(*key_id))
.map(|key_id| json!({ "id": key_id, "error": "not found" }))
.collect::<Vec<_>>();
let mut success_count = 0usize;
for key_id in found_ids {
if state.delete_provider_catalog_key(&key_id).await? {
success_count += 1;
} else {
failed.push(json!({ "id": key_id, "error": "not found" }));
}
}
return Ok(Some(
Json(json!({
"success_count": success_count,
"failed_count": failed.len(),
"failed": failed,
}))
.into_response(),
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("clear_oauth_invalid")
&& request_context.request_method == http::Method::POST
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
&& request_context
.request_path
.ends_with("/clear-oauth-invalid")
{
let Some(key_id) = admin_clear_oauth_invalid_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
if key.oauth_invalid_at_unix_secs.is_none() {
return Ok(Some(
Json(json!({
"message": "该 Key 当前无失效标记,无需清除"
}))
.into_response(),
));
}
state
.clear_provider_catalog_key_oauth_invalid_marker(&key_id)
.await?;
return Ok(Some(
Json(json!({
"message": "已清除 OAuth 失效标记"
}))
.into_response(),
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("create_provider_key")
&& request_context.request_method == http::Method::POST
&& request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
&& request_context.request_path.ends_with("/keys")
{
let Some(provider_id) = admin_provider_id_for_keys(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
if !state.has_provider_catalog_data_reader() {
return Ok(None);
}
let payload = match serde_json::from_slice::<AdminProviderKeyCreateRequest>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
));
};
let record = match build_admin_create_provider_key_record(state, &provider, payload).await {
Ok(record) => record,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
let Some(created) = state.create_provider_catalog_key(&record).await? else {
return Ok(None);
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
return Ok(Some(
Json(build_admin_provider_key_response(
state,
&created,
now_unix_secs,
))
.into_response(),
));
}
Ok(None)
}

View File

@@ -0,0 +1,209 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::paths::admin_provider_id_for_refresh_quota;
use crate::handlers::admin::provider::shared::payloads::{
AdminProviderQuotaRefreshRequest, OAUTH_ACCOUNT_BLOCK_PREFIX,
};
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::collections::BTreeSet;
use super::super::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally;
use super::super::oauth::quota::codex::refresh_codex_provider_quota_locally;
use super::super::oauth::quota::kiro::refresh_kiro_provider_quota_locally;
use super::super::oauth::quota::shared::normalize_string_id_list;
pub(super) async fn maybe_handle(
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("endpoints_manage")
|| decision.route_kind.as_deref() != Some("refresh_quota")
|| request_context.request_method != http::Method::POST
|| !request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
|| !request_context.request_path.ends_with("/refresh-quota")
{
return Ok(None);
}
let Some(provider_id) = admin_provider_id_for_refresh_quota(&request_context.request_path)
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
));
};
let normalized_provider_type = provider.provider_type.trim().to_ascii_lowercase();
let payload = if let Some(request_body) = request_body {
match serde_json::from_slice::<AdminProviderQuotaRefreshRequest>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
}
} else {
AdminProviderQuotaRefreshRequest { key_ids: None }
};
let raw_key_ids = payload.key_ids;
let selected_key_ids = normalize_string_id_list(raw_key_ids.clone());
let explicit_key_ids_requested = raw_key_ids.is_some();
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let endpoint = match normalized_provider_type.as_str() {
"codex" => endpoints.into_iter().find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("openai:cli")
}),
"antigravity" => endpoints.into_iter().find(|endpoint| {
endpoint.is_active
&& (endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:chat")
|| endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:cli"))
}),
"kiro" => endpoints
.iter()
.find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("claude:cli")
})
.cloned()
.or_else(|| endpoints.into_iter().find(|endpoint| endpoint.is_active)),
_ => return Ok(None),
};
let Some(endpoint) = endpoint else {
let detail = match normalized_provider_type.as_str() {
"codex" => "找不到有效的 openai:cli 端点",
"antigravity" => "找不到有效的 gemini:chat/gemini:cli 端点",
"kiro" => "找不到有效的 Kiro 端点",
_ => "找不到有效端点",
};
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
};
let mut keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
keys = if let Some(selected_key_ids) = selected_key_ids.as_ref() {
if selected_key_ids.is_empty() {
Vec::new()
} else {
let selected = selected_key_ids.iter().cloned().collect::<BTreeSet<_>>();
keys.into_iter()
.filter(|key| selected.contains(&key.id))
.collect()
}
} else {
keys.into_iter()
.filter(|key| {
key.is_active
|| key
.oauth_invalid_reason
.as_deref()
.map(str::trim)
.is_some_and(|value| value.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX))
})
.collect()
};
if explicit_key_ids_requested && selected_key_ids.is_none() {
return Ok(Some(
Json(json!({
"success": 0,
"failed": 0,
"total": 0,
"results": [],
"message": "未提供可刷新的 Key",
"auto_removed": 0,
}))
.into_response(),
));
}
if keys.is_empty() {
let message = if explicit_key_ids_requested {
"未提供可刷新的 Key"
} else {
"没有可刷新的 Key"
};
return Ok(Some(
Json(json!({
"success": 0,
"failed": 0,
"total": 0,
"results": [],
"message": message,
"auto_removed": 0,
}))
.into_response(),
));
}
let Some(payload) = (match normalized_provider_type.as_str() {
"codex" => refresh_codex_provider_quota_locally(state, &provider, &endpoint, keys).await?,
"kiro" => refresh_kiro_provider_quota_locally(state, &provider, &endpoint, keys).await?,
"antigravity" => {
refresh_antigravity_provider_quota_locally(state, &provider, &endpoint, keys).await?
}
_ => None,
}) else {
return Ok(None);
};
Ok(Some(Json(payload).into_response()))
}

View File

@@ -0,0 +1,168 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::paths::{
admin_export_key_id, admin_provider_id_for_keys, admin_reveal_key_id,
};
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value};
use crate::handlers::public::build_admin_keys_grouped_by_format_payload;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use super::super::write::keys::build_admin_provider_keys_payload;
use super::super::write::reveal::{build_admin_export_key_payload, build_admin_reveal_key_payload};
pub(super) async fn maybe_handle(
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("endpoints_manage")
&& decision.route_kind.as_deref() == Some("keys_grouped_by_format")
&& request_context.request_path == "/api/admin/endpoints/keys/grouped-by-format"
{
let Some(payload) = build_admin_keys_grouped_by_format_payload(state).await else {
return Ok(None);
};
return Ok(Some(Json(payload).into_response()));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("reveal_key")
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
&& request_context.request_path.ends_with("/reveal")
{
let Some(key_id) = admin_reveal_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
return Ok(Some(match build_admin_reveal_key_payload(state, &key) {
Ok(payload) => attach_admin_audit_response(
Json(payload).into_response(),
"admin_provider_key_revealed",
"reveal_provider_key",
"provider_key",
&key_id,
),
Err(detail) => (
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
}));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("export_key")
&& request_context
.request_path
.starts_with("/api/admin/endpoints/keys/")
&& request_context.request_path.ends_with("/export")
{
let Some(key_id) = admin_export_key_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Key 不存在" })),
)
.into_response(),
));
};
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
)
.into_response(),
));
};
return Ok(Some(
match build_admin_export_key_payload(state, &key).await {
Ok(payload) => attach_admin_audit_response(
Json(payload).into_response(),
"admin_provider_key_exported",
"export_provider_key",
"provider_key_export",
&key_id,
),
Err(detail) => (
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
},
));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("list_provider_keys")
&& request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
&& request_context.request_path.ends_with("/keys")
{
let Some(provider_id) = admin_provider_id_for_keys(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let skip = query_param_value(request_context.request_query_string.as_deref(), "skip")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let limit = query_param_value(request_context.request_query_string.as_deref(), "limit")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(100);
return Ok(Some(
match build_admin_provider_keys_payload(state, &provider_id, skip, limit).await {
Some(payload) => Json(payload).into_response(),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
},
));
}
Ok(None)
}

View File

@@ -0,0 +1,116 @@
use super::builders::build_admin_create_provider_endpoint_record;
use super::extractors::admin_provider_id_for_endpoints;
use super::payloads::{build_admin_provider_endpoint_response, AdminProviderEndpointCreateRequest};
use super::support::build_admin_endpoints_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
pub(super) async fn maybe_handle(
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("endpoints_manage")
|| decision.route_kind.as_deref() != Some("create_endpoint")
|| request_context.request_method != http::Method::POST
|| !request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
|| !request_context.request_path.ends_with("/endpoints")
{
return Ok(None);
}
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(provider_id) = admin_provider_id_for_endpoints(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
let payload = match serde_json::from_slice::<AdminProviderEndpointCreateRequest>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
));
};
let record = match build_admin_create_provider_endpoint_record(state, &provider, payload).await
{
Ok(record) => record,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
let Some(created) = state.create_provider_catalog_endpoint(&record).await? else {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
Ok(Some(
Json(build_admin_provider_endpoint_response(
&created,
&provider.name,
0,
0,
now_unix_secs,
))
.into_response(),
))
}

View File

@@ -0,0 +1,62 @@
use super::extractors::admin_default_body_rules_api_format;
use crate::api::ai::admin_default_body_rules_for_signature;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::query_param_value;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn maybe_handle(
_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("endpoints_manage")
|| decision.route_kind.as_deref() != Some("default_body_rules")
|| !request_context
.request_path
.starts_with("/api/admin/endpoints/defaults/")
|| !request_context.request_path.ends_with("/body-rules")
{
return Ok(None);
}
let Some(api_format) = admin_default_body_rules_api_format(&request_context.request_path)
else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "无效的 api_format" })),
)
.into_response(),
));
};
let provider_type = query_param_value(
request_context.request_query_string.as_deref(),
"provider_type",
);
Ok(Some(
match admin_default_body_rules_for_signature(&api_format, provider_type.as_deref()) {
Some((normalized_api_format, body_rules)) => Json(json!({
"api_format": normalized_api_format,
"body_rules": body_rules,
}))
.into_response(),
None => (
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": format!("无效的 api_format: {api_format}") })),
)
.into_response(),
},
))
}

View File

@@ -0,0 +1,113 @@
use super::extractors::admin_endpoint_id;
use super::payloads::key_api_formats_without_entry;
use super::support::build_admin_endpoints_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
pub(super) async fn maybe_handle(
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("endpoints_manage")
|| decision.route_kind.as_deref() != Some("delete_endpoint")
|| request_context.request_method != http::Method::DELETE
|| !request_context
.request_path
.starts_with("/api/admin/endpoints/")
{
return Ok(None);
}
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Endpoint 不存在" })),
)
.into_response(),
));
};
let Some(existing_endpoint) = state
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
)
.into_response(),
));
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
let keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(
&existing_endpoint.provider_id,
))
.await
.unwrap_or_default();
let mut affected_keys_count = 0usize;
for key in keys {
let Some(updated_formats) =
key_api_formats_without_entry(&key, existing_endpoint.api_format.as_str())
else {
continue;
};
let mut updated_key = key.clone();
updated_key.api_formats = Some(serde_json::Value::Array(
updated_formats
.into_iter()
.map(serde_json::Value::String)
.collect(),
));
updated_key.updated_at_unix_secs = Some(now_unix_secs);
if state
.update_provider_catalog_key(&updated_key)
.await?
.is_none()
{
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
affected_keys_count += 1;
}
if !state.delete_provider_catalog_endpoint(&endpoint_id).await? {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
)
.into_response(),
));
}
Ok(Some(
Json(json!({
"message": format!("Endpoint {endpoint_id} 已删除"),
"affected_keys_count": affected_keys_count,
}))
.into_response(),
))
}

View File

@@ -0,0 +1,56 @@
use super::builders::build_admin_endpoint_payload;
use super::extractors::admin_endpoint_id;
use super::support::build_admin_endpoints_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn maybe_handle(
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("endpoints_manage")
|| decision.route_kind.as_deref() != Some("get_endpoint")
|| !request_context
.request_path
.starts_with("/api/admin/endpoints/")
{
return Ok(None);
}
if !state.has_provider_catalog_data_reader() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Endpoint 不存在" })),
)
.into_response(),
));
};
Ok(Some(
match build_admin_endpoint_payload(state, &endpoint_id).await {
Some(payload) => Json(payload).into_response(),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
)
.into_response(),
},
))
}

View File

@@ -0,0 +1,65 @@
use super::builders::build_admin_provider_endpoints_payload;
use super::extractors::admin_provider_id_for_endpoints;
use super::support::build_admin_endpoints_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::query_param_value;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn maybe_handle(
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("endpoints_manage")
|| decision.route_kind.as_deref() != Some("list_provider_endpoints")
|| !request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
|| !request_context.request_path.ends_with("/endpoints")
{
return Ok(None);
}
if !state.has_provider_catalog_data_reader() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(provider_id) = admin_provider_id_for_endpoints(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let skip = query_param_value(request_context.request_query_string.as_deref(), "skip")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let limit = query_param_value(request_context.request_query_string.as_deref(), "limit")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(100);
Ok(Some(
match build_admin_provider_endpoints_payload(state, &provider_id, skip, limit).await {
Some(payload) => Json(payload).into_response(),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
},
))
}

View File

@@ -1,477 +1,48 @@
mod builders;
mod create;
mod defaults;
mod delete;
mod detail;
mod extractors;
mod list;
mod payloads;
mod support;
mod update;
use self::builders::{
build_admin_create_provider_endpoint_record, build_admin_endpoint_payload,
build_admin_provider_endpoints_payload, build_admin_update_provider_endpoint_record,
};
use self::extractors::{
admin_default_body_rules_api_format, admin_endpoint_id, admin_provider_id_for_endpoints,
};
use self::payloads::endpoint_key_counts_by_format;
use self::payloads::{
build_admin_provider_endpoint_response, key_api_formats_without_entry,
AdminProviderEndpointCreateRequest, AdminProviderEndpointUpdateRequest,
};
use crate::api::ai::admin_default_body_rules_for_signature;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::query_param_value;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
response::Response,
};
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
const ADMIN_ENDPOINTS_DATA_UNAVAILABLE_DETAIL: &str = "Admin endpoint data unavailable";
fn build_admin_endpoints_data_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": ADMIN_ENDPOINTS_DATA_UNAVAILABLE_DETAIL })),
)
.into_response()
}
pub(crate) async fn maybe_build_local_admin_endpoints_routes_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("endpoints_manage")
&& decision.route_kind.as_deref() == Some("list_provider_endpoints")
&& request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
&& request_context.request_path.ends_with("/endpoints")
{
if !state.has_provider_catalog_data_reader() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(provider_id) = admin_provider_id_for_endpoints(&request_context.request_path)
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let skip = query_param_value(request_context.request_query_string.as_deref(), "skip")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let limit = query_param_value(request_context.request_query_string.as_deref(), "limit")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(100);
return Ok(Some(
match build_admin_provider_endpoints_payload(state, &provider_id, skip, limit).await {
Some(payload) => Json(payload).into_response(),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
},
));
if let Some(response) = create::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("create_endpoint")
&& request_context.request_method == http::Method::POST
&& request_context
.request_path
.starts_with("/api/admin/endpoints/providers/")
&& request_context.request_path.ends_with("/endpoints")
{
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(provider_id) = admin_provider_id_for_endpoints(&request_context.request_path)
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
let payload =
match serde_json::from_slice::<AdminProviderEndpointCreateRequest>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
));
};
let record =
match build_admin_create_provider_endpoint_record(state, &provider, payload).await {
Ok(record) => record,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
let Some(created) = state.create_provider_catalog_endpoint(&record).await? else {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
return Ok(Some(
Json(build_admin_provider_endpoint_response(
&created,
&provider.name,
0,
0,
now_unix_secs,
))
.into_response(),
));
if let Some(response) = update::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("update_endpoint")
&& request_context.request_method == http::Method::PUT
&& request_context
.request_path
.starts_with("/api/admin/endpoints/")
{
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Endpoint 不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
let raw_value = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(value) => value,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(raw_payload) = raw_value.as_object().cloned() else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
};
let payload = match serde_json::from_value::<AdminProviderEndpointUpdateRequest>(raw_value)
{
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(existing_endpoint) = state
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
)
.into_response(),
));
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(
&existing_endpoint.provider_id,
))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {} 不存在", existing_endpoint.provider_id) })),
)
.into_response(),
));
};
let updated_record = match build_admin_update_provider_endpoint_record(
state,
&provider,
&existing_endpoint,
&raw_payload,
payload,
)
.await
{
Ok(record) => record,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
let Some(updated) = state
.update_provider_catalog_endpoint(&updated_record)
.await?
else {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
let keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
.await
.unwrap_or_default();
let (total_keys_by_format, active_keys_by_format) = endpoint_key_counts_by_format(&keys);
return Ok(Some(
Json(build_admin_provider_endpoint_response(
&updated,
&provider.name,
total_keys_by_format
.get(updated.api_format.as_str())
.copied()
.unwrap_or(0),
active_keys_by_format
.get(updated.api_format.as_str())
.copied()
.unwrap_or(0),
now_unix_secs,
))
.into_response(),
));
if let Some(response) = delete::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("delete_endpoint")
&& request_context.request_method == http::Method::DELETE
&& request_context
.request_path
.starts_with("/api/admin/endpoints/")
{
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Endpoint 不存在" })),
)
.into_response(),
));
};
let Some(existing_endpoint) = state
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
)
.into_response(),
));
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
let keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(
&existing_endpoint.provider_id,
))
.await
.unwrap_or_default();
let mut affected_keys_count = 0usize;
for key in keys {
let Some(updated_formats) =
key_api_formats_without_entry(&key, existing_endpoint.api_format.as_str())
else {
continue;
};
let mut updated_key = key.clone();
updated_key.api_formats = Some(serde_json::Value::Array(
updated_formats
.into_iter()
.map(serde_json::Value::String)
.collect(),
));
updated_key.updated_at_unix_secs = Some(now_unix_secs);
if state
.update_provider_catalog_key(&updated_key)
.await?
.is_none()
{
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
affected_keys_count += 1;
}
if !state.delete_provider_catalog_endpoint(&endpoint_id).await? {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
)
.into_response(),
));
}
return Ok(Some(
Json(json!({
"message": format!("Endpoint {endpoint_id} 已删除"),
"affected_keys_count": affected_keys_count,
}))
.into_response(),
));
if let Some(response) = list::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("get_endpoint")
&& request_context
.request_path
.starts_with("/api/admin/endpoints/")
{
if !state.has_provider_catalog_data_reader() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Endpoint 不存在" })),
)
.into_response(),
));
};
return Ok(Some(
match build_admin_endpoint_payload(state, &endpoint_id).await {
Some(payload) => Json(payload).into_response(),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
)
.into_response(),
},
));
if let Some(response) = detail::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
if decision.route_family.as_deref() == Some("endpoints_manage")
&& decision.route_kind.as_deref() == Some("default_body_rules")
&& request_context
.request_path
.starts_with("/api/admin/endpoints/defaults/")
&& request_context.request_path.ends_with("/body-rules")
{
let Some(api_format) = admin_default_body_rules_api_format(&request_context.request_path)
else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "无效的 api_format" })),
)
.into_response(),
));
};
let provider_type = query_param_value(
request_context.request_query_string.as_deref(),
"provider_type",
);
return Ok(Some(
match admin_default_body_rules_for_signature(&api_format, provider_type.as_deref()) {
Some((normalized_api_format, body_rules)) => Json(json!({
"api_format": normalized_api_format,
"body_rules": body_rules,
}))
.into_response(),
None => (
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": format!("无效的 api_format: {api_format}") })),
)
.into_response(),
},
));
if let Some(response) = defaults::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
Ok(None)

View File

@@ -0,0 +1,17 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) const ADMIN_ENDPOINTS_DATA_UNAVAILABLE_DETAIL: &str = "Admin endpoint data unavailable";
pub(super) fn build_admin_endpoints_data_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": ADMIN_ENDPOINTS_DATA_UNAVAILABLE_DETAIL })),
)
.into_response()
}

View File

@@ -0,0 +1,176 @@
use super::builders::build_admin_update_provider_endpoint_record;
use super::extractors::admin_endpoint_id;
use super::payloads::{
build_admin_provider_endpoint_response, endpoint_key_counts_by_format,
AdminProviderEndpointUpdateRequest,
};
use super::support::build_admin_endpoints_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
pub(super) async fn maybe_handle(
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("endpoints_manage")
|| decision.route_kind.as_deref() != Some("update_endpoint")
|| request_context.request_method != http::Method::PUT
|| !request_context
.request_path
.starts_with("/api/admin/endpoints/")
{
return Ok(None);
}
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
}
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Endpoint 不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体不能为空" })),
)
.into_response(),
));
};
let raw_value = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(value) => value,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(raw_payload) = raw_value.as_object().cloned() else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
};
let payload = match serde_json::from_value::<AdminProviderEndpointUpdateRequest>(raw_value) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
)
.into_response(),
));
}
};
let Some(existing_endpoint) = state
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
)
.into_response(),
));
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(
&existing_endpoint.provider_id,
))
.await?
.into_iter()
.next()
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {} 不存在", existing_endpoint.provider_id) })),
)
.into_response(),
));
};
let updated_record = match build_admin_update_provider_endpoint_record(
state,
&provider,
&existing_endpoint,
&raw_payload,
payload,
)
.await
{
Ok(record) => record,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
let Some(updated) = state
.update_provider_catalog_endpoint(&updated_record)
.await?
else {
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
let keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
.await
.unwrap_or_default();
let (total_keys_by_format, active_keys_by_format) = endpoint_key_counts_by_format(&keys);
Ok(Some(
Json(build_admin_provider_endpoint_response(
&updated,
&provider.name,
total_keys_by_format
.get(updated.api_format.as_str())
.copied()
.unwrap_or(0),
active_keys_by_format
.get(updated.api_format.as_str())
.copied()
.unwrap_or(0),
now_unix_secs,
))
.into_response(),
))
}

View File

@@ -1,9 +1,8 @@
use super::super::super::model::build_admin_batch_assign_global_models_payload;
use super::write::build_admin_batch_assign_global_models_payload;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
admin_provider_assign_global_models_path, AdminBatchAssignGlobalModelsRequest,
};
use crate::handlers::admin::provider::shared::paths::admin_provider_assign_global_models_path;
use crate::handlers::admin::provider::shared::payloads::AdminBatchAssignGlobalModelsRequest;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -1,7 +1,7 @@
use super::super::super::model::build_admin_provider_available_source_models_payload;
use super::write::build_admin_provider_available_source_models_payload;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::admin_provider_available_source_models_path;
use crate::handlers::admin::provider::shared::paths::admin_provider_available_source_models_path;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -1,12 +1,9 @@
use super::super::super::model::{
admin_provider_model_name_exists, build_admin_provider_model_create_record,
build_admin_provider_model_response,
};
use super::payloads::{admin_provider_model_name_exists, build_admin_provider_model_response};
use super::write::build_admin_provider_model_create_record;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
admin_provider_models_batch_path, AdminProviderModelCreateRequest,
};
use crate::handlers::admin::provider::shared::paths::admin_provider_models_batch_path;
use crate::handlers::admin::provider::shared::payloads::AdminProviderModelCreateRequest;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -1,11 +1,9 @@
use super::super::super::model::{
build_admin_provider_model_create_record, build_admin_provider_model_response,
};
use super::payloads::build_admin_provider_model_response;
use super::write::build_admin_provider_model_create_record;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
admin_provider_id_for_models_list, AdminProviderModelCreateRequest,
};
use crate::handlers::admin::provider::shared::paths::admin_provider_id_for_models_list;
use crate::handlers::admin::provider::shared::payloads::AdminProviderModelCreateRequest;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -1,6 +1,6 @@
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::admin_provider_model_route_parts;
use crate::handlers::admin::provider::shared::paths::admin_provider_model_route_parts;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -1,7 +1,7 @@
use super::super::super::model::build_admin_provider_model_payload;
use super::payloads::build_admin_provider_model_payload;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::admin_provider_model_route_parts;
use crate::handlers::admin::provider::shared::paths::admin_provider_model_route_parts;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -1,9 +1,8 @@
use super::super::super::model::build_admin_import_provider_models_payload;
use super::write::build_admin_import_provider_models_payload;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
admin_provider_import_models_path, AdminImportProviderModelsRequest,
};
use crate::handlers::admin::provider::shared::paths::admin_provider_import_models_path;
use crate::handlers::admin::provider::shared::payloads::AdminImportProviderModelsRequest;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -1,7 +1,7 @@
use super::super::super::model::build_admin_provider_models_payload;
use super::payloads::build_admin_provider_models_payload;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::admin_provider_id_for_models_list;
use crate::handlers::admin::provider::shared::paths::admin_provider_id_for_models_list;
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
use crate::{AppState, GatewayError};
use axum::{

View File

@@ -12,7 +12,9 @@ mod delete;
mod detail;
mod import;
mod list;
mod payloads;
mod update;
mod write;
pub(crate) async fn maybe_build_local_admin_provider_models_response(
state: &AppState,

View File

@@ -0,0 +1,273 @@
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, StoredAdminProviderModel,
};
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
fn model_tiered_pricing_first_tier_value(
tiered_pricing: Option<&serde_json::Value>,
field_name: &str,
) -> Option<f64> {
tiered_pricing
.and_then(|value| value.get("tiers"))
.and_then(serde_json::Value::as_array)
.and_then(|tiers| tiers.first())
.and_then(|tier| tier.get(field_name))
.and_then(serde_json::Value::as_f64)
}
fn model_effective_capability(
explicit: Option<bool>,
global_model_config: Option<&serde_json::Value>,
config_key: &str,
) -> bool {
explicit.unwrap_or_else(|| {
global_model_config
.and_then(|value| value.get(config_key))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
}
fn merge_json_values(base: &mut serde_json::Value, overlay: serde_json::Value) {
match (base, overlay) {
(serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
for (key, value) in overlay_map {
match base_map.get_mut(&key) {
Some(existing) => merge_json_values(existing, value),
None => {
base_map.insert(key, value);
}
}
}
}
(base, overlay) => *base = overlay,
}
}
fn merge_admin_provider_model_effective_config(
model: &StoredAdminProviderModel,
) -> Option<serde_json::Value> {
let mut merged = match model.global_model_config.clone() {
Some(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
Some(other) => other,
None => serde_json::Value::Object(serde_json::Map::new()),
};
if let Some(config) = model.config.clone() {
merge_json_values(&mut merged, config);
}
match merged {
serde_json::Value::Null => None,
serde_json::Value::Object(ref map) if map.is_empty() => None,
value => Some(value),
}
}
fn timestamp_or_now(value: Option<u64>, now_unix_secs: u64) -> serde_json::Value {
unix_secs_to_rfc3339(value.unwrap_or(now_unix_secs))
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null)
}
pub(super) fn admin_provider_model_effective_input_price(
model: &StoredAdminProviderModel,
) -> Option<f64> {
model_tiered_pricing_first_tier_value(model.tiered_pricing.as_ref(), "input_price_per_1m")
.or_else(|| {
model_tiered_pricing_first_tier_value(
model.global_model_default_tiered_pricing.as_ref(),
"input_price_per_1m",
)
})
}
pub(super) fn admin_provider_model_effective_output_price(
model: &StoredAdminProviderModel,
) -> Option<f64> {
model_tiered_pricing_first_tier_value(model.tiered_pricing.as_ref(), "output_price_per_1m")
.or_else(|| {
model_tiered_pricing_first_tier_value(
model.global_model_default_tiered_pricing.as_ref(),
"output_price_per_1m",
)
})
}
pub(super) fn admin_provider_model_effective_capability(
model: &StoredAdminProviderModel,
capability: &str,
) -> bool {
match capability {
"vision" => model_effective_capability(
model.supports_vision,
model.global_model_config.as_ref(),
"vision",
),
"function_calling" => model_effective_capability(
model.supports_function_calling,
model.global_model_config.as_ref(),
"function_calling",
),
"streaming" => model_effective_capability(
model.supports_streaming,
model.global_model_config.as_ref(),
"streaming",
),
"extended_thinking" => model_effective_capability(
model.supports_extended_thinking,
model.global_model_config.as_ref(),
"extended_thinking",
),
"image_generation" => model_effective_capability(
model.supports_image_generation,
model.global_model_config.as_ref(),
"image_generation",
),
_ => false,
}
}
pub(super) fn build_admin_provider_model_response(
model: &StoredAdminProviderModel,
now_unix_secs: u64,
) -> serde_json::Value {
let effective_tiered_pricing = model
.tiered_pricing
.clone()
.or_else(|| model.global_model_default_tiered_pricing.clone());
let effective_config = merge_admin_provider_model_effective_config(model);
json!({
"id": &model.id,
"provider_id": &model.provider_id,
"global_model_id": &model.global_model_id,
"provider_model_name": &model.provider_model_name,
"provider_model_mappings": model.provider_model_mappings.clone(),
"price_per_request": model.price_per_request,
"tiered_pricing": model.tiered_pricing.clone(),
"effective_tiered_pricing": effective_tiered_pricing,
"effective_input_price": admin_provider_model_effective_input_price(model),
"effective_output_price": admin_provider_model_effective_output_price(model),
"effective_price_per_request": model
.price_per_request
.or(model.global_model_default_price_per_request),
"supports_vision": model.supports_vision,
"supports_function_calling": model.supports_function_calling,
"supports_streaming": model.supports_streaming,
"supports_extended_thinking": model.supports_extended_thinking,
"supports_image_generation": model.supports_image_generation,
"effective_supports_vision": admin_provider_model_effective_capability(model, "vision"),
"effective_supports_function_calling": admin_provider_model_effective_capability(
model,
"function_calling",
),
"effective_supports_streaming": admin_provider_model_effective_capability(model, "streaming"),
"effective_supports_extended_thinking": admin_provider_model_effective_capability(
model,
"extended_thinking",
),
"effective_supports_image_generation": admin_provider_model_effective_capability(
model,
"image_generation",
),
"is_active": model.is_active,
"is_available": model.is_available,
"config": model.config.clone(),
"effective_config": effective_config,
"global_model_name": model.global_model_name.clone(),
"global_model_display_name": model.global_model_display_name.clone(),
"created_at": timestamp_or_now(model.created_at_unix_secs, now_unix_secs),
"updated_at": timestamp_or_now(model.updated_at_unix_secs, now_unix_secs),
})
}
pub(super) async fn build_admin_provider_models_payload(
state: &AppState,
provider_id: &str,
skip: usize,
limit: usize,
is_active: Option<bool>,
) -> Option<serde_json::Value> {
if !state.has_provider_catalog_data_reader() || !state.has_global_model_data_reader() {
return None;
}
let provider = state
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
.await
.ok()?
.into_iter()
.next()?;
let mut models = state
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: provider.id,
is_active,
offset: skip,
limit,
})
.await
.ok()?;
models.sort_by(|left, right| {
left.provider_model_name
.cmp(&right.provider_model_name)
.then_with(|| left.id.cmp(&right.id))
});
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
Some(serde_json::Value::Array(
models
.iter()
.map(|model| build_admin_provider_model_response(model, now_unix_secs))
.collect(),
))
}
pub(super) async fn build_admin_provider_model_payload(
state: &AppState,
provider_id: &str,
model_id: &str,
) -> Option<serde_json::Value> {
if !state.has_global_model_data_reader() {
return None;
}
let model = state
.get_admin_provider_model(provider_id, model_id)
.await
.ok()??;
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
Some(build_admin_provider_model_response(&model, now_unix_secs))
}
pub(super) async fn admin_provider_model_name_exists(
state: &AppState,
provider_id: &str,
provider_model_name: &str,
exclude_model_id: Option<&str>,
) -> Result<bool, GatewayError> {
let target = provider_model_name.trim();
if target.is_empty() {
return Ok(false);
}
let models = state
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: provider_id.to_string(),
is_active: None,
offset: 0,
limit: 10_000,
})
.await?;
Ok(models.into_iter().any(|model| {
model.provider_model_name == target
&& exclude_model_id.is_none_or(|exclude| model.id != exclude)
}))
}

View File

@@ -1,11 +1,9 @@
use super::super::super::model::{
build_admin_provider_model_response, build_admin_provider_model_update_record,
};
use super::payloads::build_admin_provider_model_response;
use super::write::build_admin_provider_model_update_record;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
admin_provider_model_route_parts, AdminProviderModelUpdateRequest,
};
use crate::handlers::admin::provider::shared::paths::admin_provider_model_route_parts;
use crate::handlers::admin::provider::shared::payloads::AdminProviderModelUpdateRequest;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},

View File

@@ -0,0 +1,472 @@
use super::payloads::{
admin_provider_model_effective_capability, admin_provider_model_effective_input_price,
admin_provider_model_effective_output_price, admin_provider_model_name_exists,
};
use crate::handlers::admin::provider::shared::payloads::{
AdminImportProviderModelsRequest, AdminProviderModelCreateRequest,
AdminProviderModelUpdateRequest,
};
use crate::handlers::admin::shared::{
normalize_json_array, normalize_json_object, normalize_string_list,
};
use crate::AppState;
use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, CreateAdminGlobalModelRecord, UpdateAdminGlobalModelRecord,
UpsertAdminProviderModelRecord,
};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
fn normalize_required_trimmed_string(value: &str, field_name: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{field_name} 不能为空"));
}
Ok(trimmed.to_string())
}
fn normalize_optional_price(value: Option<f64>, field_name: &str) -> Result<Option<f64>, String> {
let Some(value) = value else {
return Ok(None);
};
if !value.is_finite() || value < 0.0 {
return Err(format!("{field_name} 必须是非负数"));
}
Ok(Some(value))
}
async fn resolve_admin_global_model_by_id_or_err(
state: &AppState,
global_model_id: &str,
) -> Result<aether_data_contracts::repository::global_models::StoredAdminGlobalModel, String> {
state
.get_admin_global_model_by_id(global_model_id)
.await
.map_err(|err| format!("{err:?}"))?
.ok_or_else(|| format!("GlobalModel {global_model_id} 不存在"))
}
pub(super) async fn build_admin_provider_model_create_record(
state: &AppState,
provider_id: &str,
payload: AdminProviderModelCreateRequest,
) -> Result<UpsertAdminProviderModelRecord, String> {
let provider_model_name =
normalize_required_trimmed_string(&payload.provider_model_name, "provider_model_name")?;
if admin_provider_model_name_exists(state, provider_id, &provider_model_name, None)
.await
.map_err(|err| format!("{err:?}"))?
{
return Err(format!("模型 '{provider_model_name}' 已存在"));
}
let global_model_id =
normalize_required_trimmed_string(&payload.global_model_id, "global_model_id")?;
resolve_admin_global_model_by_id_or_err(state, &global_model_id).await?;
let price_per_request =
normalize_optional_price(payload.price_per_request, "price_per_request")?;
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
let provider_model_mappings =
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?;
let config = normalize_json_object(payload.config, "config")?;
UpsertAdminProviderModelRecord::new(
Uuid::new_v4().to_string(),
provider_id.to_string(),
global_model_id,
provider_model_name,
provider_model_mappings,
price_per_request,
tiered_pricing,
payload.supports_vision,
payload.supports_function_calling,
payload.supports_streaming,
payload.supports_extended_thinking,
None,
payload.is_active.unwrap_or(true),
true,
config,
)
.map_err(|err| err.to_string())
}
pub(super) async fn build_admin_provider_model_update_record(
state: &AppState,
existing: &aether_data_contracts::repository::global_models::StoredAdminProviderModel,
raw_payload: &serde_json::Map<String, serde_json::Value>,
payload: AdminProviderModelUpdateRequest,
) -> Result<UpsertAdminProviderModelRecord, String> {
let provider_model_name = if let Some(value) = raw_payload.get("provider_model_name") {
let Some(name) = payload.provider_model_name.as_deref() else {
return Err(if value.is_null() {
"provider_model_name 不能为空".to_string()
} else {
"provider_model_name 必须是字符串".to_string()
});
};
let name = normalize_required_trimmed_string(name, "provider_model_name")?;
if admin_provider_model_name_exists(state, &existing.provider_id, &name, Some(&existing.id))
.await
.map_err(|err| format!("{err:?}"))?
{
return Err(format!("模型 '{name}' 已存在"));
}
name
} else {
existing.provider_model_name.clone()
};
let global_model_id = if let Some(value) = raw_payload.get("global_model_id") {
let Some(global_model_id) = payload.global_model_id.as_deref() else {
return Err(if value.is_null() {
"global_model_id 不能为空".to_string()
} else {
"global_model_id 必须是字符串".to_string()
});
};
let global_model_id =
normalize_required_trimmed_string(global_model_id, "global_model_id")?;
resolve_admin_global_model_by_id_or_err(state, &global_model_id).await?;
global_model_id
} else {
existing.global_model_id.clone()
};
let price_per_request = if raw_payload.contains_key("price_per_request") {
normalize_optional_price(payload.price_per_request, "price_per_request")?
} else {
existing.price_per_request
};
let tiered_pricing = if raw_payload.contains_key("tiered_pricing") {
normalize_json_object(payload.tiered_pricing, "tiered_pricing")?
} else {
existing.tiered_pricing.clone()
};
let provider_model_mappings = if raw_payload.contains_key("provider_model_mappings") {
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?
} else {
existing.provider_model_mappings.clone()
};
let config = if raw_payload.contains_key("config") {
normalize_json_object(payload.config, "config")?
} else {
existing.config.clone()
};
UpsertAdminProviderModelRecord::new(
existing.id.clone(),
existing.provider_id.clone(),
global_model_id,
provider_model_name,
provider_model_mappings,
price_per_request,
tiered_pricing,
if raw_payload.contains_key("supports_vision") {
payload.supports_vision
} else {
existing.supports_vision
},
if raw_payload.contains_key("supports_function_calling") {
payload.supports_function_calling
} else {
existing.supports_function_calling
},
if raw_payload.contains_key("supports_streaming") {
payload.supports_streaming
} else {
existing.supports_streaming
},
if raw_payload.contains_key("supports_extended_thinking") {
payload.supports_extended_thinking
} else {
existing.supports_extended_thinking
},
existing.supports_image_generation,
payload.is_active.unwrap_or(existing.is_active),
payload.is_available.unwrap_or(existing.is_available),
config,
)
.map_err(|err| err.to_string())
}
pub(super) async fn build_admin_provider_available_source_models_payload(
state: &AppState,
provider_id: &str,
) -> Option<serde_json::Value> {
if !state.has_global_model_data_reader() || !state.has_provider_catalog_data_reader() {
return None;
}
let provider = state
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
.await
.ok()?
.into_iter()
.next()?;
let models = state
.list_admin_provider_available_source_models(&provider.id)
.await
.ok()?;
let mut by_global_model = BTreeMap::<
String,
aether_data_contracts::repository::global_models::StoredAdminProviderModel,
>::new();
for model in models {
by_global_model
.entry(model.global_model_id.clone())
.or_insert(model);
}
let mut payload_models = by_global_model
.into_values()
.map(|model| {
json!({
"global_model_name": model.global_model_name,
"display_name": model.global_model_display_name,
"provider_model_name": model.provider_model_name,
"model_id": model.id,
"price": {
"input_price_per_1m": admin_provider_model_effective_input_price(&model),
"output_price_per_1m": admin_provider_model_effective_output_price(&model),
"cache_creation_price_per_1m": serde_json::Value::Null,
"cache_read_price_per_1m": serde_json::Value::Null,
"price_per_request": model.price_per_request.or(model.global_model_default_price_per_request),
},
"capabilities": json!({
"supports_vision": admin_provider_model_effective_capability(&model, "vision"),
"supports_function_calling": admin_provider_model_effective_capability(&model, "function_calling"),
"supports_streaming": admin_provider_model_effective_capability(&model, "streaming"),
}),
"is_active": model.is_active,
})
})
.collect::<Vec<_>>();
let total = payload_models.len();
payload_models.sort_by(|left, right| {
left.get("global_model_name")
.and_then(serde_json::Value::as_str)
.cmp(
&right
.get("global_model_name")
.and_then(serde_json::Value::as_str),
)
});
Some(json!({
"models": payload_models,
"total": total,
}))
}
pub(super) async fn build_admin_batch_assign_global_models_payload(
state: &AppState,
provider_id: &str,
global_model_ids: Vec<String>,
) -> Result<serde_json::Value, String> {
let existing_models = state
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: provider_id.to_string(),
is_active: None,
offset: 0,
limit: 10_000,
})
.await
.map_err(|err| format!("{err:?}"))?;
let existing_global_model_ids = existing_models
.into_iter()
.map(|model| model.global_model_id)
.collect::<BTreeSet<_>>();
let mut success = Vec::new();
let mut errors = Vec::new();
for global_model_id in global_model_ids {
let global_model_id = global_model_id.trim().to_string();
if global_model_id.is_empty() {
continue;
}
let global_model =
match resolve_admin_global_model_by_id_or_err(state, &global_model_id).await {
Ok(model) => model,
Err(detail) => {
errors.push(json!({
"global_model_id": global_model_id,
"error": detail,
}));
continue;
}
};
if existing_global_model_ids.contains(&global_model.id) {
errors.push(json!({
"global_model_id": global_model.id,
"error": "Model already exists",
}));
continue;
}
let record = UpsertAdminProviderModelRecord::new(
Uuid::new_v4().to_string(),
provider_id.to_string(),
global_model.id.clone(),
global_model.name.clone(),
None,
None,
None,
None,
None,
None,
None,
None,
true,
true,
None,
)
.map_err(|err| err.to_string())?;
match state.create_admin_provider_model(&record).await {
Ok(Some(created)) => success.push(json!({
"global_model_id": global_model.id,
"global_model_name": global_model.name,
"provider_model_id": created.id,
})),
Ok(None) => errors.push(json!({
"global_model_id": global_model.id,
"error": "Create provider model failed",
})),
Err(err) => errors.push(json!({
"global_model_id": global_model.id,
"error": format!("{err:?}"),
})),
}
}
Ok(json!({
"success": success,
"errors": errors,
}))
}
pub(super) async fn build_admin_import_provider_models_payload(
state: &AppState,
provider_id: &str,
payload: AdminImportProviderModelsRequest,
) -> Result<serde_json::Value, String> {
let default_pricing = json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 0.0,
"output_price_per_1m": 0.0,
}]
});
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
let existing_models = state
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: provider_id.to_string(),
is_active: None,
offset: 0,
limit: 10_000,
})
.await
.map_err(|err| format!("{err:?}"))?;
let mut existing_by_name = existing_models
.iter()
.map(|model| (model.provider_model_name.clone(), model.clone()))
.collect::<BTreeMap<_, _>>();
let mut success = Vec::new();
let mut errors = Vec::new();
for model_id in payload.model_ids {
let trimmed = model_id.trim();
if trimmed.is_empty() || trimmed.len() > 100 {
errors.push(json!({
"model_id": if trimmed.is_empty() { "<empty>" } else { trimmed },
"error": "Invalid model_id: must be 1-100 characters",
}));
continue;
}
if let Some(existing) = existing_by_name.get(trimmed) {
success.push(json!({
"model_id": trimmed,
"global_model_id": existing.global_model_id,
"global_model_name": existing.global_model_name,
"provider_model_id": existing.id,
"created_global_model": false,
}));
continue;
}
let mut created_global_model = false;
let global_model = if let Some(existing) = state
.get_admin_global_model_by_name(trimmed)
.await
.map_err(|err| format!("{err:?}"))?
{
existing
} else {
let created = state
.create_admin_global_model(
&CreateAdminGlobalModelRecord::new(
Uuid::new_v4().to_string(),
trimmed.to_string(),
trimmed.to_string(),
true,
payload.price_per_request,
tiered_pricing
.clone()
.or_else(|| Some(default_pricing.clone())),
None,
None,
)
.map_err(|err| err.to_string())?,
)
.await
.map_err(|err| format!("{err:?}"))?;
let Some(created) = created else {
errors.push(json!({"model_id": trimmed, "error": "Create GlobalModel failed"}));
continue;
};
created_global_model = true;
created
};
let record = UpsertAdminProviderModelRecord::new(
Uuid::new_v4().to_string(),
provider_id.to_string(),
global_model.id.clone(),
trimmed.to_string(),
None,
payload.price_per_request,
tiered_pricing.clone(),
None,
None,
None,
None,
None,
true,
true,
None,
)
.map_err(|err| err.to_string())?;
match state.create_admin_provider_model(&record).await {
Ok(Some(created)) => {
existing_by_name.insert(trimmed.to_string(), created.clone());
success.push(json!({
"model_id": trimmed,
"global_model_id": global_model.id,
"global_model_name": global_model.name,
"provider_model_id": created.id,
"created_global_model": created_global_model,
}));
}
Ok(None) => errors.push(json!({
"model_id": trimmed,
"error": "Create provider model failed",
})),
Err(err) => errors.push(json!({
"model_id": trimmed,
"error": format!("{err:?}"),
})),
}
}
Ok(json!({
"success": success,
"errors": errors,
}))
}

View File

@@ -1,20 +1,20 @@
use super::super::provider_oauth_refresh::{
use super::super::refresh::{
build_internal_control_error_response, build_provider_oauth_auth_config_from_token_payload,
create_provider_oauth_catalog_key, find_duplicate_provider_oauth_key,
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
};
use super::super::provider_oauth_state::{
use super::super::state::{
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
current_unix_secs, decode_jwt_claims, exchange_admin_provider_oauth_refresh_token,
is_fixed_provider_type_for_provider_oauth, save_provider_oauth_batch_task_payload,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::paths::{
admin_provider_oauth_batch_import_provider_id,
admin_provider_oauth_batch_import_task_provider_id,
ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL,
};
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
use crate::{AppState, GatewayError};
use axum::{
body::{to_bytes, Body, Bytes},

View File

@@ -1,18 +1,18 @@
use super::super::provider_oauth_quota::refresh_codex_provider_quota_locally;
use super::super::provider_oauth_refresh::{
use super::super::quota::codex::refresh_codex_provider_quota_locally;
use super::super::refresh::{
build_internal_control_error_response, build_provider_oauth_auth_config_from_token_payload,
create_provider_oauth_catalog_key, find_duplicate_provider_oauth_key,
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
};
use super::super::provider_oauth_state::{
use super::super::state::{
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
consume_provider_oauth_state, enrich_admin_provider_oauth_auth_config,
exchange_admin_provider_oauth_code, is_fixed_provider_type_for_provider_oauth,
json_non_empty_string, json_u64_value, parse_provider_oauth_callback_params,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::paths::{
admin_provider_oauth_complete_key_id, admin_provider_oauth_complete_provider_id,
};
use crate::handlers::admin::shared::encrypt_catalog_secret_with_fallbacks;

View File

@@ -1,10 +1,10 @@
use super::super::provider_oauth_refresh::{
use super::super::refresh::{
build_internal_control_error_response, build_provider_oauth_auth_config_from_token_payload,
create_provider_oauth_catalog_key, find_duplicate_provider_oauth_key,
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
};
use super::super::provider_oauth_state::{
use super::super::state::{
build_admin_provider_oauth_backend_unavailable_response, build_kiro_device_key_name,
current_unix_secs, decode_jwt_claims, default_kiro_device_region,
default_kiro_device_start_url, generate_provider_oauth_nonce, json_non_empty_string,
@@ -13,7 +13,7 @@ use super::super::provider_oauth_state::{
save_provider_oauth_device_session, start_admin_kiro_device_authorization,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::paths::{
admin_provider_oauth_device_authorize_provider_id, admin_provider_oauth_device_poll_provider_id,
};
use crate::handlers::admin::shared::attach_admin_audit_response;

View File

@@ -0,0 +1,21 @@
use crate::handlers::admin::shared::attach_admin_audit_response;
use axum::{
body::Body,
response::{IntoResponse, Response},
};
pub(super) fn attach_admin_provider_oauth_audit_response(
response: Response<Body>,
event_name: &'static str,
action: &'static str,
target_type: &'static str,
target_id: Option<String>,
) -> Response<Body> {
if !response.status().is_success() {
return response;
}
let Some(target_id) = target_id else {
return response;
};
attach_admin_audit_response(response, event_name, action, target_type, &target_id)
}

View File

@@ -1,15 +1,15 @@
use super::super::provider_oauth_refresh::{
use super::super::refresh::{
build_internal_control_error_response, build_provider_oauth_auth_config_from_token_payload,
create_provider_oauth_catalog_key, find_duplicate_provider_oauth_key,
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
};
use super::super::provider_oauth_state::{
use super::super::state::{
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
exchange_admin_provider_oauth_refresh_token, is_fixed_provider_type_for_provider_oauth,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::admin_provider_oauth_import_provider_id;
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_import_provider_id;
use crate::{AppState, GatewayError};
use axum::{
body::Body,

View File

@@ -1,16 +1,15 @@
use super::provider_oauth_state::{
use super::state::{
build_admin_provider_oauth_backend_unavailable_response,
build_admin_provider_oauth_supported_types_payload,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::paths::{
admin_provider_oauth_batch_import_provider_id,
admin_provider_oauth_batch_import_task_provider_id, admin_provider_oauth_complete_key_id,
admin_provider_oauth_complete_provider_id, admin_provider_oauth_device_authorize_provider_id,
admin_provider_oauth_import_provider_id, admin_provider_oauth_refresh_key_id,
admin_provider_oauth_start_key_id, admin_provider_oauth_start_provider_id,
};
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
@@ -22,6 +21,7 @@ use axum::{
mod batch;
mod complete;
mod device;
mod helpers;
mod import;
mod refresh;
mod start;
@@ -53,7 +53,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
if route_kind == Some("start_key_oauth") && *method == http::Method::POST {
let response = start::handle_admin_provider_oauth_start_key(state, request_context).await?;
return Ok(Some(attach_admin_provider_oauth_audit_response(
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
response,
"admin_provider_oauth_authorization_started",
"start_provider_oauth_for_key",
@@ -65,7 +65,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
if route_kind == Some("start_provider_oauth") && *method == http::Method::POST {
let response =
start::handle_admin_provider_oauth_start_provider(state, request_context).await?;
return Ok(Some(attach_admin_provider_oauth_audit_response(
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
response,
"admin_provider_oauth_authorization_started",
"start_provider_oauth_for_provider",
@@ -88,7 +88,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
request_body,
)
.await?;
return Ok(Some(attach_admin_provider_oauth_audit_response(
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
response,
"admin_provider_oauth_completed",
"complete_provider_oauth_for_key",
@@ -100,7 +100,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
if route_kind == Some("refresh_key_oauth") && *method == http::Method::POST {
let response =
refresh::handle_admin_provider_oauth_refresh_key(state, request_context).await?;
return Ok(Some(attach_admin_provider_oauth_audit_response(
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
response,
"admin_provider_oauth_refreshed",
"refresh_provider_oauth_for_key",
@@ -116,7 +116,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
request_body,
)
.await?;
return Ok(Some(attach_admin_provider_oauth_audit_response(
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
response,
"admin_provider_oauth_completed",
"complete_provider_oauth_for_provider",
@@ -132,7 +132,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
request_body,
)
.await?;
return Ok(Some(attach_admin_provider_oauth_audit_response(
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
response,
"admin_provider_oauth_refresh_token_imported",
"import_provider_oauth_refresh_token",
@@ -145,7 +145,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
let response =
batch::handle_admin_provider_oauth_batch_import(state, request_context, request_body)
.await?;
return Ok(Some(attach_admin_provider_oauth_audit_response(
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
response,
"admin_provider_oauth_batch_import_completed",
"batch_import_provider_oauth",
@@ -161,7 +161,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
request_body,
)
.await?;
return Ok(Some(attach_admin_provider_oauth_audit_response(
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
response,
"admin_provider_oauth_batch_import_started",
"start_provider_oauth_batch_import",
@@ -177,7 +177,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
request_body,
)
.await?;
return Ok(Some(attach_admin_provider_oauth_audit_response(
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
response,
"admin_provider_oauth_device_authorization_started",
"start_provider_oauth_device_authorization",
@@ -205,18 +205,4 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
Ok(None)
}
fn attach_admin_provider_oauth_audit_response(
response: Response<Body>,
event_name: &'static str,
action: &'static str,
target_type: &'static str,
target_id: Option<String>,
) -> Response<Body> {
if !response.status().is_success() {
return response;
}
let Some(target_id) = target_id else {
return response;
};
attach_admin_audit_response(response, event_name, action, target_type, &target_id)
}
// Dispatch-specific helpers have moved to helpers.rs, so nothing remains here.

View File

@@ -1,13 +1,14 @@
use super::super::provider_oauth_quota::persist_provider_quota_refresh_state;
use super::super::provider_oauth_refresh::{
use super::super::quota::shared::persist_provider_quota_refresh_state;
use super::super::refresh::{
build_internal_control_error_response, merge_provider_oauth_refresh_failure_reason,
normalize_provider_oauth_refresh_error_message, provider_oauth_runtime_endpoint_for_provider,
refresh_provider_oauth_account_state_after_update,
};
use super::super::provider_oauth_state::is_fixed_provider_type_for_provider_oauth;
use super::super::state::is_fixed_provider_type_for_provider_oauth;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
admin_provider_oauth_refresh_key_id, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_refresh_key_id;
use crate::handlers::admin::provider::shared::payloads::{
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
};
use crate::handlers::admin::shared::decrypt_catalog_secret_with_fallbacks;
use crate::{AppState, GatewayError};

View File

@@ -1,11 +1,11 @@
use super::super::provider_oauth_refresh::build_internal_control_error_response;
use super::super::provider_oauth_state::{
use super::super::refresh::build_internal_control_error_response;
use super::super::state::{
admin_provider_oauth_template, build_provider_oauth_start_response,
generate_provider_oauth_pkce_verifier, is_fixed_provider_type_for_provider_oauth,
provider_oauth_pkce_s256, save_provider_oauth_state,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::paths::{
admin_provider_oauth_start_key_id, admin_provider_oauth_start_provider_id,
};
use crate::{AppState, GatewayError};

View File

@@ -1,7 +1,7 @@
use super::super::provider_oauth_refresh::build_internal_control_error_response;
use super::super::provider_oauth_state::read_provider_oauth_batch_task_payload;
use super::super::refresh::build_internal_control_error_response;
use super::super::state::read_provider_oauth_batch_task_payload;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::admin_provider_oauth_batch_import_task_path;
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_batch_import_task_path;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{

View File

@@ -4,13 +4,3 @@ pub(crate) mod refresh;
pub(crate) mod state;
pub(crate) use self::dispatch::maybe_build_local_admin_provider_oauth_response;
pub(crate) use self::quota as provider_oauth_quota;
pub(crate) use self::quota::{
normalize_string_id_list, refresh_antigravity_provider_quota_locally,
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
};
pub(crate) use self::refresh as provider_oauth_refresh;
pub(crate) use self::refresh::{
build_internal_control_error_response, normalize_provider_oauth_refresh_error_message,
};
pub(crate) use self::state as provider_oauth_state;

View File

@@ -1,9 +1,9 @@
use super::{
use super::shared::{
coerce_json_f64, coerce_json_string, execute_provider_quota_plan,
extract_execution_error_message, persist_provider_quota_refresh_state,
quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome,
};
use crate::handlers::admin::provider::shared::ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH;
use crate::handlers::admin::provider::shared::payloads::ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH;
use crate::{AppState, GatewayError};
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
use aether_data_contracts::repository::provider_catalog::{

View File

@@ -1,11 +1,11 @@
use super::{
use super::shared::{
coerce_json_bool, coerce_json_f64, coerce_json_string, coerce_json_u64,
execute_provider_quota_plan, extract_execution_error_message, normalize_string_id_list,
persist_provider_quota_refresh_state, provider_auto_remove_banned_keys,
quota_refresh_success_invalid_state, should_auto_remove_structured_reason,
ProviderQuotaExecutionOutcome,
};
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::payloads::{
CODEX_WHAM_USAGE_URL, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
OAUTH_REQUEST_FAILED_PREFIX,
};

View File

@@ -1,9 +1,11 @@
use super::{
use super::shared::{
coerce_json_f64, execute_provider_quota_plan, extract_execution_error_message,
persist_provider_quota_refresh_state, quota_refresh_success_invalid_state,
ProviderQuotaExecutionOutcome,
};
use crate::handlers::admin::provider::shared::{KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION};
use crate::handlers::admin::provider::shared::payloads::{
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
};
use crate::handlers::admin::shared::encrypt_catalog_secret_with_fallbacks;
use crate::{AppState, GatewayError};
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};

View File

@@ -1,15 +1,4 @@
mod antigravity;
mod codex;
mod kiro;
mod shared;
pub(crate) use self::antigravity::refresh_antigravity_provider_quota_locally;
pub(crate) use self::codex::refresh_codex_provider_quota_locally;
pub(crate) use self::kiro::refresh_kiro_provider_quota_locally;
use self::shared::{
coerce_json_bool, coerce_json_f64, coerce_json_string, coerce_json_u64,
execute_provider_quota_plan, extract_execution_error_message, provider_auto_remove_banned_keys,
quota_refresh_success_invalid_state, should_auto_remove_structured_reason,
ProviderQuotaExecutionOutcome,
};
pub(crate) use self::shared::{normalize_string_id_list, persist_provider_quota_refresh_state};
pub(crate) mod antigravity;
pub(crate) mod codex;
pub(crate) mod kiro;
pub(crate) mod shared;

View File

@@ -1,4 +1,4 @@
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::payloads::{
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
};
use crate::{AppState, GatewayError};

View File

@@ -1,11 +1,11 @@
use super::provider_oauth_quota::{
persist_provider_quota_refresh_state, refresh_antigravity_provider_quota_locally,
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
};
use super::provider_oauth_state::{
use super::quota::antigravity::refresh_antigravity_provider_quota_locally;
use super::quota::codex::refresh_codex_provider_quota_locally;
use super::quota::kiro::refresh_kiro_provider_quota_locally;
use super::quota::shared::persist_provider_quota_refresh_state;
use super::state::{
enrich_admin_provider_oauth_auth_config, json_non_empty_string, json_u64_value,
};
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::payloads::{
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
OAUTH_REQUEST_FAILED_PREFIX,
};

View File

@@ -1,7 +1,7 @@
use super::{
use super::refresh::{
build_internal_control_error_response, normalize_provider_oauth_refresh_error_message,
};
use crate::handlers::admin::provider::shared::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
use crate::provider_transport::provider_types::{
provider_type_admin_oauth_template, provider_type_is_fixed_for_admin_oauth,
ProviderOAuthTemplate, ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES,

View File

@@ -1,5 +1,5 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::paths::{
admin_provider_ops_architecture_id_from_path, is_admin_provider_ops_architectures_root,
};
use crate::GatewayError;

View File

@@ -4,9 +4,7 @@ use axum::body::{Body, Bytes};
use axum::http::Response;
mod architectures;
mod providers;
pub(crate) use self::providers::admin_provider_ops_local_action_response;
pub(crate) mod providers;
pub(crate) async fn maybe_build_local_admin_provider_ops_response(
state: &AppState,

View File

@@ -1,10 +1,10 @@
use super::{
use super::config::{
admin_provider_ops_config_object, admin_provider_ops_connector_object,
admin_provider_ops_decrypted_credentials, admin_provider_ops_uses_python_verify_fallback,
admin_provider_ops_value_as_f64, admin_provider_ops_verify_headers,
resolve_admin_provider_ops_base_url, AdminProviderOpsCheckinOutcome,
ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE,
resolve_admin_provider_ops_base_url,
};
use super::support::{AdminProviderOpsCheckinOutcome, ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE};
use super::verify::{admin_provider_ops_value_as_f64, admin_provider_ops_verify_headers};
use crate::AppState;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,

View File

@@ -1,4 +1,4 @@
use super::{AdminProviderOpsSaveConfigRequest, ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS};
use super::support::{AdminProviderOpsSaveConfigRequest, ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS};
use crate::handlers::admin::shared::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks,
};

View File

@@ -1,117 +1,6 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
admin_provider_id_for_provider_ops_balance, admin_provider_id_for_provider_ops_checkin,
admin_provider_id_for_provider_ops_config, admin_provider_id_for_provider_ops_connect,
admin_provider_id_for_provider_ops_disconnect, admin_provider_id_for_provider_ops_status,
admin_provider_id_for_provider_ops_verify, admin_provider_ops_action_route_parts,
};
use crate::handlers::admin::shared::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks,
};
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
mod actions;
pub(crate) mod actions;
mod config;
mod routes;
mod support;
mod verify;
use self::actions::admin_provider_ops_is_valid_action_type;
pub(crate) use self::actions::admin_provider_ops_local_action_response;
use self::config::{
admin_provider_ops_config_object, admin_provider_ops_connector_object,
admin_provider_ops_decrypted_credentials, admin_provider_ops_merge_credentials,
admin_provider_ops_uses_python_verify_fallback, build_admin_provider_ops_config_payload,
build_admin_provider_ops_saved_config_value, build_admin_provider_ops_status_payload,
resolve_admin_provider_ops_base_url,
};
pub(super) use self::routes::maybe_build_local_admin_provider_ops_providers_response;
use self::verify::{
admin_provider_ops_local_verify_response, admin_provider_ops_normalized_verify_architecture_id,
admin_provider_ops_value_as_f64, admin_provider_ops_verify_failure,
admin_provider_ops_verify_headers,
};
const ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS: &[&str] = &[
"api_key",
"password",
"refresh_token",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
];
const ADMIN_PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE: &str =
"Provider 连接仅支持 Rust execution runtime";
const ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE: &str =
"Provider 操作仅支持 Rust execution runtime";
const ADMIN_PROVIDER_OPS_VERIFY_RUST_ONLY_MESSAGE: &str = "认证验证仅支持 Rust execution runtime";
#[derive(Debug, Deserialize)]
struct AdminProviderOpsSaveConfigRequest {
#[serde(default = "default_admin_provider_ops_architecture_id")]
architecture_id: String,
#[serde(default)]
base_url: Option<String>,
connector: AdminProviderOpsConnectorConfigRequest,
#[serde(default)]
actions: BTreeMap<String, AdminProviderOpsActionConfigRequest>,
#[serde(default)]
schedule: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct AdminProviderOpsConnectorConfigRequest {
auth_type: String,
#[serde(default)]
config: serde_json::Map<String, serde_json::Value>,
#[serde(default)]
credentials: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct AdminProviderOpsActionConfigRequest {
#[serde(default = "default_admin_provider_ops_action_enabled")]
enabled: bool,
#[serde(default)]
config: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct AdminProviderOpsConnectRequest {
#[serde(default)]
credentials: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Debug, Deserialize)]
struct AdminProviderOpsExecuteActionRequest {
#[serde(default)]
config: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Debug, Clone)]
struct AdminProviderOpsCheckinOutcome {
success: Option<bool>,
message: String,
cookie_expired: bool,
}
fn default_admin_provider_ops_architecture_id() -> String {
"generic_api".to_string()
}
fn default_admin_provider_ops_action_enabled() -> bool {
true
}

View File

@@ -1,16 +1,22 @@
use super::{
use super::actions::{
admin_provider_ops_is_valid_action_type, admin_provider_ops_local_action_response,
};
use super::config::{
admin_provider_ops_config_object, admin_provider_ops_connector_object,
admin_provider_ops_decrypted_credentials, admin_provider_ops_is_valid_action_type,
admin_provider_ops_local_action_response, admin_provider_ops_local_verify_response,
admin_provider_ops_merge_credentials, admin_provider_ops_normalized_verify_architecture_id,
admin_provider_ops_verify_failure, build_admin_provider_ops_config_payload,
build_admin_provider_ops_saved_config_value, build_admin_provider_ops_status_payload,
resolve_admin_provider_ops_base_url, AdminProviderOpsConnectRequest,
AdminProviderOpsExecuteActionRequest, AdminProviderOpsSaveConfigRequest,
ADMIN_PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE,
admin_provider_ops_decrypted_credentials, admin_provider_ops_merge_credentials,
build_admin_provider_ops_config_payload, build_admin_provider_ops_saved_config_value,
build_admin_provider_ops_status_payload, resolve_admin_provider_ops_base_url,
};
use super::support::{
AdminProviderOpsConnectRequest, AdminProviderOpsExecuteActionRequest,
AdminProviderOpsSaveConfigRequest, ADMIN_PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE,
};
use super::verify::{
admin_provider_ops_local_verify_response, admin_provider_ops_normalized_verify_architecture_id,
admin_provider_ops_verify_failure,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::paths::{
admin_provider_id_for_provider_ops_balance, admin_provider_id_for_provider_ops_checkin,
admin_provider_id_for_provider_ops_config, admin_provider_id_for_provider_ops_connect,
admin_provider_id_for_provider_ops_disconnect, admin_provider_id_for_provider_ops_status,

View File

@@ -0,0 +1,77 @@
use serde::Deserialize;
use std::collections::BTreeMap;
pub(super) const ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS: &[&str] = &[
"api_key",
"password",
"refresh_token",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
];
pub(super) const ADMIN_PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE: &str =
"Provider 连接仅支持 Rust execution runtime";
pub(super) const ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE: &str =
"Provider 操作仅支持 Rust execution runtime";
pub(super) const ADMIN_PROVIDER_OPS_VERIFY_RUST_ONLY_MESSAGE: &str =
"认证验证仅支持 Rust execution runtime";
#[derive(Debug, Deserialize)]
pub(super) struct AdminProviderOpsSaveConfigRequest {
#[serde(default = "default_admin_provider_ops_architecture_id")]
pub(crate) architecture_id: String,
#[serde(default)]
pub(crate) base_url: Option<String>,
pub(crate) connector: AdminProviderOpsConnectorConfigRequest,
#[serde(default)]
pub(crate) actions: BTreeMap<String, AdminProviderOpsActionConfigRequest>,
#[serde(default)]
pub(crate) schedule: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct AdminProviderOpsConnectorConfigRequest {
pub(crate) auth_type: String,
#[serde(default)]
pub(crate) config: serde_json::Map<String, serde_json::Value>,
#[serde(default)]
pub(crate) credentials: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(super) struct AdminProviderOpsActionConfigRequest {
#[serde(default = "default_admin_provider_ops_action_enabled")]
pub(crate) enabled: bool,
#[serde(default)]
pub(crate) config: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(super) struct AdminProviderOpsConnectRequest {
#[serde(default)]
pub(crate) credentials: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Debug, Deserialize)]
pub(super) struct AdminProviderOpsExecuteActionRequest {
#[serde(default)]
pub(crate) config: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Debug, Clone)]
pub(super) struct AdminProviderOpsCheckinOutcome {
pub(crate) success: Option<bool>,
pub(crate) message: String,
pub(crate) cookie_expired: bool,
}
fn default_admin_provider_ops_architecture_id() -> String {
"generic_api".to_string()
}
fn default_admin_provider_ops_action_enabled() -> bool {
true
}

View File

@@ -1,4 +1,4 @@
use super::ADMIN_PROVIDER_OPS_VERIFY_RUST_ONLY_MESSAGE;
use super::support::ADMIN_PROVIDER_OPS_VERIFY_RUST_ONLY_MESSAGE;
use crate::AppState;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use regex::Regex;

View File

@@ -1,485 +1,2 @@
use super::shared::{
AdminProviderPoolConfig, AdminProviderPoolRuntimeState, ADMIN_PROVIDER_POOL_SCAN_BATCH,
};
use crate::{AppState, GatewayError};
use aether_data::redis::{RedisKeyspace, RedisKvRunner};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
use serde_json::json;
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
fn json_u64(value: &serde_json::Value) -> Option<u64> {
value
.as_u64()
.or_else(|| value.as_i64().and_then(|raw| u64::try_from(raw).ok()))
}
fn admin_provider_pool_lru_enabled(
raw_pool_advanced: &serde_json::Map<String, serde_json::Value>,
) -> bool {
if let Some(explicit) = raw_pool_advanced
.get("lru_enabled")
.and_then(serde_json::Value::as_bool)
{
return explicit;
}
let Some(presets) = raw_pool_advanced
.get("scheduling_presets")
.and_then(serde_json::Value::as_array)
else {
return false;
};
let Some(first) = presets.first() else {
return false;
};
if first.is_string() {
return raw_pool_advanced
.get("lru_enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
}
presets
.iter()
.filter_map(serde_json::Value::as_object)
.any(|item| {
item.get("preset")
.and_then(serde_json::Value::as_str)
.is_some_and(|preset| preset.eq_ignore_ascii_case("lru"))
&& item
.get("enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true)
})
}
pub(crate) fn admin_provider_pool_config(
provider: &StoredProviderCatalogProvider,
) -> Option<AdminProviderPoolConfig> {
let raw_pool_advanced = provider
.config
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|config| config.get("pool_advanced"))?;
let Some(pool_advanced) = raw_pool_advanced.as_object() else {
return Some(AdminProviderPoolConfig {
lru_enabled: false,
cost_window_seconds: 18_000,
cost_limit_per_key_tokens: None,
});
};
Some(AdminProviderPoolConfig {
lru_enabled: admin_provider_pool_lru_enabled(pool_advanced),
cost_window_seconds: pool_advanced
.get("cost_window_seconds")
.and_then(json_u64)
.filter(|value| *value > 0)
.unwrap_or(18_000),
cost_limit_per_key_tokens: pool_advanced
.get("cost_limit_per_key_tokens")
.and_then(json_u64),
})
}
fn pool_sticky_pattern(keyspace: &RedisKeyspace, provider_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:sticky:*"))
}
fn pool_lru_key(keyspace: &RedisKeyspace, provider_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:lru"))
}
fn pool_cooldown_key(keyspace: &RedisKeyspace, provider_id: &str, key_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:cooldown:{key_id}"))
}
fn pool_cooldown_index_key(keyspace: &RedisKeyspace, provider_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:cooldown_idx"))
}
fn pool_cost_key(keyspace: &RedisKeyspace, provider_id: &str, key_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:cost:{key_id}"))
}
fn parse_pool_cost_member(member: &str) -> u64 {
member
.rsplit_once(':')
.and_then(|(_, suffix)| suffix.parse::<u64>().ok())
.unwrap_or(0)
}
async fn scan_redis_keys(
connection: &mut redis::aio::MultiplexedConnection,
pattern: &str,
) -> Result<Vec<String>, GatewayError> {
let mut cursor = 0u64;
let mut keys = Vec::new();
loop {
let (next_cursor, batch): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(ADMIN_PROVIDER_POOL_SCAN_BATCH)
.query_async(connection)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
keys.extend(batch);
if next_cursor == 0 {
break;
}
cursor = next_cursor;
}
Ok(keys)
}
fn pool_cooldown_keys(
keyspace: &RedisKeyspace,
provider_id: &str,
key_ids: &[String],
) -> Vec<String> {
key_ids
.iter()
.map(|key_id| pool_cooldown_key(keyspace, provider_id, key_id))
.collect()
}
fn pool_cost_keys(keyspace: &RedisKeyspace, provider_id: &str, key_ids: &[String]) -> Vec<String> {
key_ids
.iter()
.map(|key_id| pool_cost_key(keyspace, provider_id, key_id))
.collect()
}
pub(crate) async fn read_admin_provider_pool_cooldown_counts(
runner: &RedisKvRunner,
provider_ids: &[String],
) -> BTreeMap<String, usize> {
if provider_ids.is_empty() {
return BTreeMap::new();
}
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis for cooldown counts");
return BTreeMap::new();
};
let keyspace = runner.keyspace().clone();
let mut pipeline = redis::pipe();
for provider_id in provider_ids {
pipeline
.cmd("SCARD")
.arg(pool_cooldown_index_key(&keyspace, provider_id));
}
match pipeline.query_async::<Vec<u64>>(&mut connection).await {
Ok(counts) => provider_ids
.iter()
.cloned()
.zip(counts.into_iter())
.map(|(provider_id, count)| (provider_id, count as usize))
.collect(),
Err(err) => {
warn!(
"gateway admin provider pool: failed to batch read cooldown counts: {:?}",
err
);
BTreeMap::new()
}
}
}
pub(crate) async fn read_admin_provider_pool_runtime_state(
runner: &RedisKvRunner,
provider_id: &str,
key_ids: &[String],
pool_config: AdminProviderPoolConfig,
) -> AdminProviderPoolRuntimeState {
let mut runtime = AdminProviderPoolRuntimeState::default();
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
return runtime;
};
let keyspace = runner.keyspace().clone();
let cooldown_keys = pool_cooldown_keys(&keyspace, provider_id, key_ids);
let cost_keys = pool_cost_keys(&keyspace, provider_id, key_ids);
let sticky_keys = match scan_redis_keys(
&mut connection,
&pool_sticky_pattern(&keyspace, provider_id),
)
.await
{
Ok(keys) => keys,
Err(err) => {
warn!(
"gateway admin provider pool: failed to scan sticky keys for provider {provider_id}: {:?}",
err
);
Vec::new()
}
};
runtime.total_sticky_sessions = sticky_keys.len();
if !sticky_keys.is_empty() {
for chunk in sticky_keys.chunks(ADMIN_PROVIDER_POOL_SCAN_BATCH as usize) {
let values = redis::cmd("MGET")
.arg(chunk)
.query_async::<Vec<Option<String>>>(&mut connection)
.await;
let Ok(values) = values else {
warn!("gateway admin provider pool: failed to read sticky bindings for provider {provider_id}");
break;
};
for bound_key_id in values.into_iter().flatten() {
*runtime
.sticky_sessions_by_key
.entry(bound_key_id)
.or_insert(0) += 1;
}
}
}
if !cooldown_keys.is_empty() {
let cooldown_reasons = redis::cmd("MGET")
.arg(&cooldown_keys)
.query_async::<Vec<Option<String>>>(&mut connection)
.await
.unwrap_or_else(|err| {
warn!(
"gateway admin provider pool: failed to batch read cooldown reasons for provider {provider_id}: {:?}",
err
);
vec![None; cooldown_keys.len()]
});
let mut ttl_pipeline = redis::pipe();
for cooldown_key in &cooldown_keys {
ttl_pipeline.cmd("TTL").arg(cooldown_key);
}
let cooldown_ttls = ttl_pipeline
.query_async::<Vec<i64>>(&mut connection)
.await
.unwrap_or_else(|err| {
warn!(
"gateway admin provider pool: failed to batch read cooldown ttl for provider {provider_id}: {:?}",
err
);
vec![-1; cooldown_keys.len()]
});
for (((key_id, _cooldown_key), reason), ttl) in key_ids
.iter()
.zip(cooldown_keys.iter())
.zip(cooldown_reasons.into_iter())
.zip(cooldown_ttls.into_iter())
{
if let Some(reason) = reason {
runtime
.cooldown_reason_by_key
.insert(key_id.clone(), reason);
if let Ok(ttl_seconds) = u64::try_from(ttl) {
if ttl_seconds > 0 {
runtime
.cooldown_ttl_by_key
.insert(key_id.clone(), ttl_seconds);
}
}
}
}
}
if !cost_keys.is_empty() {
let window_start = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_sub(pool_config.cost_window_seconds);
let mut cost_pipeline = redis::pipe();
for cost_key in &cost_keys {
cost_pipeline
.cmd("ZRANGEBYSCORE")
.arg(cost_key)
.arg(window_start)
.arg("+inf");
}
let members_by_key = cost_pipeline
.query_async::<Vec<Vec<String>>>(&mut connection)
.await
.unwrap_or_else(|err| {
warn!(
"gateway admin provider pool: failed to batch read cost windows for provider {provider_id}: {:?}",
err
);
vec![Vec::new(); cost_keys.len()]
});
for (key_id, members) in key_ids.iter().zip(members_by_key.into_iter()) {
let total = members
.iter()
.map(|member| parse_pool_cost_member(member))
.sum::<u64>();
runtime
.cost_window_usage_by_key
.insert(key_id.clone(), total);
}
}
if pool_config.lru_enabled && !key_ids.is_empty() {
let mut command = redis::cmd("ZMSCORE");
command.arg(pool_lru_key(&keyspace, provider_id));
for key_id in key_ids {
command.arg(key_id);
}
if let Ok(scores) = command
.query_async::<Vec<Option<f64>>>(&mut connection)
.await
{
for (key_id, score) in key_ids.iter().zip(scores.into_iter()) {
if let Some(score) = score {
runtime.lru_score_by_key.insert(key_id.clone(), score);
}
}
}
}
runtime
}
pub(crate) async fn read_admin_provider_pool_cooldown_count(
runner: &RedisKvRunner,
provider_id: &str,
) -> usize {
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
return 0;
};
let keyspace = runner.keyspace().clone();
redis::cmd("SCARD")
.arg(pool_cooldown_index_key(&keyspace, provider_id))
.query_async::<u64>(&mut connection)
.await
.map(|value| value as usize)
.unwrap_or(0)
}
pub(crate) async fn read_admin_provider_pool_cooldown_key_ids(
runner: &RedisKvRunner,
provider_id: &str,
) -> Vec<String> {
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
return Vec::new();
};
let keyspace = runner.keyspace().clone();
redis::cmd("SMEMBERS")
.arg(pool_cooldown_index_key(&keyspace, provider_id))
.query_async::<Vec<String>>(&mut connection)
.await
.unwrap_or_default()
}
pub(crate) async fn build_admin_provider_pool_status_payload(
state: &AppState,
provider_id: &str,
) -> Option<serde_json::Value> {
if !state.has_provider_catalog_data_reader() {
return None;
}
let provider = state
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
.await
.ok()
.and_then(|mut providers| providers.drain(..).next())?;
let Some(pool_config) = admin_provider_pool_config(&provider) else {
return Some(json!({
"provider_id": provider.id,
"provider_name": provider.name,
"pool_enabled": false,
"total_keys": 0,
"total_sticky_sessions": 0,
"keys": [],
}));
};
let keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
.await
.ok()
.unwrap_or_default();
let key_ids = keys.iter().map(|key| key.id.clone()).collect::<Vec<_>>();
let runtime = match state.redis_kv_runner() {
Some(runner) => {
read_admin_provider_pool_runtime_state(&runner, &provider.id, &key_ids, pool_config)
.await
}
None => AdminProviderPoolRuntimeState::default(),
};
let key_payloads = keys
.into_iter()
.map(|key| {
let cooldown_reason = runtime.cooldown_reason_by_key.get(&key.id).cloned();
json!({
"key_id": key.id,
"key_name": key.name,
"is_active": key.is_active,
"cooldown_reason": cooldown_reason,
"cooldown_ttl_seconds": cooldown_reason
.as_ref()
.and_then(|_| runtime.cooldown_ttl_by_key.get(&key.id).copied()),
"cost_window_usage": runtime.cost_window_usage_by_key.get(&key.id).copied().unwrap_or(0),
"cost_limit": pool_config.cost_limit_per_key_tokens,
"sticky_sessions": runtime.sticky_sessions_by_key.get(&key.id).copied().unwrap_or(0),
"lru_score": runtime.lru_score_by_key.get(&key.id).copied(),
})
})
.collect::<Vec<_>>();
Some(json!({
"provider_id": provider.id,
"provider_name": provider.name,
"pool_enabled": true,
"total_keys": key_payloads.len(),
"total_sticky_sessions": runtime.total_sticky_sessions,
"keys": key_payloads,
}))
}
pub(crate) async fn clear_admin_provider_pool_cooldown(
state: &AppState,
provider_id: &str,
key_id: &str,
) {
let Some(runner) = state.redis_kv_runner() else {
return;
};
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis to clear cooldown for key {key_id}");
return;
};
let keyspace = runner.keyspace().clone();
let _: Result<(), _> = redis::pipe()
.cmd("DEL")
.arg(pool_cooldown_key(&keyspace, provider_id, key_id))
.ignore()
.cmd("SREM")
.arg(pool_cooldown_index_key(&keyspace, provider_id))
.arg(key_id)
.ignore()
.query_async(&mut connection)
.await;
}
pub(crate) async fn reset_admin_provider_pool_cost(
state: &AppState,
provider_id: &str,
key_id: &str,
) {
let Some(runner) = state.redis_kv_runner() else {
return;
};
let _ = runner.del(&format!("ap:{provider_id}:cost:{key_id}")).await;
}
pub(crate) mod config;
pub(crate) mod runtime;

View File

@@ -0,0 +1,72 @@
use crate::handlers::admin::provider::shared::support::AdminProviderPoolConfig;
use serde_json::{Map, Value};
fn json_u64(value: &Value) -> Option<u64> {
value
.as_u64()
.or_else(|| value.as_i64().and_then(|raw| u64::try_from(raw).ok()))
}
fn admin_provider_pool_lru_enabled(raw_pool_advanced: &Map<String, Value>) -> bool {
if let Some(explicit) = raw_pool_advanced
.get("lru_enabled")
.and_then(Value::as_bool)
{
return explicit;
}
let Some(presets) = raw_pool_advanced
.get("scheduling_presets")
.and_then(Value::as_array)
else {
return false;
};
let Some(first) = presets.first() else {
return false;
};
if first.is_string() {
return raw_pool_advanced
.get("lru_enabled")
.and_then(Value::as_bool)
.unwrap_or(true);
}
presets.iter().filter_map(Value::as_object).any(|item| {
item.get("preset")
.and_then(Value::as_str)
.is_some_and(|preset| preset.eq_ignore_ascii_case("lru"))
&& item.get("enabled").and_then(Value::as_bool).unwrap_or(true)
})
}
pub(crate) fn admin_provider_pool_config(
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
) -> Option<AdminProviderPoolConfig> {
let raw_pool_advanced = provider
.config
.as_ref()
.and_then(Value::as_object)
.and_then(|config| config.get("pool_advanced"))?;
let Some(pool_advanced) = raw_pool_advanced.as_object() else {
return Some(AdminProviderPoolConfig {
lru_enabled: false,
cost_window_seconds: 18_000,
cost_limit_per_key_tokens: None,
});
};
Some(AdminProviderPoolConfig {
lru_enabled: admin_provider_pool_lru_enabled(pool_advanced),
cost_window_seconds: pool_advanced
.get("cost_window_seconds")
.and_then(json_u64)
.filter(|value| *value > 0)
.unwrap_or(18_000),
cost_limit_per_key_tokens: pool_advanced
.get("cost_limit_per_key_tokens")
.and_then(json_u64),
})
}

View File

@@ -0,0 +1,409 @@
use super::config::admin_provider_pool_config;
use crate::handlers::admin::provider::shared::support::{
AdminProviderPoolConfig, AdminProviderPoolRuntimeState, ADMIN_PROVIDER_POOL_SCAN_BATCH,
};
use crate::{AppState, GatewayError};
use aether_data::redis::{RedisKeyspace, RedisKvRunner};
use serde_json::json;
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
fn pool_sticky_pattern(keyspace: &RedisKeyspace, provider_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:sticky:*"))
}
fn pool_lru_key(keyspace: &RedisKeyspace, provider_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:lru"))
}
fn pool_cooldown_key(keyspace: &RedisKeyspace, provider_id: &str, key_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:cooldown:{key_id}"))
}
fn pool_cooldown_index_key(keyspace: &RedisKeyspace, provider_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:cooldown_idx"))
}
fn pool_cost_key(keyspace: &RedisKeyspace, provider_id: &str, key_id: &str) -> String {
keyspace.key(&format!("ap:{provider_id}:cost:{key_id}"))
}
fn parse_pool_cost_member(member: &str) -> u64 {
member
.rsplit_once(':')
.and_then(|(_, suffix)| suffix.parse::<u64>().ok())
.unwrap_or(0)
}
async fn scan_redis_keys(
connection: &mut redis::aio::MultiplexedConnection,
pattern: &str,
) -> Result<Vec<String>, GatewayError> {
let mut cursor = 0u64;
let mut keys = Vec::new();
loop {
let (next_cursor, batch): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(ADMIN_PROVIDER_POOL_SCAN_BATCH)
.query_async(connection)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
keys.extend(batch);
if next_cursor == 0 {
break;
}
cursor = next_cursor;
}
Ok(keys)
}
fn pool_cooldown_keys(
keyspace: &RedisKeyspace,
provider_id: &str,
key_ids: &[String],
) -> Vec<String> {
key_ids
.iter()
.map(|key_id| pool_cooldown_key(keyspace, provider_id, key_id))
.collect()
}
fn pool_cost_keys(keyspace: &RedisKeyspace, provider_id: &str, key_ids: &[String]) -> Vec<String> {
key_ids
.iter()
.map(|key_id| pool_cost_key(keyspace, provider_id, key_id))
.collect()
}
pub(crate) async fn read_admin_provider_pool_cooldown_counts(
runner: &RedisKvRunner,
provider_ids: &[String],
) -> BTreeMap<String, usize> {
if provider_ids.is_empty() {
return BTreeMap::new();
}
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis for cooldown counts");
return BTreeMap::new();
};
let keyspace = runner.keyspace().clone();
let mut pipeline = redis::pipe();
for provider_id in provider_ids {
pipeline
.cmd("SCARD")
.arg(pool_cooldown_index_key(&keyspace, provider_id));
}
match pipeline.query_async::<Vec<u64>>(&mut connection).await {
Ok(counts) => provider_ids
.iter()
.cloned()
.zip(counts.into_iter())
.map(|(provider_id, count)| (provider_id, count as usize))
.collect(),
Err(err) => {
warn!(
"gateway admin provider pool: failed to batch read cooldown counts: {:?}",
err
);
BTreeMap::new()
}
}
}
pub(crate) async fn read_admin_provider_pool_runtime_state(
runner: &RedisKvRunner,
provider_id: &str,
key_ids: &[String],
pool_config: AdminProviderPoolConfig,
) -> AdminProviderPoolRuntimeState {
let mut runtime = AdminProviderPoolRuntimeState::default();
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
return runtime;
};
let keyspace = runner.keyspace().clone();
let cooldown_keys = pool_cooldown_keys(&keyspace, provider_id, key_ids);
let cost_keys = pool_cost_keys(&keyspace, provider_id, key_ids);
let sticky_keys = match scan_redis_keys(
&mut connection,
&pool_sticky_pattern(&keyspace, provider_id),
)
.await
{
Ok(keys) => keys,
Err(err) => {
warn!(
"gateway admin provider pool: failed to scan sticky keys for provider {provider_id}: {:?}",
err
);
Vec::new()
}
};
runtime.total_sticky_sessions = sticky_keys.len();
if !sticky_keys.is_empty() {
for chunk in sticky_keys.chunks(ADMIN_PROVIDER_POOL_SCAN_BATCH as usize) {
let values = redis::cmd("MGET")
.arg(chunk)
.query_async::<Vec<Option<String>>>(&mut connection)
.await;
let Ok(values) = values else {
warn!(
"gateway admin provider pool: failed to read sticky bindings for provider {provider_id}"
);
break;
};
for bound_key_id in values.into_iter().flatten() {
*runtime
.sticky_sessions_by_key
.entry(bound_key_id)
.or_insert(0) += 1;
}
}
}
if !cooldown_keys.is_empty() {
let cooldown_reasons = redis::cmd("MGET")
.arg(&cooldown_keys)
.query_async::<Vec<Option<String>>>(&mut connection)
.await
.unwrap_or_else(|err| {
warn!(
"gateway admin provider pool: failed to batch read cooldown reasons for provider {provider_id}: {:?}",
err
);
vec![None; cooldown_keys.len()]
});
let mut ttl_pipeline = redis::pipe();
for cooldown_key in &cooldown_keys {
ttl_pipeline.cmd("TTL").arg(cooldown_key);
}
let cooldown_ttls = ttl_pipeline
.query_async::<Vec<i64>>(&mut connection)
.await
.unwrap_or_else(|err| {
warn!(
"gateway admin provider pool: failed to batch read cooldown ttl for provider {provider_id}: {:?}",
err
);
vec![-1; cooldown_keys.len()]
});
for (((key_id, _cooldown_key), reason), ttl) in key_ids
.iter()
.zip(cooldown_keys.iter())
.zip(cooldown_reasons.into_iter())
.zip(cooldown_ttls.into_iter())
{
if let Some(reason) = reason {
runtime
.cooldown_reason_by_key
.insert(key_id.clone(), reason);
if let Ok(ttl_seconds) = u64::try_from(ttl) {
if ttl_seconds > 0 {
runtime
.cooldown_ttl_by_key
.insert(key_id.clone(), ttl_seconds);
}
}
}
}
}
if !cost_keys.is_empty() {
let window_start = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_sub(pool_config.cost_window_seconds);
let mut cost_pipeline = redis::pipe();
for cost_key in &cost_keys {
cost_pipeline
.cmd("ZRANGEBYSCORE")
.arg(cost_key)
.arg(window_start)
.arg("+inf");
}
let members_by_key = cost_pipeline
.query_async::<Vec<Vec<String>>>(&mut connection)
.await
.unwrap_or_else(|err| {
warn!(
"gateway admin provider pool: failed to batch read cost windows for provider {provider_id}: {:?}",
err
);
vec![Vec::new(); cost_keys.len()]
});
for (key_id, members) in key_ids.iter().zip(members_by_key.into_iter()) {
let total = members
.iter()
.map(|member| parse_pool_cost_member(member))
.sum::<u64>();
runtime
.cost_window_usage_by_key
.insert(key_id.clone(), total);
}
}
if pool_config.lru_enabled && !key_ids.is_empty() {
let mut command = redis::cmd("ZMSCORE");
command.arg(pool_lru_key(&keyspace, provider_id));
for key_id in key_ids {
command.arg(key_id);
}
if let Ok(scores) = command
.query_async::<Vec<Option<f64>>>(&mut connection)
.await
{
for (key_id, score) in key_ids.iter().zip(scores.into_iter()) {
if let Some(score) = score {
runtime.lru_score_by_key.insert(key_id.clone(), score);
}
}
}
}
runtime
}
pub(crate) async fn read_admin_provider_pool_cooldown_count(
runner: &RedisKvRunner,
provider_id: &str,
) -> usize {
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
return 0;
};
let keyspace = runner.keyspace().clone();
redis::cmd("SCARD")
.arg(pool_cooldown_index_key(&keyspace, provider_id))
.query_async::<u64>(&mut connection)
.await
.map(|value| value as usize)
.unwrap_or(0)
}
pub(crate) async fn read_admin_provider_pool_cooldown_key_ids(
runner: &RedisKvRunner,
provider_id: &str,
) -> Vec<String> {
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
return Vec::new();
};
let keyspace = runner.keyspace().clone();
redis::cmd("SMEMBERS")
.arg(pool_cooldown_index_key(&keyspace, provider_id))
.query_async::<Vec<String>>(&mut connection)
.await
.unwrap_or_default()
}
pub(crate) async fn build_admin_provider_pool_status_payload(
state: &AppState,
provider_id: &str,
) -> Option<serde_json::Value> {
if !state.has_provider_catalog_data_reader() {
return None;
}
let provider = state
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
.await
.ok()
.and_then(|mut providers| providers.drain(..).next())?;
let Some(pool_config) = admin_provider_pool_config(&provider) else {
return Some(json!({
"provider_id": provider.id,
"provider_name": provider.name,
"pool_enabled": false,
"total_keys": 0,
"total_sticky_sessions": 0,
"keys": [],
}));
};
let keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
.await
.ok()
.unwrap_or_default();
let key_ids = keys.iter().map(|key| key.id.clone()).collect::<Vec<_>>();
let runtime = match state.redis_kv_runner() {
Some(runner) => {
read_admin_provider_pool_runtime_state(&runner, &provider.id, &key_ids, pool_config)
.await
}
None => AdminProviderPoolRuntimeState::default(),
};
let key_payloads = keys
.into_iter()
.map(|key| {
let cooldown_reason = runtime.cooldown_reason_by_key.get(&key.id).cloned();
json!({
"key_id": key.id,
"key_name": key.name,
"is_active": key.is_active,
"cooldown_reason": cooldown_reason,
"cooldown_ttl_seconds": cooldown_reason
.as_ref()
.and_then(|_| runtime.cooldown_ttl_by_key.get(&key.id).copied()),
"cost_window_usage": runtime.cost_window_usage_by_key.get(&key.id).copied().unwrap_or(0),
"cost_limit": pool_config.cost_limit_per_key_tokens,
"sticky_sessions": runtime.sticky_sessions_by_key.get(&key.id).copied().unwrap_or(0),
"lru_score": runtime.lru_score_by_key.get(&key.id).copied(),
})
})
.collect::<Vec<_>>();
Some(json!({
"provider_id": provider.id,
"provider_name": provider.name,
"pool_enabled": true,
"total_keys": key_payloads.len(),
"total_sticky_sessions": runtime.total_sticky_sessions,
"keys": key_payloads,
}))
}
pub(crate) async fn clear_admin_provider_pool_cooldown(
state: &AppState,
provider_id: &str,
key_id: &str,
) {
let Some(runner) = state.redis_kv_runner() else {
return;
};
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
warn!("gateway admin provider pool: failed to connect redis to clear cooldown for key {key_id}");
return;
};
let keyspace = runner.keyspace().clone();
let _: Result<(), _> = redis::pipe()
.cmd("DEL")
.arg(pool_cooldown_key(&keyspace, provider_id, key_id))
.ignore()
.cmd("SREM")
.arg(pool_cooldown_index_key(&keyspace, provider_id))
.arg(key_id)
.ignore()
.query_async(&mut connection)
.await;
}
pub(crate) async fn reset_admin_provider_pool_cost(
state: &AppState,
provider_id: &str,
key_id: &str,
) {
let Some(runner) = state.redis_kv_runner() else {
return;
};
let _ = runner.del(&format!("ap:{provider_id}:cost:{key_id}")).await;
}

View File

@@ -1,4 +1,4 @@
use super::{
use super::support::{
admin_pool_provider_id_from_path, build_admin_pool_error_response,
ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE,
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
@@ -6,7 +6,7 @@ use super::{
};
use super::{payloads as pool_payloads, selection as pool_selection};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::pool::{
use crate::handlers::admin::provider::pool::runtime::{
clear_admin_provider_pool_cooldown, reset_admin_provider_pool_cost,
};
use crate::handlers::admin::shared::{

View File

@@ -1,137 +1,17 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::query_param_value;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
response::Response,
};
use serde_json::json;
const ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL: &str =
"Admin pool overview requires provider catalog reader";
const ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL: &str =
"Admin pool cleanup requires provider catalog writer";
const ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE: &str = "未发现可清理的异常账号";
mod support;
mod batch_routes;
mod payloads;
mod read_routes;
mod selection;
#[derive(Debug, Default, serde::Deserialize)]
struct AdminPoolResolveSelectionRequest {
#[serde(default)]
search: String,
#[serde(default)]
quick_selectors: Vec<String>,
}
fn build_admin_pool_error_response(
status: http::StatusCode,
detail: impl Into<String>,
) -> Response<Body> {
(status, Json(json!({ "detail": detail.into() }))).into_response()
}
fn parse_admin_pool_page(query: Option<&str>) -> Result<usize, String> {
match query_param_value(query, "page") {
None => Ok(1),
Some(value) => {
let parsed = value
.parse::<usize>()
.map_err(|_| "page must be an integer between 1 and 10000".to_string())?;
if (1..=10_000).contains(&parsed) {
Ok(parsed)
} else {
Err("page must be an integer between 1 and 10000".to_string())
}
}
}
}
fn parse_admin_pool_page_size(query: Option<&str>) -> Result<usize, String> {
match query_param_value(query, "page_size") {
None => Ok(50),
Some(value) => {
let parsed = value
.parse::<usize>()
.map_err(|_| "page_size must be an integer between 1 and 200".to_string())?;
if (1..=200).contains(&parsed) {
Ok(parsed)
} else {
Err("page_size must be an integer between 1 and 200".to_string())
}
}
}
}
fn parse_admin_pool_search(query: Option<&str>) -> Option<String> {
query_param_value(query, "search")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn parse_admin_pool_status_filter(query: Option<&str>) -> Result<String, String> {
let value = query_param_value(query, "status")
.unwrap_or_else(|| "all".to_string())
.trim()
.to_ascii_lowercase();
match value.as_str() {
"all" | "active" | "inactive" | "cooldown" => Ok(value),
_ => Err("status must be one of: all, active, cooldown, inactive".to_string()),
}
}
fn admin_pool_provider_id_from_path(request_path: &str) -> Option<String> {
let raw = request_path.strip_prefix("/api/admin/pool/")?;
let mut segments = raw.split('/');
let provider_id = segments.next()?.trim();
let keys_segment = segments.next()?.trim();
if provider_id.is_empty() || keys_segment != "keys" {
None
} else {
Some(provider_id.to_string())
}
}
fn is_admin_pool_route(request_context: &GatewayPublicRequestContext) -> bool {
let normalized_path = request_context.request_path.trim_end_matches('/');
let path = if normalized_path.is_empty() {
request_context.request_path.as_str()
} else {
normalized_path
};
(request_context.request_method == http::Method::GET && path == "/api/admin/pool/overview")
|| (request_context.request_method == http::Method::GET
&& path == "/api/admin/pool/scheduling-presets")
|| (request_context.request_method == http::Method::GET
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys")
&& path.matches('/').count() == 5)
|| (request_context.request_method == http::Method::POST
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys/batch-import")
&& path.matches('/').count() == 6)
|| (request_context.request_method == http::Method::POST
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys/batch-action")
&& path.matches('/').count() == 6)
|| (request_context.request_method == http::Method::POST
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys/resolve-selection")
&& path.matches('/').count() == 6)
|| (request_context.request_method == http::Method::GET
&& path.starts_with("/api/admin/pool/")
&& path.contains("/keys/batch-delete-task/")
&& path.matches('/').count() == 7)
|| (request_context.request_method == http::Method::POST
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys/cleanup-banned")
&& path.matches('/').count() == 6)
}
use self::support::{build_admin_pool_error_response, is_admin_pool_route};
pub(crate) async fn maybe_build_local_admin_pool_response(
state: &AppState,

View File

@@ -1,4 +1,4 @@
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::support::{
AdminProviderPoolConfig, AdminProviderPoolRuntimeState,
};
use crate::handlers::admin::shared::unix_secs_to_rfc3339;

View File

@@ -1,15 +1,16 @@
use super::{
use super::support::{
admin_pool_provider_id_from_path, build_admin_pool_error_response, parse_admin_pool_page,
parse_admin_pool_page_size, parse_admin_pool_search, parse_admin_pool_status_filter,
AdminPoolResolveSelectionRequest, ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
};
use super::{payloads as pool_payloads, selection as pool_selection};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::pool::{
admin_provider_pool_config, read_admin_provider_pool_cooldown_counts,
read_admin_provider_pool_cooldown_key_ids, read_admin_provider_pool_runtime_state,
use crate::handlers::admin::provider::pool::config::admin_provider_pool_config;
use crate::handlers::admin::provider::pool::runtime::{
read_admin_provider_pool_cooldown_counts, read_admin_provider_pool_cooldown_key_ids,
read_admin_provider_pool_runtime_state,
};
use crate::handlers::admin::provider::shared::AdminProviderPoolRuntimeState;
use crate::handlers::admin::provider::shared::support::AdminProviderPoolRuntimeState;
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyListQuery;
use axum::{

View File

@@ -0,0 +1,130 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::query_param_value;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
pub(super) const ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL: &str =
"Admin pool overview requires provider catalog reader";
pub(super) const ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL: &str =
"Admin pool cleanup requires provider catalog writer";
pub(super) const ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE: &str = "未发现可清理的异常账号";
#[derive(Debug, Default, Deserialize)]
pub(super) struct AdminPoolResolveSelectionRequest {
#[serde(default)]
pub(crate) search: String,
#[serde(default)]
pub(crate) quick_selectors: Vec<String>,
}
pub(super) fn build_admin_pool_error_response(
status: http::StatusCode,
detail: impl Into<String>,
) -> Response<Body> {
(status, Json(json!({ "detail": detail.into() }))).into_response()
}
pub(super) fn parse_admin_pool_page(query: Option<&str>) -> Result<usize, String> {
match query_param_value(query, "page") {
None => Ok(1),
Some(value) => {
let parsed = value
.parse::<usize>()
.map_err(|_| "page must be an integer between 1 and 10000".to_string())?;
if (1..=10_000).contains(&parsed) {
Ok(parsed)
} else {
Err("page must be an integer between 1 and 10000".to_string())
}
}
}
}
pub(super) fn parse_admin_pool_page_size(query: Option<&str>) -> Result<usize, String> {
match query_param_value(query, "page_size") {
None => Ok(50),
Some(value) => {
let parsed = value
.parse::<usize>()
.map_err(|_| "page_size must be an integer between 1 and 200".to_string())?;
if (1..=200).contains(&parsed) {
Ok(parsed)
} else {
Err("page_size must be an integer between 1 and 200".to_string())
}
}
}
}
pub(super) fn parse_admin_pool_search(query: Option<&str>) -> Option<String> {
query_param_value(query, "search")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(super) fn parse_admin_pool_status_filter(query: Option<&str>) -> Result<String, String> {
let value = query_param_value(query, "status")
.unwrap_or_else(|| "all".to_string())
.trim()
.to_ascii_lowercase();
match value.as_str() {
"all" | "active" | "inactive" | "cooldown" => Ok(value),
_ => Err("status must be one of: all, active, cooldown, inactive".to_string()),
}
}
pub(super) fn admin_pool_provider_id_from_path(request_path: &str) -> Option<String> {
let raw = request_path.strip_prefix("/api/admin/pool/")?;
let mut segments = raw.split('/');
let provider_id = segments.next()?.trim();
let keys_segment = segments.next()?.trim();
if provider_id.is_empty() || keys_segment != "keys" {
None
} else {
Some(provider_id.to_string())
}
}
pub(super) fn is_admin_pool_route(request_context: &GatewayPublicRequestContext) -> bool {
let normalized_path = request_context.request_path.trim_end_matches('/');
let path = if normalized_path.is_empty() {
request_context.request_path.as_str()
} else {
normalized_path
};
(request_context.request_method == http::Method::GET && path == "/api/admin/pool/overview")
|| (request_context.request_method == http::Method::GET
&& path == "/api/admin/pool/scheduling-presets")
|| (request_context.request_method == http::Method::GET
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys")
&& path.matches('/').count() == 5)
|| (request_context.request_method == http::Method::POST
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys/batch-import")
&& path.matches('/').count() == 6)
|| (request_context.request_method == http::Method::POST
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys/batch-action")
&& path.matches('/').count() == 6)
|| (request_context.request_method == http::Method::POST
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys/resolve-selection")
&& path.matches('/').count() == 6)
|| (request_context.request_method == http::Method::GET
&& path.starts_with("/api/admin/pool/")
&& path.contains("/keys/batch-delete-task/")
&& path.matches('/').count() == 7)
|| (request_context.request_method == http::Method::POST
&& path.starts_with("/api/admin/pool/")
&& path.ends_with("/keys/cleanup-banned")
&& path.matches('/').count() == 6)
}

View File

@@ -4,8 +4,9 @@ use axum::body::{Body, Bytes};
use axum::http::Response;
mod models;
mod payload;
mod response;
mod routes;
mod shared;
pub(crate) async fn maybe_build_local_admin_provider_query_response(
state: &AppState,

View File

@@ -1,6 +1,6 @@
use super::shared::{
use super::payload::{provider_query_extract_api_key_id, provider_query_extract_provider_id};
use super::response::{
build_admin_provider_query_bad_request_response, build_admin_provider_query_not_found_response,
provider_query_extract_api_key_id, provider_query_extract_provider_id,
ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL, ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL,
ADMIN_PROVIDER_QUERY_NO_LOCAL_MODELS_DETAIL, ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL,
ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL,

View File

@@ -1,46 +1,10 @@
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use axum::body::Bytes;
use axum::response::{IntoResponse, Response};
use serde_json::json;
pub(super) const ADMIN_PROVIDER_QUERY_INVALID_JSON_DETAIL: &str = "Invalid JSON request body";
pub(super) const ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL: &str = "provider_id is required";
pub(super) const ADMIN_PROVIDER_QUERY_MODEL_REQUIRED_DETAIL: &str = "model is required";
pub(super) const ADMIN_PROVIDER_QUERY_FAILOVER_MODELS_REQUIRED_DETAIL: &str =
"failover_models should not be empty";
pub(super) const ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL: &str = "Provider not found";
pub(super) const ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL: &str = "API Key not found";
pub(super) const ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL: &str =
"No active API Key found for this provider";
pub(super) const ADMIN_PROVIDER_QUERY_NO_LOCAL_MODELS_DETAIL: &str =
"No models available from local provider catalog";
pub(super) fn build_admin_provider_query_bad_request_response(
detail: &'static str,
) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response()
}
pub(super) fn build_admin_provider_query_not_found_response(
detail: &'static str,
) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": detail })),
)
.into_response()
}
pub(super) fn parse_admin_provider_query_body(
request_body: Option<&Bytes>,
) -> Result<serde_json::Value, Response<Body>> {
) -> Result<serde_json::Value, Response<axum::body::Body>> {
let Some(raw_body) = request_body else {
return Ok(json!({}));
};
@@ -48,7 +12,9 @@ pub(super) fn parse_admin_provider_query_body(
return Ok(json!({}));
}
serde_json::from_slice::<serde_json::Value>(raw_body).map_err(|_| {
build_admin_provider_query_bad_request_response(ADMIN_PROVIDER_QUERY_INVALID_JSON_DETAIL)
super::response::build_admin_provider_query_bad_request_response(
super::response::ADMIN_PROVIDER_QUERY_INVALID_JSON_DETAIL,
)
})
}

View File

@@ -0,0 +1,39 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) const ADMIN_PROVIDER_QUERY_INVALID_JSON_DETAIL: &str = "Invalid JSON request body";
pub(super) const ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL: &str = "provider_id is required";
pub(super) const ADMIN_PROVIDER_QUERY_MODEL_REQUIRED_DETAIL: &str = "model is required";
pub(super) const ADMIN_PROVIDER_QUERY_FAILOVER_MODELS_REQUIRED_DETAIL: &str =
"failover_models should not be empty";
pub(super) const ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL: &str = "Provider not found";
pub(super) const ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL: &str = "API Key not found";
pub(super) const ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL: &str =
"No active API Key found for this provider";
pub(super) const ADMIN_PROVIDER_QUERY_NO_LOCAL_MODELS_DETAIL: &str =
"No models available from local provider catalog";
pub(super) fn build_admin_provider_query_bad_request_response(
detail: &'static str,
) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response()
}
pub(super) fn build_admin_provider_query_not_found_response(
detail: &'static str,
) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": detail })),
)
.into_response()
}

View File

@@ -3,11 +3,14 @@ use super::models::{
build_admin_provider_query_test_model_failover_response,
build_admin_provider_query_test_model_response,
};
use super::shared::{
build_admin_provider_query_bad_request_response, parse_admin_provider_query_body,
provider_query_extract_failover_models, provider_query_extract_model,
provider_query_extract_provider_id, provider_query_extract_request_id,
provider_query_payload_keys, ADMIN_PROVIDER_QUERY_FAILOVER_MODELS_REQUIRED_DETAIL,
use super::payload::{
parse_admin_provider_query_body, provider_query_extract_failover_models,
provider_query_extract_model, provider_query_extract_provider_id,
provider_query_extract_request_id, provider_query_payload_keys,
};
use super::response::{
build_admin_provider_query_bad_request_response,
ADMIN_PROVIDER_QUERY_FAILOVER_MODELS_REQUIRED_DETAIL,
ADMIN_PROVIDER_QUERY_MODEL_REQUIRED_DETAIL, ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL,
};
use crate::control::GatewayPublicRequestContext;

View File

@@ -1,7 +1,3 @@
mod paths;
mod payloads;
mod support;
pub(crate) use self::paths::*;
pub(crate) use self::payloads::*;
pub(crate) use self::support::*;
pub(crate) mod paths;
pub(crate) mod payloads;
pub(crate) mod support;

View File

@@ -48,3 +48,24 @@ pub(crate) fn put_admin_provider_delete_task(
) {
state.put_provider_delete_task(task.clone());
}
pub(crate) fn normalize_provider_billing_type(value: &str) -> Result<String, String> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"monthly_quota" | "pay_as_you_go" | "free_tier" => Ok(normalized),
_ => Err("billing_type 仅支持 monthly_quota / pay_as_you_go / free_tier".to_string()),
}
}
pub(crate) fn parse_optional_rfc3339_unix_secs(
value: &str,
field_name: &str,
) -> Result<u64, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{field_name} 不能为空"));
}
let parsed = chrono::DateTime::parse_from_rfc3339(trimmed)
.map_err(|_| format!("{field_name} 必须是合法的 RFC3339 时间"))?;
u64::try_from(parsed.timestamp()).map_err(|_| format!("{field_name} 超出有效时间范围"))
}

View File

@@ -1,5 +1,6 @@
use super::super::write::{normalize_provider_billing_type, parse_optional_rfc3339_unix_secs};
use super::shared::admin_provider_strategy_provider_not_found_response;
use crate::handlers::admin::provider::shared::support::{
normalize_provider_billing_type, parse_optional_rfc3339_unix_secs,
};
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::{AppState, GatewayError};
use axum::{
@@ -257,3 +258,11 @@ pub(super) async fn build_provider_strategy_reset_quota_response(
}))
.into_response())
}
fn admin_provider_strategy_provider_not_found_response() -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider not found" })),
)
.into_response()
}

View File

@@ -4,8 +4,8 @@ use axum::body::{Body, Bytes};
use axum::http::Response;
mod builders;
mod responses;
mod routes;
mod shared;
pub(crate) async fn maybe_build_local_admin_provider_strategy_response(
state: &AppState,

View File

@@ -1,10 +1,5 @@
use crate::handlers::admin::shared::build_proxy_error_response;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use axum::{body::Body, http, response::Response};
use serde_json::json;
pub(super) const ADMIN_PROVIDER_STRATEGY_DATA_UNAVAILABLE_DETAIL: &str =
@@ -20,19 +15,3 @@ pub(super) fn admin_provider_strategy_data_unavailable_response(detail: &str) ->
Some(json!({ "error": detail })),
)
}
pub(super) fn admin_provider_strategy_provider_not_found_response() -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider not found" })),
)
.into_response()
}
pub(super) fn admin_provider_strategy_dispatcher_not_found_response() -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider strategy route not found" })),
)
.into_response()
}

View File

@@ -3,15 +3,13 @@ use super::builders::{
build_provider_strategy_stats_response, build_provider_strategy_update_billing_response,
AdminProviderStrategyBillingRequest,
};
use super::shared::{
use super::responses::{
admin_provider_strategy_data_unavailable_response,
admin_provider_strategy_dispatcher_not_found_response,
admin_provider_strategy_provider_not_found_response,
ADMIN_PROVIDER_STRATEGY_DATA_UNAVAILABLE_DETAIL,
ADMIN_PROVIDER_STRATEGY_STATS_DATA_UNAVAILABLE_DETAIL,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::paths::{
admin_provider_id_for_provider_strategy_billing, admin_provider_id_for_provider_strategy_quota,
admin_provider_id_for_provider_strategy_stats, is_admin_provider_strategy_strategies_root,
};
@@ -134,3 +132,19 @@ pub(super) async fn maybe_build_local_admin_provider_strategy_response(
Ok(Some(admin_provider_strategy_dispatcher_not_found_response()))
}
fn admin_provider_strategy_provider_not_found_response() -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider not found" })),
)
.into_response()
}
fn admin_provider_strategy_dispatcher_not_found_response() -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider strategy route not found" })),
)
.into_response()
}

View File

@@ -1,12 +1,11 @@
use super::{
normalize_auth_type, normalize_json_object, normalize_string_list, validate_vertex_api_formats,
};
use crate::handlers::admin::provider::shared::{
use super::normalize::{normalize_auth_type, validate_vertex_api_formats};
use crate::handlers::admin::provider::shared::payloads::{
AdminProviderKeyCreateRequest, AdminProviderKeyUpdateRequest,
};
use crate::handlers::admin::shared::{
build_admin_provider_key_response, decrypt_catalog_secret_with_fallbacks,
encrypt_catalog_secret_with_fallbacks, json_string_list, parse_catalog_auth_config_json,
encrypt_catalog_secret_with_fallbacks, json_string_list, normalize_json_object,
normalize_string_list, parse_catalog_auth_config_json,
};
use crate::AppState;
use aether_data_contracts::repository::provider_catalog::{

View File

@@ -1,85 +1,4 @@
use crate::handlers::admin::shared::{normalize_json_object, normalize_string_list};
pub(crate) fn normalize_provider_type_input(value: &str) -> Result<String, String> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"custom" | "claude_code" | "kiro" | "codex" | "gemini_cli" | "antigravity"
| "vertex_ai" => Ok(normalized),
_ => Err(
"provider_type 仅支持 custom / claude_code / kiro / codex / gemini_cli / antigravity / vertex_ai"
.to_string(),
),
}
}
pub(crate) fn normalize_provider_billing_type(value: &str) -> Result<String, String> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"monthly_quota" | "pay_as_you_go" | "free_tier" => Ok(normalized),
_ => Err("billing_type 仅支持 monthly_quota / pay_as_you_go / free_tier".to_string()),
}
}
pub(crate) fn parse_optional_rfc3339_unix_secs(
value: &str,
field_name: &str,
) -> Result<u64, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{field_name} 不能为空"));
}
let parsed = chrono::DateTime::parse_from_rfc3339(trimmed)
.map_err(|_| format!("{field_name} 必须是合法的 RFC3339 时间"))?;
u64::try_from(parsed.timestamp()).map_err(|_| format!("{field_name} 超出有效时间范围"))
}
pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String> {
let auth_type = value.unwrap_or("api_key").trim().to_ascii_lowercase();
match auth_type.as_str() {
"api_key" | "service_account" | "oauth" => Ok(auth_type),
_ => Err("auth_type 仅支持 api_key / service_account / oauth".to_string()),
}
}
pub(crate) fn validate_vertex_api_formats(
provider_type: &str,
auth_type: &str,
api_formats: &[String],
) -> Result<(), String> {
if !provider_type.trim().eq_ignore_ascii_case("vertex_ai") {
return Ok(());
}
let allowed = match auth_type {
"api_key" => &["gemini:chat"][..],
"service_account" | "vertex_ai" => &["claude:chat", "gemini:chat"][..],
_ => return Ok(()),
};
let invalid = api_formats
.iter()
.filter(|value| !allowed.contains(&value.as_str()))
.cloned()
.collect::<Vec<_>>();
if invalid.is_empty() {
return Ok(());
}
Err(format!(
"Vertex {auth_type} 不支持以下 API 格式: {};允许: {}",
invalid.join(", "),
allowed.join(", ")
))
}
mod keys;
mod provider;
mod reveal;
pub(crate) use self::keys::{
build_admin_create_provider_key_record, build_admin_provider_keys_payload,
build_admin_update_provider_key_record,
};
pub(crate) use self::provider::{
build_admin_create_provider_record, build_admin_fixed_provider_endpoint_record,
build_admin_update_provider_record,
};
pub(crate) use self::reveal::{build_admin_export_key_payload, build_admin_reveal_key_payload};
pub(crate) mod keys;
pub(crate) mod normalize;
pub(crate) mod provider;
pub(crate) mod reveal;

View File

@@ -0,0 +1,48 @@
pub(crate) fn normalize_provider_type_input(value: &str) -> Result<String, String> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"custom" | "claude_code" | "kiro" | "codex" | "gemini_cli" | "antigravity"
| "vertex_ai" => Ok(normalized),
_ => Err(
"provider_type 仅支持 custom / claude_code / kiro / codex / gemini_cli / antigravity / vertex_ai"
.to_string(),
),
}
}
pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String> {
let auth_type = value.unwrap_or("api_key").trim().to_ascii_lowercase();
match auth_type.as_str() {
"api_key" | "service_account" | "oauth" => Ok(auth_type),
_ => Err("auth_type 仅支持 api_key / service_account / oauth".to_string()),
}
}
pub(crate) fn validate_vertex_api_formats(
provider_type: &str,
auth_type: &str,
api_formats: &[String],
) -> Result<(), String> {
if !provider_type.trim().eq_ignore_ascii_case("vertex_ai") {
return Ok(());
}
let allowed = match auth_type {
"api_key" => &["gemini:chat"][..],
"service_account" | "vertex_ai" => &["claude:chat", "gemini:chat"][..],
_ => return Ok(()),
};
let invalid = api_formats
.iter()
.filter(|value| !allowed.contains(&value.as_str()))
.cloned()
.collect::<Vec<_>>();
if invalid.is_empty() {
return Ok(());
}
Err(format!(
"Vertex {auth_type} 不支持以下 API 格式: {};允许: {}",
invalid.join(", "),
allowed.join(", ")
))
}

View File

@@ -1,11 +1,12 @@
use super::{
normalize_json_object, normalize_provider_billing_type, normalize_provider_type_input,
parse_optional_rfc3339_unix_secs,
};
use super::normalize::normalize_provider_type_input;
use crate::api::ai::{admin_default_body_rules_for_signature, admin_endpoint_signature_parts};
use crate::handlers::admin::provider::shared::{
use crate::handlers::admin::provider::shared::payloads::{
AdminProviderCreateRequest, AdminProviderUpdateRequest,
};
use crate::handlers::admin::provider::shared::support::{
normalize_provider_billing_type, parse_optional_rfc3339_unix_secs,
};
use crate::handlers::admin::shared::normalize_json_object;
use crate::handlers::public::normalize_admin_base_url;
use crate::provider_transport::provider_types::provider_type_enables_format_conversion_by_default;
use crate::AppState;

View File

@@ -1 +0,0 @@
pub(super) use crate::handlers::admin::system::maybe_build_local_admin_management_tokens_response as maybe_build_local_admin_core_management_tokens_response;

View File

@@ -5,7 +5,6 @@ use axum::{
http,
response::Response,
};
const ADMIN_AWS_REGIONS: &[&str] = &[
"af-south-1",
"ap-east-1",
@@ -37,10 +36,6 @@ const ADMIN_AWS_REGIONS: &[&str] = &[
"us-west-1",
"us-west-2",
];
mod management_tokens_routes;
mod model_routes;
mod modules_routes;
mod oauth_routes;
mod system_routes;
pub(crate) async fn maybe_build_local_admin_core_response(
@@ -49,7 +44,7 @@ pub(crate) async fn maybe_build_local_admin_core_response(
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
if let Some(response) =
management_tokens_routes::maybe_build_local_admin_core_management_tokens_response(
crate::handlers::admin::system::maybe_build_local_admin_management_tokens_response(
state,
request_context,
)
@@ -57,7 +52,7 @@ pub(crate) async fn maybe_build_local_admin_core_response(
{
return Ok(Some(response));
}
if let Some(response) = oauth_routes::maybe_build_local_admin_core_oauth_response(
if let Some(response) = crate::handlers::admin::auth::maybe_build_local_admin_oauth_response(
state,
request_context,
request_body,
@@ -66,12 +61,13 @@ pub(crate) async fn maybe_build_local_admin_core_response(
{
return Ok(Some(response));
}
if let Some(response) = modules_routes::maybe_build_local_admin_core_modules_response(
state,
request_context,
request_body,
)
.await?
if let Some(response) =
crate::handlers::admin::system::maybe_build_local_admin_modules_response(
state,
request_context,
request_body,
)
.await?
{
return Ok(Some(response));
}
@@ -85,7 +81,11 @@ pub(crate) async fn maybe_build_local_admin_core_response(
return Ok(Some(response));
}
if let Some(response) =
model_routes::maybe_build_local_admin_core_model_response(state, request_context).await?
crate::handlers::admin::model::maybe_build_local_admin_model_catalog_response(
state,
request_context,
)
.await?
{
return Ok(Some(response));
}

View File

@@ -1 +0,0 @@
pub(super) use crate::handlers::admin::model::maybe_build_local_admin_model_catalog_response as maybe_build_local_admin_core_model_response;

View File

@@ -1 +0,0 @@
pub(super) use crate::handlers::admin::system::maybe_build_local_admin_modules_response as maybe_build_local_admin_core_modules_response;

View File

@@ -1 +0,0 @@
pub(super) use crate::handlers::admin::auth::maybe_build_local_admin_oauth_response as maybe_build_local_admin_core_oauth_response;

View File

@@ -2,18 +2,24 @@ use super::ADMIN_AWS_REGIONS;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::handlers::admin::shared::build_proxy_error_response;
use crate::handlers::admin::system::shared::{
use crate::handlers::admin::system::shared::configs::{
apply_admin_system_config_update, build_admin_system_config_detail_payload,
build_admin_system_config_export_payload, build_admin_system_configs_payload,
build_admin_system_users_export_payload, delete_admin_system_config,
};
use crate::handlers::admin::system::shared::email_templates::{
apply_admin_email_template_update, build_admin_email_template_payload,
build_admin_email_templates_payload, preview_admin_email_template, reset_admin_email_template,
};
use crate::handlers::admin::system::shared::paths::{
admin_system_config_key_from_path, admin_system_email_template_preview_type_from_path,
admin_system_email_template_reset_type_from_path, admin_system_email_template_type_from_path,
apply_admin_email_template_update, apply_admin_system_config_update,
apply_admin_system_settings_update, build_admin_api_formats_payload,
build_admin_email_template_payload, build_admin_email_templates_payload,
build_admin_system_check_update_payload, build_admin_system_config_detail_payload,
build_admin_system_config_export_payload, build_admin_system_configs_payload,
build_admin_system_settings_payload, build_admin_system_stats_payload,
build_admin_system_users_export_payload, current_aether_version, delete_admin_system_config,
is_admin_system_configs_root, is_admin_system_email_templates_root,
preview_admin_email_template, reset_admin_email_template,
};
use crate::handlers::admin::system::shared::settings::{
apply_admin_system_settings_update, build_admin_api_formats_payload,
build_admin_system_check_update_payload, build_admin_system_settings_payload,
build_admin_system_stats_payload, current_aether_version,
};
use crate::{AppState, GatewayError};
use axum::{

View File

@@ -1,6 +1,6 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
use crate::handlers::admin::system::shared::{
use crate::handlers::admin::system::shared::paths::{
admin_management_token_id_from_path, admin_management_token_status_id_from_path,
is_admin_management_tokens_root,
};

View File

@@ -2,7 +2,6 @@ mod adaptive;
mod core;
mod management_tokens;
mod modules;
mod pool;
mod proxy_nodes;
pub(crate) mod shared;
@@ -10,5 +9,5 @@ pub(crate) use self::adaptive::maybe_build_local_admin_adaptive_response;
pub(crate) use self::core::maybe_build_local_admin_core_response;
pub(crate) use self::management_tokens::maybe_build_local_admin_management_tokens_response;
pub(crate) use self::modules::maybe_build_local_admin_modules_response;
pub(crate) use self::pool::maybe_build_local_admin_pool_response;
pub(crate) use self::proxy_nodes::maybe_build_local_admin_proxy_nodes_response;
pub(crate) use crate::handlers::admin::provider::pool_admin::maybe_build_local_admin_pool_response;

View File

@@ -1,10 +1,13 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::system::shared::{
use crate::handlers::admin::system::shared::modules::{
admin_module_by_name, admin_module_name_from_enabled_path, admin_module_name_from_status_path,
build_admin_module_runtime_state, build_admin_module_status_payload,
build_admin_module_validation_result, build_admin_modules_status_payload,
AdminSetModuleEnabledRequest,
};
use crate::handlers::admin::system::shared::paths::{
is_admin_system_configs_root, is_admin_system_email_templates_root,
};
use crate::handlers::shared::module_available_from_env;
use crate::{AppState, GatewayError};
use axum::{

View File

@@ -1 +0,0 @@
pub(crate) use crate::handlers::admin::provider::pool_admin::maybe_build_local_admin_pool_response;

View File

@@ -1,14 +1,5 @@
mod configs;
mod email_templates;
mod modules;
mod paths;
mod settings;
pub(crate) use self::configs::*;
pub(crate) use self::email_templates::{
apply_admin_email_template_update, build_admin_email_template_payload,
build_admin_email_templates_payload, preview_admin_email_template, reset_admin_email_template,
};
pub(crate) use self::modules::*;
pub(crate) use self::paths::*;
pub(crate) use self::settings::*;
pub(crate) mod configs;
pub(crate) mod email_templates;
pub(crate) mod modules;
pub(crate) mod paths;
pub(crate) mod settings;

View File

@@ -12,7 +12,7 @@ use crate::execution_runtime::{
maybe_build_local_video_success_outcome, resolve_local_sync_error_background_report_kind,
resolve_local_sync_success_background_report_kind,
};
use crate::handlers::admin::provider::oauth::build_internal_control_error_response;
use crate::handlers::admin::provider::oauth::refresh::build_internal_control_error_response;
use crate::handlers::shared::{
unix_secs_to_rfc3339, InternalTunnelHeartbeatRequest, InternalTunnelNodeStatusRequest,
};

View File

@@ -1,4 +1,4 @@
use super::admin::provider::oauth::build_internal_control_error_response;
use super::admin::provider::oauth::refresh::build_internal_control_error_response;
mod gateway_helpers;
use self::gateway_helpers::*;

View File

@@ -16,7 +16,7 @@ use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::data::GatewayDataState;
use crate::handlers::admin::provider::ops::admin_provider_ops_local_action_response;
use crate::handlers::admin::provider::ops::providers::actions::admin_provider_ops_local_action_response;
use crate::{AppState, GatewayError};
#[path = "runtime/audit_cleanup.rs"]

View File

@@ -6,7 +6,7 @@ use aether_data_contracts::repository::provider_catalog::{
use futures_util::stream::{self, StreamExt};
use tracing::{debug, warn};
use crate::handlers::admin::provider::ops::admin_provider_ops_local_action_response;
use crate::handlers::admin::provider::ops::providers::actions::admin_provider_ops_local_action_response;
use crate::{AppState, GatewayError};
use super::{system_config_bool, PROVIDER_CHECKIN_CONCURRENCY};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
use super::*;
#[test]
fn non_admin_handlers_do_not_depend_on_admin_stats_module() {
let handlers_mod = read_workspace_file("apps/aether-gateway/src/handlers/mod.rs");
assert!(
!handlers_mod.contains("pub(crate) use admin::{"),
"handlers/mod.rs should stay as pure module wiring after shared usage stats facade extraction"
);
for path in [
"apps/aether-gateway/src/handlers/public/support/user_me.rs",
"apps/aether-gateway/src/handlers/public/support/wallet/reads.rs",
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_store.rs",
] {
let file = read_workspace_file(path);
assert!(
!file.contains("handlers::admin::stats::"),
"{path} should not depend directly on admin::stats"
);
}
let admin_mod = read_workspace_file("apps/aether-gateway/src/handlers/admin/mod.rs");
assert!(
!admin_mod.contains("pub(crate) mod facade;"),
"handlers/admin/mod.rs should not keep admin facade after direct subdomain exposure"
);
let shared_mod = read_workspace_file("apps/aether-gateway/src/handlers/shared/mod.rs");
for pattern in [
"admin_stats_bad_request_response",
"list_usage_for_optional_range",
"parse_bounded_u32",
"round_to",
"AdminStatsTimeRange",
"AdminStatsUsageFilter",
] {
assert!(
shared_mod.contains(pattern),
"handlers/shared/mod.rs should expose shared usage stats helper {pattern}"
);
}
let admin_observability_mod =
read_workspace_file("apps/aether-gateway/src/handlers/admin/observability/mod.rs");
for pattern in [
"admin_stats_bad_request_response",
"aggregate_usage_stats",
"list_usage_for_optional_range",
"parse_bounded_u32",
"round_to",
"AdminStatsTimeRange",
"AdminStatsUsageFilter",
"match_admin_monitoring_route",
"AdminMonitoringRoute",
"ADMIN_MONITORING_REDIS_REQUIRED_DETAIL",
"test_support",
] {
assert!(
!admin_observability_mod.contains(pattern),
"handlers/admin/observability/mod.rs should not re-export {pattern}"
);
}
let shared_usage_stats =
read_workspace_file("apps/aether-gateway/src/handlers/shared/usage_stats.rs");
assert!(
shared_usage_stats.contains("crate::handlers::admin::observability::stats::{"),
"handlers/shared/usage_stats.rs should depend on observability::stats directly"
);
}
#[test]
fn admin_monitoring_root_stays_thin() {
let monitoring_mod = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/mod.rs",
);
for pattern in [
"mod common;",
"use self::activity::{",
"use self::cache::{",
"use self::resilience::{",
"use self::trace::{",
"pub(crate) use self::routes::{",
"const ADMIN_MONITORING_",
] {
assert!(
!monitoring_mod.contains(pattern),
"handlers/admin/observability/monitoring/mod.rs should not act as a glue re-export layer for {pattern}"
);
}
assert!(
monitoring_mod.contains("routes::maybe_build_local_admin_monitoring_response"),
"handlers/admin/observability/monitoring/mod.rs should delegate through routes module"
);
assert!(
monitoring_mod.contains("mod cache_config;"),
"handlers/admin/observability/monitoring/mod.rs should register cache_config as a dedicated cache boundary"
);
assert!(
monitoring_mod.contains("mod cache_mutations;"),
"handlers/admin/observability/monitoring/mod.rs should register cache_mutations as a dedicated mutation boundary"
);
assert!(
monitoring_mod.contains("mod responses;"),
"handlers/admin/observability/monitoring/mod.rs should register responses as a dedicated response boundary"
);
assert!(
!workspace_file_exists(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/common.rs",
),
"handlers/admin/observability/monitoring/common.rs should stay removed after boundary split"
);
}
#[test]
fn admin_stats_root_stays_thin() {
let stats_mod =
read_workspace_file("apps/aether-gateway/src/handlers/admin/observability/stats/mod.rs");
for pattern in [
"use self::leaderboard::{",
"use self::responses::{",
"use self::timeseries::{",
"enum AdminStatsComparisonType",
"enum AdminStatsGranularity",
"struct AdminStatsForecastPoint",
"struct AdminStatsLeaderboardItem",
"struct AdminStatsUserMetadata",
"struct AdminStatsTimeSeriesBucket",
"impl AdminStatsTimeRange {",
"pub(crate) fn round_to(",
] {
assert!(
!stats_mod.contains(pattern),
"handlers/admin/observability/stats/mod.rs should not own stats helper implementation {pattern}"
);
}
for pattern in [
"mod helpers;",
"pub(crate) use self::helpers::{round_to, AdminStatsTimeRange, AdminStatsUsageFilter};",
"pub(crate) use self::range::{list_usage_for_optional_range, parse_bounded_u32};",
"pub(crate) use self::responses::admin_stats_bad_request_response;",
"pub(crate) use self::timeseries::aggregate_usage_stats;",
] {
assert!(
stats_mod.contains(pattern),
"handlers/admin/observability/stats/mod.rs should stay as a thin seam for {pattern}"
);
}
let analytics_routes = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs",
);
for pattern in [
"use super::helpers::{",
"use super::range::{",
"use super::responses::{",
"use super::timeseries::{",
] {
assert!(
analytics_routes.contains(pattern),
"stats/analytics_routes.rs should depend on split stats boundaries via {pattern}"
);
}
let cost_routes = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs",
);
for pattern in [
"use super::helpers::{",
"use super::range::{",
"use super::responses::{",
"use super::timeseries::{",
] {
assert!(
cost_routes.contains(pattern),
"stats/cost_routes.rs should depend on split stats boundaries via {pattern}"
);
}
let leaderboard_routes = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs",
);
for pattern in [
"use super::helpers::{",
"use super::leaderboard::{",
"use super::range::{",
"use super::responses::{",
] {
assert!(
leaderboard_routes.contains(pattern),
"stats/leaderboard_routes.rs should depend on split stats boundaries via {pattern}"
);
}
let provider_quota_routes = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/stats/provider_quota_routes.rs",
);
assert!(
provider_quota_routes
.contains("use super::responses::admin_stats_provider_quota_usage_empty_response;"),
"stats/provider_quota_routes.rs should depend on responses boundary directly"
);
}
#[test]
fn admin_monitoring_cache_mutations_are_split_from_reads() {
let monitoring_cache = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache.rs",
);
for pattern in [
"pub(super) async fn build_admin_monitoring_cache_users_delete_response(",
"pub(super) async fn build_admin_monitoring_cache_affinity_delete_response(",
"pub(super) async fn build_admin_monitoring_cache_flush_response(",
"pub(super) async fn build_admin_monitoring_cache_provider_delete_response(",
"pub(super) async fn build_admin_monitoring_model_mapping_delete_response(",
"pub(super) async fn build_admin_monitoring_model_mapping_delete_model_response(",
"pub(super) async fn build_admin_monitoring_model_mapping_delete_provider_response(",
"pub(super) async fn build_admin_monitoring_redis_keys_delete_response(",
] {
assert!(
!monitoring_cache.contains(pattern),
"monitoring/cache.rs should stay focused on read/report handlers, not {pattern}"
);
}
let monitoring_cache_mutations = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_mutations.rs",
);
for pattern in [
"pub(super) async fn build_admin_monitoring_cache_users_delete_response(",
"pub(super) async fn build_admin_monitoring_cache_affinity_delete_response(",
"pub(super) async fn build_admin_monitoring_cache_flush_response(",
"pub(super) async fn build_admin_monitoring_cache_provider_delete_response(",
"pub(super) async fn build_admin_monitoring_model_mapping_delete_response(",
"pub(super) async fn build_admin_monitoring_model_mapping_delete_model_response(",
"pub(super) async fn build_admin_monitoring_model_mapping_delete_provider_response(",
"pub(super) async fn build_admin_monitoring_redis_keys_delete_response(",
] {
assert!(
monitoring_cache_mutations.contains(pattern),
"monitoring/cache_mutations.rs should own {pattern}"
);
}
let monitoring_routes = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/routes.rs",
);
assert!(
monitoring_routes.contains("use super::cache_mutations::{"),
"monitoring/routes.rs should depend on cache_mutations directly for delete handlers"
);
assert!(
monitoring_routes.contains("use super::cache_affinity_reads::{"),
"monitoring/routes.rs should depend on cache_affinity_reads directly for affinity read handlers"
);
assert!(
monitoring_routes.contains("use super::cache_model_mapping::{"),
"monitoring/routes.rs should depend on cache_model_mapping directly for model-mapping read handlers"
);
let monitoring_mod = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/mod.rs",
);
for pattern in ["mod cache_affinity_reads;", "mod cache_model_mapping;"] {
assert!(
monitoring_mod.contains(pattern),
"monitoring/mod.rs should register split read module {pattern}"
);
}
let monitoring_affinity_reads = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_affinity_reads.rs",
);
for pattern in [
"pub(super) async fn build_admin_monitoring_cache_affinities_response(",
"pub(super) async fn build_admin_monitoring_cache_affinity_response(",
] {
assert!(
monitoring_affinity_reads.contains(pattern),
"monitoring/cache_affinity_reads.rs should own {pattern}"
);
}
let monitoring_model_mapping = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_model_mapping.rs",
);
for pattern in [
"pub(super) async fn build_admin_monitoring_model_mapping_stats_response(",
"pub(super) async fn build_admin_monitoring_redis_cache_categories_response(",
] {
assert!(
monitoring_model_mapping.contains(pattern),
"monitoring/cache_model_mapping.rs should own {pattern}"
);
}
}
#[test]
fn admin_usage_root_stays_thin() {
let usage_mod =
read_workspace_file("apps/aether-gateway/src/handlers/admin/observability/usage/mod.rs");
for pattern in ["pub(crate) use analytics::{", "pub(crate) use helpers::{"] {
assert!(
!usage_mod.contains(pattern),
"handlers/admin/observability/usage/mod.rs should not re-export helper seam {pattern}"
);
}
for pattern in [
"mod analytics;",
"mod analytics_routes;",
"mod detail_routes;",
"mod helpers;",
"mod replay;",
"mod summary_routes;",
"detail_routes::maybe_build_local_admin_usage_detail_response",
"summary_routes::maybe_build_local_admin_usage_summary_response",
"analytics_routes::maybe_build_local_admin_usage_analytics_response",
] {
assert!(
usage_mod.contains(pattern),
"handlers/admin/observability/usage/mod.rs should stay as a thin router for {pattern}"
);
}
let analytics_routes = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/usage/analytics_routes.rs",
);
assert!(
analytics_routes.contains("use super::analytics::{"),
"usage/analytics_routes.rs should depend on analytics boundary directly"
);
assert!(
analytics_routes.contains("use super::helpers::{"),
"usage/analytics_routes.rs should depend on helpers boundary directly"
);
let summary_routes = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs",
);
assert!(
summary_routes.contains("use super::analytics::{"),
"usage/summary_routes.rs should depend on analytics boundary directly"
);
assert!(
summary_routes.contains("use super::helpers::{"),
"usage/summary_routes.rs should depend on helpers boundary directly"
);
let detail_routes = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs",
);
assert!(
detail_routes.contains("use super::analytics::{"),
"usage/detail_routes.rs should depend on analytics boundary directly"
);
assert!(
detail_routes.contains("use super::helpers::{"),
"usage/detail_routes.rs should depend on helpers boundary directly"
);
let replay =
read_workspace_file("apps/aether-gateway/src/handlers/admin/observability/usage/replay.rs");
assert!(
replay.contains("use super::helpers::{"),
"usage/replay.rs should depend on helpers boundary directly"
);
}
#[test]
fn admin_monitoring_snapshots_stay_app_local() {
let monitoring_cache_types = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_types.rs",
);
for pattern in [
"pub(super) struct AdminMonitoringCacheSnapshot",
"pub(super) struct AdminMonitoringCacheAffinityRecord",
] {
assert!(
monitoring_cache_types.contains(pattern),
"monitoring/cache_types.rs should own {pattern}"
);
}
let monitoring_resilience = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/observability/monitoring/resilience.rs",
);
assert!(
monitoring_resilience.contains("AdminMonitoringResilienceSnapshot"),
"monitoring/resilience.rs should keep resilience snapshot ownership locally"
);
assert!(
monitoring_resilience.contains("struct AdminMonitoringResilienceSnapshot"),
"monitoring/resilience.rs should define AdminMonitoringResilienceSnapshot locally"
);
let data_system = read_workspace_file("crates/aether-data/src/repository/system.rs");
assert!(
!data_system.contains("AdminMonitoringCacheSnapshot")
&& !data_system.contains("AdminMonitoringResilienceSnapshot"),
"monitoring snapshots are admin view DTOs and should not move into aether-data"
);
}

Some files were not shown because too many files have changed in this diff Show More