feat(admin): 实现系统数据导入导出功能,支持提供商和模型批量配置

This commit is contained in:
fawney19
2026-04-11 21:39:04 +08:00
parent 801e16c988
commit a9f610fa69
36 changed files with 3247 additions and 409 deletions

View File

@@ -33,8 +33,7 @@ use self::shared::{
admin_api_keys_parse_limit, admin_api_keys_parse_skip, build_admin_api_key_detail_payload, admin_api_keys_parse_limit, admin_api_keys_parse_skip, build_admin_api_key_detail_payload,
build_admin_api_key_list_item_payload, build_admin_api_keys_bad_request_response, build_admin_api_key_list_item_payload, build_admin_api_keys_bad_request_response,
build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response, build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response,
AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyFieldPresence, AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyToggleRequest,
AdminStandaloneApiKeyToggleRequest, AdminStandaloneApiKeyUpdateRequest,
}; };
pub(crate) async fn maybe_build_local_admin_api_keys_response( pub(crate) async fn maybe_build_local_admin_api_keys_response(

View File

@@ -2,8 +2,8 @@ use super::shared::{
admin_api_key_total_tokens_by_ids, admin_api_keys_id_from_path, admin_api_keys_operator_id, admin_api_key_total_tokens_by_ids, admin_api_keys_id_from_path, admin_api_keys_operator_id,
build_admin_api_key_detail_payload, build_admin_api_keys_bad_request_response, build_admin_api_key_detail_payload, build_admin_api_keys_bad_request_response,
build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response, build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response,
AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyFieldPresence, AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyToggleRequest,
AdminStandaloneApiKeyToggleRequest, AdminStandaloneApiKeyUpdateRequest, AdminStandaloneApiKeyUpdatePatch,
}; };
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response; use crate::handlers::admin::shared::attach_admin_audit_response;
@@ -162,14 +162,7 @@ pub(super) async fn build_admin_update_api_key_response(
)); ));
} }
}; };
let field_presence = AdminStandaloneApiKeyFieldPresence { let patch = match AdminStandaloneApiKeyUpdatePatch::from_object(raw_payload) {
allowed_providers: raw_payload.contains_key("allowed_providers"),
allowed_api_formats: raw_payload.contains_key("allowed_api_formats"),
allowed_models: raw_payload.contains_key("allowed_models"),
};
let payload = match serde_json::from_value::<AdminStandaloneApiKeyUpdateRequest>(
serde_json::Value::Object(raw_payload),
) {
Ok(value) => value, Ok(value) => value,
Err(_) => { Err(_) => {
return Ok(build_admin_api_keys_bad_request_response( return Ok(build_admin_api_keys_bad_request_response(
@@ -177,6 +170,7 @@ pub(super) async fn build_admin_update_api_key_response(
)); ));
} }
}; };
let (field_presence, payload) = patch.into_parts();
if payload.initial_balance_usd.is_some() if payload.initial_balance_usd.is_some()
|| payload.unlimited_balance.is_some() || payload.unlimited_balance.is_some()
|| payload.expire_days.is_some() || payload.expire_days.is_some()
@@ -197,7 +191,7 @@ pub(super) async fn build_admin_update_api_key_response(
"rate_limit 必须大于等于 0", "rate_limit 必须大于等于 0",
)); ));
} }
let allowed_providers = if field_presence.allowed_providers { let allowed_providers = if field_presence.contains("allowed_providers") {
match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") { match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") {
Ok(value) => Some(value), Ok(value) => Some(value),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)), Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
@@ -205,7 +199,7 @@ pub(super) async fn build_admin_update_api_key_response(
} else { } else {
None None
}; };
let allowed_api_formats = if field_presence.allowed_api_formats { let allowed_api_formats = if field_presence.contains("allowed_api_formats") {
match normalize_admin_user_api_formats(payload.allowed_api_formats) { match normalize_admin_user_api_formats(payload.allowed_api_formats) {
Ok(value) => Some(value), Ok(value) => Some(value),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)), Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
@@ -213,7 +207,7 @@ pub(super) async fn build_admin_update_api_key_response(
} else { } else {
None None
}; };
let allowed_models = if field_presence.allowed_models { let allowed_models = if field_presence.contains("allowed_models") {
match normalize_admin_user_string_list(payload.allowed_models, "allowed_models") { match normalize_admin_user_string_list(payload.allowed_models, "allowed_models") {
Ok(value) => Some(value), Ok(value) => Some(value),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)), Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),

View File

@@ -1,5 +1,5 @@
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::query_param_value; use crate::handlers::admin::shared::{query_param_value, AdminTypedObjectPatch};
use crate::handlers::admin::users::{ use crate::handlers::admin::users::{
format_optional_unix_secs_iso8601, masked_user_api_key_display, format_optional_unix_secs_iso8601, masked_user_api_key_display,
}; };
@@ -43,18 +43,14 @@ pub(super) struct AdminStandaloneApiKeyUpdateRequest {
pub(super) auto_delete_on_expiry: Option<bool>, pub(super) auto_delete_on_expiry: Option<bool>,
} }
pub(super) type AdminStandaloneApiKeyUpdatePatch =
AdminTypedObjectPatch<AdminStandaloneApiKeyUpdateRequest>;
#[derive(Debug, Default, serde::Deserialize)] #[derive(Debug, Default, serde::Deserialize)]
pub(super) struct AdminStandaloneApiKeyToggleRequest { pub(super) struct AdminStandaloneApiKeyToggleRequest {
pub(super) is_active: Option<bool>, pub(super) is_active: Option<bool>,
} }
#[derive(Debug, Default)]
pub(super) struct AdminStandaloneApiKeyFieldPresence {
pub(super) allowed_providers: bool,
pub(super) allowed_api_formats: bool,
pub(super) allowed_models: bool,
}
pub(super) fn build_admin_api_keys_data_unavailable_response() -> Response<Body> { pub(super) fn build_admin_api_keys_data_unavailable_response() -> Response<Body> {
( (
http::StatusCode::SERVICE_UNAVAILABLE, http::StatusCode::SERVICE_UNAVAILABLE,

View File

@@ -15,7 +15,7 @@ use super::shared::{
use crate::handlers::admin::model::shared::{ use crate::handlers::admin::model::shared::{
admin_global_model_assign_to_providers_id, admin_global_model_id_from_path, admin_global_model_assign_to_providers_id, admin_global_model_id_from_path,
is_admin_global_models_root, AdminBatchAssignToProvidersRequest, AdminBatchDeleteIdsRequest, is_admin_global_models_root, AdminBatchAssignToProvidersRequest, AdminBatchDeleteIdsRequest,
AdminGlobalModelCreateRequest, AdminGlobalModelUpdateRequest, AdminGlobalModelCreateRequest, AdminGlobalModelUpdatePatch,
}; };
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response; use crate::handlers::admin::shared::attach_admin_audit_response;
@@ -150,16 +150,14 @@ async fn build_update_global_model_response(
Ok(payload) => payload, Ok(payload) => payload,
Err(response) => return Ok(response), Err(response) => return Ok(response),
}; };
let payload = match serde_json::from_value::<AdminGlobalModelUpdateRequest>(raw_value) { let patch = match AdminGlobalModelUpdatePatch::from_object(raw_payload) {
Ok(payload) => payload, Ok(patch) => patch,
Err(_) => return Ok(bad_request_response("请求体必须是合法的 JSON 对象")), Err(_) => return Ok(bad_request_response("请求体必须是合法的 JSON 对象")),
}; };
let record = let record = match build_admin_global_model_update_record(state, &existing, patch).await {
match build_admin_global_model_update_record(state, &existing, &raw_payload, payload).await Ok(record) => record,
{ Err(detail) => return Ok(bad_request_response(detail)),
Ok(record) => record, };
Err(detail) => return Ok(bad_request_response(detail)),
};
Ok(match state.update_admin_global_model(&record).await? { Ok(match state.update_admin_global_model(&record).await? {
Some(updated) => attach_admin_audit_response( Some(updated) => attach_admin_audit_response(

View File

@@ -1,3 +1,4 @@
use crate::handlers::admin::shared::AdminTypedObjectPatch;
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -32,6 +33,8 @@ pub(crate) struct AdminGlobalModelUpdateRequest {
pub(crate) config: Option<serde_json::Value>, pub(crate) config: Option<serde_json::Value>,
} }
pub(crate) type AdminGlobalModelUpdatePatch = AdminTypedObjectPatch<AdminGlobalModelUpdateRequest>;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AdminBatchDeleteIdsRequest { pub(crate) struct AdminBatchDeleteIdsRequest {
pub(crate) ids: Vec<String>, pub(crate) ids: Vec<String>,

View File

@@ -1,6 +1,6 @@
use super::payloads::{normalize_optional_price, normalize_required_trimmed_string}; use super::payloads::{normalize_optional_price, normalize_required_trimmed_string};
use crate::handlers::admin::model::shared::{ use crate::handlers::admin::model::shared::{
AdminGlobalModelCreateRequest, AdminGlobalModelUpdateRequest, AdminGlobalModelCreateRequest, AdminGlobalModelUpdatePatch,
}; };
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{normalize_json_object, normalize_string_list}; use crate::handlers::admin::shared::{normalize_json_object, normalize_string_list};
@@ -49,12 +49,12 @@ pub(crate) async fn build_admin_global_model_create_record(
pub(crate) async fn build_admin_global_model_update_record( pub(crate) async fn build_admin_global_model_update_record(
_state: &AdminAppState<'_>, _state: &AdminAppState<'_>,
existing: &StoredAdminGlobalModel, existing: &StoredAdminGlobalModel,
raw_payload: &serde_json::Map<String, serde_json::Value>, patch: AdminGlobalModelUpdatePatch,
payload: AdminGlobalModelUpdateRequest,
) -> Result<UpdateAdminGlobalModelRecord, String> { ) -> Result<UpdateAdminGlobalModelRecord, String> {
let display_name = if let Some(value) = raw_payload.get("display_name") { let (fields, payload) = patch.into_parts();
let display_name = if fields.contains("display_name") {
let Some(display_name) = payload.display_name.as_deref() else { let Some(display_name) = payload.display_name.as_deref() else {
return Err(if value.is_null() { return Err(if fields.is_null("display_name") {
"display_name 不能为空".to_string() "display_name 不能为空".to_string()
} else { } else {
"display_name 必须是字符串".to_string() "display_name 必须是字符串".to_string()
@@ -65,7 +65,7 @@ pub(crate) async fn build_admin_global_model_update_record(
existing.display_name.clone() existing.display_name.clone()
}; };
let default_price_per_request = if raw_payload.contains_key("default_price_per_request") { let default_price_per_request = if fields.contains("default_price_per_request") {
normalize_optional_price( normalize_optional_price(
payload.default_price_per_request, payload.default_price_per_request,
"default_price_per_request", "default_price_per_request",
@@ -74,19 +74,19 @@ pub(crate) async fn build_admin_global_model_update_record(
existing.default_price_per_request existing.default_price_per_request
}; };
let default_tiered_pricing = if raw_payload.contains_key("default_tiered_pricing") { let default_tiered_pricing = if fields.contains("default_tiered_pricing") {
normalize_json_object(payload.default_tiered_pricing, "default_tiered_pricing")? normalize_json_object(payload.default_tiered_pricing, "default_tiered_pricing")?
} else { } else {
existing.default_tiered_pricing.clone() existing.default_tiered_pricing.clone()
}; };
let supported_capabilities = if raw_payload.contains_key("supported_capabilities") { let supported_capabilities = if fields.contains("supported_capabilities") {
normalize_string_list(payload.supported_capabilities).map(|value| json!(value)) normalize_string_list(payload.supported_capabilities).map(|value| json!(value))
} else { } else {
existing.supported_capabilities.clone() existing.supported_capabilities.clone()
}; };
let config = if raw_payload.contains_key("config") { let config = if fields.contains("config") {
normalize_json_object(payload.config, "config")? normalize_json_object(payload.config, "config")?
} else { } else {
existing.config.clone() existing.config.clone()

View File

@@ -3,7 +3,7 @@ use crate::handlers::admin::provider::shared::paths::{
admin_provider_id_for_manage_path, is_admin_providers_root, admin_provider_id_for_manage_path, is_admin_providers_root,
}; };
use crate::handlers::admin::provider::shared::payloads::{ use crate::handlers::admin::provider::shared::payloads::{
AdminProviderCreateRequest, AdminProviderUpdateRequest, AdminProviderCreateRequest, AdminProviderUpdatePatch,
}; };
use crate::handlers::admin::provider::write::provider::build_admin_fixed_provider_endpoint_record; use crate::handlers::admin::provider::write::provider::build_admin_fixed_provider_endpoint_record;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -137,8 +137,8 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
"请求体必须是合法的 JSON 对象", "请求体必须是合法的 JSON 对象",
))); )));
}; };
let payload = match serde_json::from_value::<AdminProviderUpdateRequest>(raw_value) { let patch = match AdminProviderUpdatePatch::from_object(raw_payload) {
Ok(payload) => payload, Ok(patch) => patch,
Err(_) => { Err(_) => {
return Ok(Some(build_admin_provider_bad_request_response( return Ok(Some(build_admin_provider_bad_request_response(
"请求体必须是合法的 JSON 对象", "请求体必须是合法的 JSON 对象",
@@ -156,7 +156,7 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
)))); ))));
}; };
let updated_record = match state let updated_record = match state
.build_admin_update_provider_record(&existing_provider, &raw_payload, payload) .build_admin_update_provider_record(&existing_provider, patch)
.await .await
{ {
Ok(record) => record, Ok(record) => record,

View File

@@ -1,5 +1,5 @@
use crate::handlers::admin::provider::shared::paths::admin_update_key_id; use crate::handlers::admin::provider::shared::paths::admin_update_key_id;
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdateRequest; use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::GatewayError; use crate::GatewayError;
use axum::{ use axum::{
@@ -46,8 +46,8 @@ pub(super) async fn maybe_handle(
let Some(raw_payload) = raw_value.as_object().cloned() else { let Some(raw_payload) = raw_value.as_object().cloned() else {
return Ok(Some(bad_request_response("请求体必须是合法的 JSON 对象"))); return Ok(Some(bad_request_response("请求体必须是合法的 JSON 对象")));
}; };
let payload = match serde_json::from_value::<AdminProviderKeyUpdateRequest>(raw_value) { let patch = match AdminProviderKeyUpdatePatch::from_object(raw_payload) {
Ok(payload) => payload, Ok(patch) => patch,
Err(_) => return Ok(Some(bad_request_response("请求体必须是合法的 JSON 对象"))), Err(_) => return Ok(Some(bad_request_response("请求体必须是合法的 JSON 对象"))),
}; };
@@ -72,7 +72,7 @@ pub(super) async fn maybe_handle(
}; };
let updated_record = match state let updated_record = match state
.build_admin_update_provider_key_record(&provider, &existing_key, &raw_payload, payload) .build_admin_update_provider_key_record(&provider, &existing_key, patch)
.await .await
{ {
Ok(record) => record, Ok(record) => record,

View File

@@ -1,3 +1,4 @@
use crate::handlers::admin::shared::AdminTypedObjectPatch;
use aether_admin::provider::endpoints as admin_provider_endpoints_pure; use aether_admin::provider::endpoints as admin_provider_endpoints_pure;
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
@@ -82,3 +83,6 @@ pub(crate) struct AdminProviderEndpointUpdateRequest {
#[serde(default)] #[serde(default)]
pub(crate) format_acceptance_config: Option<serde_json::Value>, pub(crate) format_acceptance_config: Option<serde_json::Value>,
} }
pub(crate) type AdminProviderEndpointUpdatePatch =
AdminTypedObjectPatch<AdminProviderEndpointUpdateRequest>;

View File

@@ -1,7 +1,7 @@
use super::extractors::admin_endpoint_id; use super::extractors::admin_endpoint_id;
use super::payloads::{ use super::payloads::{
build_admin_provider_endpoint_response, endpoint_key_counts_by_format, build_admin_provider_endpoint_response, endpoint_key_counts_by_format,
AdminProviderEndpointUpdateRequest, AdminProviderEndpointUpdatePatch,
}; };
use super::support::build_admin_endpoints_data_unavailable_response; use super::support::build_admin_endpoints_data_unavailable_response;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -75,8 +75,8 @@ pub(super) async fn maybe_handle(
.into_response(), .into_response(),
)); ));
}; };
let payload = match serde_json::from_value::<AdminProviderEndpointUpdateRequest>(raw_value) { let patch = match AdminProviderEndpointUpdatePatch::from_object(raw_payload) {
Ok(payload) => payload, Ok(patch) => patch,
Err(_) => { Err(_) => {
return Ok(Some( return Ok(Some(
( (
@@ -118,12 +118,7 @@ pub(super) async fn maybe_handle(
)); ));
}; };
let updated_record = match state let updated_record = match state
.build_admin_update_provider_endpoint_record( .build_admin_update_provider_endpoint_record(&provider, &existing_endpoint, patch)
&provider,
&existing_endpoint,
&raw_payload,
payload,
)
.await .await
{ {
Ok(record) => record, Ok(record) => record,

View File

@@ -1,6 +1,6 @@
use super::payloads::build_admin_provider_model_response; use super::payloads::build_admin_provider_model_response;
use crate::handlers::admin::provider::shared::paths::admin_provider_model_route_parts; use crate::handlers::admin::provider::shared::paths::admin_provider_model_route_parts;
use crate::handlers::admin::provider::shared::payloads::AdminProviderModelUpdateRequest; use crate::handlers::admin::provider::shared::payloads::AdminProviderModelUpdatePatch;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::GatewayError; use crate::GatewayError;
use axum::{ use axum::{
@@ -75,8 +75,8 @@ pub(super) async fn maybe_handle(
.into_response(), .into_response(),
)); ));
}; };
let payload = match serde_json::from_value::<AdminProviderModelUpdateRequest>(raw_value) { let patch = match AdminProviderModelUpdatePatch::from_object(raw_payload) {
Ok(payload) => payload, Ok(patch) => patch,
Err(_) => { Err(_) => {
return Ok(Some( return Ok(Some(
( (
@@ -88,7 +88,7 @@ pub(super) async fn maybe_handle(
} }
}; };
let record = match state let record = match state
.build_admin_provider_model_update_record(&existing, &raw_payload, payload) .build_admin_provider_model_update_record(&existing, patch)
.await .await
{ {
Ok(record) => record, Ok(record) => record,

View File

@@ -1,3 +1,4 @@
use crate::handlers::admin::shared::AdminTypedObjectPatch;
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -83,6 +84,8 @@ pub(crate) struct AdminProviderKeyUpdateRequest {
pub(crate) fingerprint: Option<serde_json::Value>, pub(crate) fingerprint: Option<serde_json::Value>,
} }
pub(crate) type AdminProviderKeyUpdatePatch = AdminTypedObjectPatch<AdminProviderKeyUpdateRequest>;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AdminProviderKeyBatchDeleteRequest { pub(crate) struct AdminProviderKeyBatchDeleteRequest {
pub(crate) ids: Vec<String>, pub(crate) ids: Vec<String>,
@@ -187,6 +190,8 @@ pub(crate) struct AdminProviderUpdateRequest {
pub(crate) config: Option<serde_json::Value>, pub(crate) config: Option<serde_json::Value>,
} }
pub(crate) type AdminProviderUpdatePatch = AdminTypedObjectPatch<AdminProviderUpdateRequest>;
pub(crate) const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage"; pub(crate) const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
pub(crate) const KIRO_USAGE_LIMITS_PATH: &str = "/getUsageLimits"; pub(crate) const KIRO_USAGE_LIMITS_PATH: &str = "/getUsageLimits";
pub(crate) const KIRO_USAGE_SDK_VERSION: &str = "1.0.0"; pub(crate) const KIRO_USAGE_SDK_VERSION: &str = "1.0.0";
@@ -248,6 +253,9 @@ pub(crate) struct AdminProviderModelUpdateRequest {
pub(crate) config: Option<serde_json::Value>, pub(crate) config: Option<serde_json::Value>,
} }
pub(crate) type AdminProviderModelUpdatePatch =
AdminTypedObjectPatch<AdminProviderModelUpdateRequest>;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AdminBatchAssignGlobalModelsRequest { pub(crate) struct AdminBatchAssignGlobalModelsRequest {
pub(crate) global_model_ids: Vec<String>, pub(crate) global_model_ids: Vec<String>,

View File

@@ -1,4 +1,4 @@
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdateRequest; use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
use crate::handlers::admin::provider::write::normalize::{ use crate::handlers::admin::provider::write::normalize::{
normalize_auth_type, validate_vertex_api_formats, normalize_auth_type, validate_vertex_api_formats,
}; };
@@ -17,11 +17,11 @@ pub(crate) async fn build_admin_update_provider_key_record(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider, provider: &StoredProviderCatalogProvider,
existing: &StoredProviderCatalogKey, existing: &StoredProviderCatalogKey,
raw_payload: &serde_json::Map<String, serde_json::Value>, patch: AdminProviderKeyUpdatePatch,
payload: AdminProviderKeyUpdateRequest,
) -> Result<StoredProviderCatalogKey, String> { ) -> Result<StoredProviderCatalogKey, String> {
let state = state.as_ref(); let state = state.as_ref();
let mut updated = existing.clone(); let mut updated = existing.clone();
let (fields, payload) = patch.into_parts();
let current_auth_type = normalize_auth_type(Some(&existing.auth_type))?; let current_auth_type = normalize_auth_type(Some(&existing.auth_type))?;
let target_auth_type = payload let target_auth_type = payload
.auth_type .auth_type
@@ -34,7 +34,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
.as_deref() .as_deref()
.is_some_and(|_| target_auth_type != current_auth_type); .is_some_and(|_| target_auth_type != current_auth_type);
let api_key_present = raw_payload.contains_key("api_key"); let api_key_present = fields.contains("api_key");
let api_key_value = payload let api_key_value = payload
.api_key .api_key
.as_deref() .as_deref()
@@ -44,7 +44,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
return Err("api_key 不能为空".to_string()); return Err("api_key 不能为空".to_string());
} }
let auth_config_present = raw_payload.contains_key("auth_config"); let auth_config_present = fields.contains("auth_config");
let auth_config = normalize_json_object(payload.auth_config, "auth_config")?; let auth_config = normalize_json_object(payload.auth_config, "auth_config")?;
let auth_config_object = auth_config let auth_config_object = auth_config
.as_ref() .as_ref()
@@ -182,7 +182,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
_ => {} _ => {}
} }
if raw_payload.contains_key("api_formats") { if fields.contains("api_formats") {
let api_formats = normalize_string_list(payload.api_formats) let api_formats = normalize_string_list(payload.api_formats)
.ok_or_else(|| "api_formats 为必填字段".to_string())?; .ok_or_else(|| "api_formats 为必填字段".to_string())?;
validate_vertex_api_formats(&provider.provider_type, &target_auth_type, &api_formats)?; validate_vertex_api_formats(&provider.provider_type, &target_auth_type, &api_formats)?;
@@ -201,30 +201,30 @@ pub(crate) async fn build_admin_update_provider_key_record(
} }
updated.name = trimmed.to_string(); updated.name = trimmed.to_string();
} }
if raw_payload.contains_key("rate_multipliers") { if fields.contains("rate_multipliers") {
updated.rate_multipliers = updated.rate_multipliers =
normalize_json_object(payload.rate_multipliers, "rate_multipliers")?; normalize_json_object(payload.rate_multipliers, "rate_multipliers")?;
} }
if let Some(internal_priority) = payload.internal_priority { if let Some(internal_priority) = payload.internal_priority {
updated.internal_priority = internal_priority; updated.internal_priority = internal_priority;
} }
if raw_payload.contains_key("global_priority_by_format") { if fields.contains("global_priority_by_format") {
updated.global_priority_by_format = normalize_json_object( updated.global_priority_by_format = normalize_json_object(
payload.global_priority_by_format, payload.global_priority_by_format,
"global_priority_by_format", "global_priority_by_format",
)?; )?;
} }
if raw_payload.contains_key("rpm_limit") { if fields.contains("rpm_limit") {
updated.rpm_limit = payload.rpm_limit; updated.rpm_limit = payload.rpm_limit;
if payload.rpm_limit.is_none() { if payload.rpm_limit.is_none() {
updated.learned_rpm_limit = None; updated.learned_rpm_limit = None;
} }
} }
if raw_payload.contains_key("allowed_models") { if fields.contains("allowed_models") {
updated.allowed_models = updated.allowed_models =
normalize_string_list(payload.allowed_models).map(|value| json!(value)); normalize_string_list(payload.allowed_models).map(|value| json!(value));
} }
if raw_payload.contains_key("capabilities") { if fields.contains("capabilities") {
updated.capabilities = normalize_json_object(payload.capabilities, "capabilities")?; updated.capabilities = normalize_json_object(payload.capabilities, "capabilities")?;
} }
if let Some(cache_ttl_minutes) = payload.cache_ttl_minutes { if let Some(cache_ttl_minutes) = payload.cache_ttl_minutes {
@@ -236,7 +236,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
if let Some(is_active) = payload.is_active { if let Some(is_active) = payload.is_active {
updated.is_active = is_active; updated.is_active = is_active;
} }
if raw_payload.contains_key("note") { if fields.contains("note") {
updated.note = payload updated.note = payload
.note .note
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
@@ -245,22 +245,22 @@ pub(crate) async fn build_admin_update_provider_key_record(
if let Some(auto_fetch_models) = payload.auto_fetch_models { if let Some(auto_fetch_models) = payload.auto_fetch_models {
updated.auto_fetch_models = auto_fetch_models; updated.auto_fetch_models = auto_fetch_models;
} }
if raw_payload.contains_key("locked_models") { if fields.contains("locked_models") {
updated.locked_models = updated.locked_models =
normalize_string_list(payload.locked_models).map(|value| json!(value)); normalize_string_list(payload.locked_models).map(|value| json!(value));
} }
if raw_payload.contains_key("model_include_patterns") { if fields.contains("model_include_patterns") {
updated.model_include_patterns = updated.model_include_patterns =
normalize_string_list(payload.model_include_patterns).map(|value| json!(value)); normalize_string_list(payload.model_include_patterns).map(|value| json!(value));
} }
if raw_payload.contains_key("model_exclude_patterns") { if fields.contains("model_exclude_patterns") {
updated.model_exclude_patterns = updated.model_exclude_patterns =
normalize_string_list(payload.model_exclude_patterns).map(|value| json!(value)); normalize_string_list(payload.model_exclude_patterns).map(|value| json!(value));
} }
if raw_payload.contains_key("proxy") { if fields.contains("proxy") {
updated.proxy = normalize_json_object(payload.proxy, "proxy")?; updated.proxy = normalize_json_object(payload.proxy, "proxy")?;
} }
if raw_payload.contains_key("fingerprint") { if fields.contains("fingerprint") {
updated.fingerprint = normalize_json_object(payload.fingerprint, "fingerprint")?; updated.fingerprint = normalize_json_object(payload.fingerprint, "fingerprint")?;
} }
if auth_config_present && !auth_type_switch && updated.auth_type != "api_key" { if auth_config_present && !auth_type_switch && updated.auth_type != "api_key" {

View File

@@ -1,4 +1,4 @@
use crate::handlers::admin::provider::shared::payloads::AdminProviderUpdateRequest; use crate::handlers::admin::provider::shared::payloads::AdminProviderUpdatePatch;
use crate::handlers::admin::provider::shared::support::{ use crate::handlers::admin::provider::shared::support::{
normalize_provider_billing_type, parse_optional_rfc3339_unix_secs, normalize_provider_billing_type, parse_optional_rfc3339_unix_secs,
}; };
@@ -11,15 +11,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) async fn build_admin_update_provider_record( pub(crate) async fn build_admin_update_provider_record(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
existing: &StoredProviderCatalogProvider, existing: &StoredProviderCatalogProvider,
raw_payload: &serde_json::Map<String, serde_json::Value>, patch: AdminProviderUpdatePatch,
payload: AdminProviderUpdateRequest,
) -> Result<StoredProviderCatalogProvider, String> { ) -> Result<StoredProviderCatalogProvider, String> {
let state = state.as_ref(); let state = state.as_ref();
let mut updated = existing.clone(); let mut updated = existing.clone();
let (fields, payload) = patch.into_parts();
if let Some(value) = raw_payload.get("name") { if fields.contains("name") {
let Some(name) = payload.name.as_deref() else { let Some(name) = payload.name.as_deref() else {
return Err(if value.is_null() { return Err(if fields.is_null("name") {
"name 不能为空".to_string() "name 不能为空".to_string()
} else { } else {
"name 必须是字符串".to_string() "name 必须是字符串".to_string()
@@ -41,9 +41,9 @@ pub(crate) async fn build_admin_update_provider_record(
updated.name = trimmed.to_string(); updated.name = trimmed.to_string();
} }
let target_provider_type = if let Some(value) = raw_payload.get("provider_type") { let target_provider_type = if fields.contains("provider_type") {
let Some(provider_type) = payload.provider_type.as_deref() else { let Some(provider_type) = payload.provider_type.as_deref() else {
return Err(if value.is_null() { return Err(if fields.is_null("provider_type") {
"provider_type 不能为空".to_string() "provider_type 不能为空".to_string()
} else { } else {
"provider_type 必须是字符串".to_string() "provider_type 必须是字符串".to_string()
@@ -56,17 +56,17 @@ pub(crate) async fn build_admin_update_provider_record(
updated.provider_type.clone() updated.provider_type.clone()
}; };
if raw_payload.contains_key("description") { if fields.contains("description") {
updated.description = payload updated.description = payload
.description .description
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
} }
if let Some(value) = raw_payload.get("website") { if fields.contains("website") {
updated.website = match payload.website { updated.website = match payload.website {
None => { None => {
if value.is_null() { if fields.is_null("website") {
None None
} else { } else {
return Err("website 必须是字符串".to_string()); return Err("website 必须是字符串".to_string());
@@ -85,9 +85,9 @@ pub(crate) async fn build_admin_update_provider_record(
}; };
} }
if let Some(value) = raw_payload.get("billing_type") { if fields.contains("billing_type") {
let Some(billing_type) = payload.billing_type.as_deref() else { let Some(billing_type) = payload.billing_type.as_deref() else {
return Err(if value.is_null() { return Err(if fields.is_null("billing_type") {
"billing_type 不能为空".to_string() "billing_type 不能为空".to_string()
} else { } else {
"billing_type 必须是字符串".to_string() "billing_type 必须是字符串".to_string()
@@ -96,8 +96,8 @@ pub(crate) async fn build_admin_update_provider_record(
updated.billing_type = Some(normalize_provider_billing_type(billing_type)?); updated.billing_type = Some(normalize_provider_billing_type(billing_type)?);
} }
if let Some(value) = raw_payload.get("monthly_quota_usd") { if fields.contains("monthly_quota_usd") {
if value.is_null() { if fields.is_null("monthly_quota_usd") {
updated.monthly_quota_usd = None; updated.monthly_quota_usd = None;
} else { } else {
let Some(monthly_quota_usd) = payload.monthly_quota_usd else { let Some(monthly_quota_usd) = payload.monthly_quota_usd else {
@@ -110,8 +110,8 @@ pub(crate) async fn build_admin_update_provider_record(
} }
} }
if let Some(value) = raw_payload.get("quota_reset_day") { if fields.contains("quota_reset_day") {
if value.is_null() { if fields.is_null("quota_reset_day") {
updated.quota_reset_day = None; updated.quota_reset_day = None;
} else { } else {
let Some(quota_reset_day) = payload.quota_reset_day else { let Some(quota_reset_day) = payload.quota_reset_day else {
@@ -124,8 +124,8 @@ pub(crate) async fn build_admin_update_provider_record(
} }
} }
if let Some(value) = raw_payload.get("quota_last_reset_at") { if fields.contains("quota_last_reset_at") {
if value.is_null() { if fields.is_null("quota_last_reset_at") {
updated.quota_last_reset_at_unix_secs = None; updated.quota_last_reset_at_unix_secs = None;
} else { } else {
let Some(raw) = payload.quota_last_reset_at.as_deref() else { let Some(raw) = payload.quota_last_reset_at.as_deref() else {
@@ -138,8 +138,8 @@ pub(crate) async fn build_admin_update_provider_record(
} }
} }
if let Some(value) = raw_payload.get("quota_expires_at") { if fields.contains("quota_expires_at") {
if value.is_null() { if fields.is_null("quota_expires_at") {
updated.quota_expires_at_unix_secs = None; updated.quota_expires_at_unix_secs = None;
} else { } else {
let Some(raw) = payload.quota_expires_at.as_deref() else { let Some(raw) = payload.quota_expires_at.as_deref() else {
@@ -150,9 +150,9 @@ pub(crate) async fn build_admin_update_provider_record(
} }
} }
if let Some(value) = raw_payload.get("provider_priority") { if fields.contains("provider_priority") {
let Some(provider_priority) = payload.provider_priority else { let Some(provider_priority) = payload.provider_priority else {
return Err(if value.is_null() { return Err(if fields.is_null("provider_priority") {
"provider_priority 不能为空".to_string() "provider_priority 不能为空".to_string()
} else { } else {
"provider_priority 必须是整数".to_string() "provider_priority 必须是整数".to_string()
@@ -164,21 +164,21 @@ pub(crate) async fn build_admin_update_provider_record(
updated.provider_priority = provider_priority; updated.provider_priority = provider_priority;
} }
if let Some(_value) = raw_payload.get("keep_priority_on_conversion") { if fields.contains("keep_priority_on_conversion") {
let Some(keep_priority_on_conversion) = payload.keep_priority_on_conversion else { let Some(keep_priority_on_conversion) = payload.keep_priority_on_conversion else {
return Err("keep_priority_on_conversion 必须是布尔值".to_string()); return Err("keep_priority_on_conversion 必须是布尔值".to_string());
}; };
updated.keep_priority_on_conversion = keep_priority_on_conversion; updated.keep_priority_on_conversion = keep_priority_on_conversion;
} }
if let Some(_value) = raw_payload.get("is_active") { if fields.contains("is_active") {
let Some(is_active) = payload.is_active else { let Some(is_active) = payload.is_active else {
return Err("is_active 必须是布尔值".to_string()); return Err("is_active 必须是布尔值".to_string());
}; };
updated.is_active = is_active; updated.is_active = is_active;
} }
if raw_payload.contains_key("concurrent_limit") { if fields.contains("concurrent_limit") {
updated.concurrent_limit = match payload.concurrent_limit { updated.concurrent_limit = match payload.concurrent_limit {
Some(value) if value >= 0 => Some(value), Some(value) if value >= 0 => Some(value),
Some(_) => return Err("concurrent_limit 必须是非负整数".to_string()), Some(_) => return Err("concurrent_limit 必须是非负整数".to_string()),
@@ -186,7 +186,7 @@ pub(crate) async fn build_admin_update_provider_record(
}; };
} }
if raw_payload.contains_key("max_retries") { if fields.contains("max_retries") {
updated.max_retries = match payload.max_retries { updated.max_retries = match payload.max_retries {
Some(value) if (0..=999).contains(&value) => Some(value), Some(value) if (0..=999).contains(&value) => Some(value),
Some(_) => return Err("max_retries 必须是 0 到 999 之间的整数".to_string()), Some(_) => return Err("max_retries 必须是 0 到 999 之间的整数".to_string()),
@@ -194,11 +194,11 @@ pub(crate) async fn build_admin_update_provider_record(
}; };
} }
if raw_payload.contains_key("proxy") { if fields.contains("proxy") {
updated.proxy = normalize_json_object(payload.proxy, "proxy")?; updated.proxy = normalize_json_object(payload.proxy, "proxy")?;
} }
if raw_payload.contains_key("stream_first_byte_timeout") { if fields.contains("stream_first_byte_timeout") {
updated.stream_first_byte_timeout_secs = match payload.stream_first_byte_timeout { updated.stream_first_byte_timeout_secs = match payload.stream_first_byte_timeout {
Some(value) if (1.0..=300.0).contains(&value) => Some(value), Some(value) if (1.0..=300.0).contains(&value) => Some(value),
Some(_) => { Some(_) => {
@@ -208,7 +208,7 @@ pub(crate) async fn build_admin_update_provider_record(
}; };
} }
if raw_payload.contains_key("request_timeout") { if fields.contains("request_timeout") {
updated.request_timeout_secs = match payload.request_timeout { updated.request_timeout_secs = match payload.request_timeout {
Some(value) if (1.0..=600.0).contains(&value) => Some(value), Some(value) if (1.0..=600.0).contains(&value) => Some(value),
Some(_) => return Err("request_timeout 必须是 1 到 600 之间的数字".to_string()), Some(_) => return Err("request_timeout 必须是 1 到 600 之间的数字".to_string()),
@@ -216,14 +216,14 @@ pub(crate) async fn build_admin_update_provider_record(
}; };
} }
if let Some(_value) = raw_payload.get("enable_format_conversion") { if fields.contains("enable_format_conversion") {
let Some(enable_format_conversion) = payload.enable_format_conversion else { let Some(enable_format_conversion) = payload.enable_format_conversion else {
return Err("enable_format_conversion 必须是布尔值".to_string()); return Err("enable_format_conversion 必须是布尔值".to_string());
}; };
updated.enable_format_conversion = enable_format_conversion; updated.enable_format_conversion = enable_format_conversion;
} }
let config_seed = if raw_payload.contains_key("config") { let config_seed = if fields.contains("config") {
normalize_json_object(payload.config, "config")? normalize_json_object(payload.config, "config")?
} else { } else {
updated.config.clone() updated.config.clone()
@@ -232,11 +232,8 @@ pub(crate) async fn build_admin_update_provider_record(
.and_then(|value| value.as_object().cloned()) .and_then(|value| value.as_object().cloned())
.unwrap_or_default(); .unwrap_or_default();
if raw_payload.contains_key("claude_code_advanced") { if fields.contains("claude_code_advanced") {
if raw_payload if fields.is_null("claude_code_advanced") {
.get("claude_code_advanced")
.is_some_and(serde_json::Value::is_null)
{
config_map.remove("claude_code_advanced"); config_map.remove("claude_code_advanced");
} else { } else {
if target_provider_type != "claude_code" { if target_provider_type != "claude_code" {
@@ -251,11 +248,8 @@ pub(crate) async fn build_admin_update_provider_record(
config_map.remove("claude_code_advanced"); config_map.remove("claude_code_advanced");
} }
if raw_payload.contains_key("pool_advanced") { if fields.contains("pool_advanced") {
if raw_payload if fields.is_null("pool_advanced") {
.get("pool_advanced")
.is_some_and(serde_json::Value::is_null)
{
config_map.remove("pool_advanced"); config_map.remove("pool_advanced");
} else { } else {
let value = normalize_json_object(payload.pool_advanced, "pool_advanced")? let value = normalize_json_object(payload.pool_advanced, "pool_advanced")?
@@ -264,11 +258,8 @@ pub(crate) async fn build_admin_update_provider_record(
} }
} }
if raw_payload.contains_key("failover_rules") { if fields.contains("failover_rules") {
if raw_payload if fields.is_null("failover_rules") {
.get("failover_rules")
.is_some_and(serde_json::Value::is_null)
{
config_map.remove("failover_rules"); config_map.remove("failover_rules");
} else { } else {
let value = normalize_json_object(payload.failover_rules, "failover_rules")? let value = normalize_json_object(payload.failover_rules, "failover_rules")?

View File

@@ -1,7 +1,7 @@
use super::AdminAppState; use super::AdminAppState;
use crate::handlers::admin::provider::shared::payloads::{ use crate::handlers::admin::provider::shared::payloads::{
AdminImportProviderModelsRequest, AdminProviderModelCreateRequest, AdminImportProviderModelsRequest, AdminProviderModelCreateRequest,
AdminProviderModelUpdateRequest, AdminProviderModelUpdatePatch,
}; };
use crate::handlers::admin::shared::{normalize_json_array, normalize_json_object}; use crate::handlers::admin::shared::{normalize_json_array, normalize_json_object};
use crate::GatewayError; use crate::GatewayError;
@@ -128,12 +128,12 @@ impl<'a> AdminAppState<'a> {
pub(crate) async fn build_admin_provider_model_update_record( pub(crate) async fn build_admin_provider_model_update_record(
&self, &self,
existing: &StoredAdminProviderModel, existing: &StoredAdminProviderModel,
raw_payload: &serde_json::Map<String, serde_json::Value>, patch: AdminProviderModelUpdatePatch,
payload: AdminProviderModelUpdateRequest,
) -> Result<UpsertAdminProviderModelRecord, String> { ) -> Result<UpsertAdminProviderModelRecord, String> {
let provider_model_name = if let Some(value) = raw_payload.get("provider_model_name") { let (fields, payload) = patch.into_parts();
let provider_model_name = if fields.contains("provider_model_name") {
let Some(name) = payload.provider_model_name.as_deref() else { let Some(name) = payload.provider_model_name.as_deref() else {
return Err(if value.is_null() { return Err(if fields.is_null("provider_model_name") {
"provider_model_name 不能为空".to_string() "provider_model_name 不能为空".to_string()
} else { } else {
"provider_model_name 必须是字符串".to_string() "provider_model_name 必须是字符串".to_string()
@@ -155,9 +155,9 @@ impl<'a> AdminAppState<'a> {
existing.provider_model_name.clone() existing.provider_model_name.clone()
}; };
let global_model_id = if let Some(value) = raw_payload.get("global_model_id") { let global_model_id = if fields.contains("global_model_id") {
let Some(global_model_id) = payload.global_model_id.as_deref() else { let Some(global_model_id) = payload.global_model_id.as_deref() else {
return Err(if value.is_null() { return Err(if fields.is_null("global_model_id") {
"global_model_id 不能为空".to_string() "global_model_id 不能为空".to_string()
} else { } else {
"global_model_id 必须是字符串".to_string() "global_model_id 必须是字符串".to_string()
@@ -175,7 +175,7 @@ impl<'a> AdminAppState<'a> {
existing.global_model_id.clone() existing.global_model_id.clone()
}; };
let price_per_request = if raw_payload.contains_key("price_per_request") { let price_per_request = if fields.contains("price_per_request") {
admin_provider_models_write_pure::normalize_optional_price( admin_provider_models_write_pure::normalize_optional_price(
payload.price_per_request, payload.price_per_request,
"price_per_request", "price_per_request",
@@ -183,17 +183,17 @@ impl<'a> AdminAppState<'a> {
} else { } else {
existing.price_per_request existing.price_per_request
}; };
let tiered_pricing = if raw_payload.contains_key("tiered_pricing") { let tiered_pricing = if fields.contains("tiered_pricing") {
normalize_json_object(payload.tiered_pricing, "tiered_pricing")? normalize_json_object(payload.tiered_pricing, "tiered_pricing")?
} else { } else {
existing.tiered_pricing.clone() existing.tiered_pricing.clone()
}; };
let provider_model_mappings = if raw_payload.contains_key("provider_model_mappings") { let provider_model_mappings = if fields.contains("provider_model_mappings") {
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")? normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?
} else { } else {
existing.provider_model_mappings.clone() existing.provider_model_mappings.clone()
}; };
let config = if raw_payload.contains_key("config") { let config = if fields.contains("config") {
normalize_json_object(payload.config, "config")? normalize_json_object(payload.config, "config")?
} else { } else {
existing.config.clone() existing.config.clone()
@@ -206,22 +206,22 @@ impl<'a> AdminAppState<'a> {
provider_model_mappings, provider_model_mappings,
price_per_request, price_per_request,
tiered_pricing, tiered_pricing,
if raw_payload.contains_key("supports_vision") { if fields.contains("supports_vision") {
payload.supports_vision payload.supports_vision
} else { } else {
existing.supports_vision existing.supports_vision
}, },
if raw_payload.contains_key("supports_function_calling") { if fields.contains("supports_function_calling") {
payload.supports_function_calling payload.supports_function_calling
} else { } else {
existing.supports_function_calling existing.supports_function_calling
}, },
if raw_payload.contains_key("supports_streaming") { if fields.contains("supports_streaming") {
payload.supports_streaming payload.supports_streaming
} else { } else {
existing.supports_streaming existing.supports_streaming
}, },
if raw_payload.contains_key("supports_extended_thinking") { if fields.contains("supports_extended_thinking") {
payload.supports_extended_thinking payload.supports_extended_thinking
} else { } else {
existing.supports_extended_thinking existing.supports_extended_thinking

View File

@@ -23,16 +23,11 @@ impl<'a> AdminAppState<'a> {
&self, &self,
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider, provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
existing: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey, existing: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
raw_payload: &serde_json::Map<String, serde_json::Value>, patch: crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch,
payload: crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdateRequest,
) -> Result<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey, String> ) -> Result<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey, String>
{ {
crate::handlers::admin::provider::write::keys::build_admin_update_provider_key_record( crate::handlers::admin::provider::write::keys::build_admin_update_provider_key_record(
self, self, provider, existing, patch,
provider,
existing,
raw_payload,
payload,
) )
.await .await
} }
@@ -130,17 +125,13 @@ impl<'a> AdminAppState<'a> {
pub(crate) async fn build_admin_update_provider_record( pub(crate) async fn build_admin_update_provider_record(
&self, &self,
existing: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider, existing: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
raw_payload: &serde_json::Map<String, serde_json::Value>, patch: crate::handlers::admin::provider::shared::payloads::AdminProviderUpdatePatch,
payload: crate::handlers::admin::provider::shared::payloads::AdminProviderUpdateRequest,
) -> Result< ) -> Result<
aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider, aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
String, String,
> { > {
crate::handlers::admin::provider::write::provider::build_admin_update_provider_record( crate::handlers::admin::provider::write::provider::build_admin_update_provider_record(
self, self, existing, patch,
existing,
raw_payload,
payload,
) )
.await .await
} }
@@ -277,8 +268,7 @@ impl<'a> AdminAppState<'a> {
&self, &self,
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider, provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
existing_endpoint: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint, existing_endpoint: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint,
raw_payload: &serde_json::Map<String, serde_json::Value>, patch: crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdatePatch,
payload: crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdateRequest,
) -> Result< ) -> Result<
aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint, aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint,
String, String,
@@ -286,9 +276,10 @@ impl<'a> AdminAppState<'a> {
use crate::api::ai::admin_endpoint_signature_parts; use crate::api::ai::admin_endpoint_signature_parts;
use crate::handlers::public::{admin_requested_force_stream, normalize_admin_base_url}; use crate::handlers::public::{admin_requested_force_stream, normalize_admin_base_url};
use aether_admin::provider::endpoints as admin_provider_endpoints_pure; use aether_admin::provider::endpoints as admin_provider_endpoints_pure;
let (fields, payload) = patch.into_parts();
if self.provider_type_is_fixed(&provider.provider_type) if self.provider_type_is_fixed(&provider.provider_type)
&& (raw_payload.contains_key("base_url") || raw_payload.contains_key("custom_path")) && (fields.contains("base_url") || fields.contains("custom_path"))
{ {
return Err( return Err(
"固定类型 Provider 的 Endpoint 不允许修改 base_url/custom_path".to_string(), "固定类型 Provider 的 Endpoint 不允许修改 base_url/custom_path".to_string(),
@@ -312,13 +303,14 @@ impl<'a> AdminAppState<'a> {
let mut updated = let mut updated =
admin_provider_endpoints_pure::apply_admin_provider_endpoint_update_fields( admin_provider_endpoints_pure::apply_admin_provider_endpoint_update_fields(
existing_endpoint, existing_endpoint,
raw_payload, |field| fields.contains(field),
|field| fields.is_null(field),
&update_fields, &update_fields,
)?; )?;
let provider_type = provider.provider_type.trim().to_ascii_lowercase(); let provider_type = provider.provider_type.trim().to_ascii_lowercase();
if provider_type == "codex" && existing_endpoint.api_format == "openai:cli" { if provider_type == "codex" && existing_endpoint.api_format == "openai:cli" {
let has_config_in_payload = raw_payload.contains_key("config"); let has_config_in_payload = fields.contains("config");
let config_payload = if has_config_in_payload { let config_payload = if has_config_in_payload {
updated updated
.config .config

View File

@@ -2,11 +2,15 @@ use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::system::shared::configs::is_sensitive_admin_system_config_key; use crate::handlers::admin::system::shared::configs::is_sensitive_admin_system_config_key;
use crate::handlers::admin::system::shared::export::{ use crate::handlers::admin::system::shared::export::{
build_admin_system_export_providers_payload, decrypt_admin_system_export_secret, build_admin_system_export_providers_payload, decrypt_admin_system_export_secret,
ADMIN_SYSTEM_CONFIG_EXPORT_VERSION, ADMIN_SYSTEM_EXPORT_PAGE_LIMIT, ADMIN_SYSTEM_EXPORT_PAGE_LIMIT,
}; };
use crate::handlers::shared::{system_config_string, unix_secs_to_rfc3339}; use crate::handlers::shared::{system_config_string, unix_secs_to_rfc3339};
use crate::GatewayError; use crate::GatewayError;
use aether_admin::system::serialize_admin_system_users_export_wallet; use aether_admin::system::{
serialize_admin_system_users_export_wallet, AdminSystemConfigDocument, AdminSystemConfigEntry,
AdminSystemConfigGlobalModel, AdminSystemConfigLdap, AdminSystemConfigOAuthProvider,
AdminSystemConfigProxyNode, ADMIN_SYSTEM_CONFIG_EXPORT_VERSION,
};
use aether_data_contracts::repository::global_models::AdminGlobalModelListQuery; use aether_data_contracts::repository::global_models::AdminGlobalModelListQuery;
use chrono::Utc; use chrono::Utc;
use serde_json::json; use serde_json::json;
@@ -31,42 +35,50 @@ impl<'a> AdminAppState<'a> {
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();
let global_models_data = global_models let global_models_data = global_models
.iter() .iter()
.map(|model| { .map(|model| AdminSystemConfigGlobalModel {
json!({ name: model.name.clone(),
"name": model.name, display_name: model.display_name.clone(),
"display_name": model.display_name, default_price_per_request: model.default_price_per_request,
"default_price_per_request": model.default_price_per_request, default_tiered_pricing: model.default_tiered_pricing.clone(),
"default_tiered_pricing": model.default_tiered_pricing, supported_capabilities: model.supported_capabilities.as_ref().and_then(|value| {
"supported_capabilities": model.supported_capabilities, value.as_array().map(|items| {
"config": model.config, items
"is_active": model.is_active, .iter()
}) .filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
}),
config: model.config.clone(),
is_active: model.is_active,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let providers_data = let providers_data =
build_admin_system_export_providers_payload(self, &global_model_name_by_id).await?; build_admin_system_export_providers_payload(self, &global_model_name_by_id).await?;
let ldap_data = self.get_ldap_module_config().await?.map(|config| { let ldap_data = self
let bind_password = config .get_ldap_module_config()
.bind_password_encrypted .await?
.as_deref() .map(|config| AdminSystemConfigLdap {
.and_then(|ciphertext| decrypt_admin_system_export_secret(self, ciphertext)) server_url: config.server_url,
.unwrap_or_default(); bind_dn: config.bind_dn,
json!({ bind_password: Some(
"server_url": config.server_url, config
"bind_dn": config.bind_dn, .bind_password_encrypted
"bind_password": bind_password, .as_deref()
"base_dn": config.base_dn, .and_then(|ciphertext| decrypt_admin_system_export_secret(self, ciphertext))
"user_search_filter": config.user_search_filter, .unwrap_or_default(),
"username_attr": config.username_attr, ),
"email_attr": config.email_attr, base_dn: config.base_dn,
"display_name_attr": config.display_name_attr, user_search_filter: config.user_search_filter,
"is_enabled": config.is_enabled, username_attr: config.username_attr,
"is_exclusive": config.is_exclusive, email_attr: config.email_attr,
"use_starttls": config.use_starttls, display_name_attr: config.display_name_attr,
"connect_timeout": config.connect_timeout, is_enabled: config.is_enabled,
}) is_exclusive: config.is_exclusive,
}); use_starttls: config.use_starttls,
connect_timeout: config.connect_timeout,
});
let system_configs = self.list_system_config_entries().await?; let system_configs = self.list_system_config_entries().await?;
let system_configs_data = system_configs let system_configs_data = system_configs
@@ -82,73 +94,72 @@ impl<'a> AdminAppState<'a> {
} else { } else {
entry.value.clone() entry.value.clone()
}; };
json!({ AdminSystemConfigEntry {
"key": entry.key, key: entry.key.clone(),
"value": value, value,
"description": entry.description, description: entry.description.clone(),
}) }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let oauth_providers = self.list_oauth_provider_configs().await?; let oauth_providers = self.list_oauth_provider_configs().await?;
let oauth_data = oauth_providers let oauth_data = oauth_providers
.iter() .iter()
.map(|provider| { .map(|provider| AdminSystemConfigOAuthProvider {
let client_secret = provider provider_type: provider.provider_type.clone(),
.client_secret_encrypted display_name: provider.display_name.clone(),
.as_deref() client_id: provider.client_id.clone(),
.and_then(|ciphertext| decrypt_admin_system_export_secret(self, ciphertext)) client_secret: Some(
.unwrap_or_default(); provider
json!({ .client_secret_encrypted
"provider_type": provider.provider_type, .as_deref()
"display_name": provider.display_name, .and_then(|ciphertext| decrypt_admin_system_export_secret(self, ciphertext))
"client_id": provider.client_id, .unwrap_or_default(),
"client_secret": client_secret, ),
"authorization_url_override": provider.authorization_url_override, authorization_url_override: provider.authorization_url_override.clone(),
"token_url_override": provider.token_url_override, token_url_override: provider.token_url_override.clone(),
"userinfo_url_override": provider.userinfo_url_override, userinfo_url_override: provider.userinfo_url_override.clone(),
"scopes": provider.scopes, scopes: provider.scopes.clone(),
"redirect_uri": provider.redirect_uri, redirect_uri: provider.redirect_uri.clone(),
"frontend_callback_url": provider.frontend_callback_url, frontend_callback_url: provider.frontend_callback_url.clone(),
"attribute_mapping": provider.attribute_mapping, attribute_mapping: provider.attribute_mapping.clone(),
"extra_config": provider.extra_config, extra_config: provider.extra_config.clone(),
"is_enabled": provider.is_enabled, is_enabled: provider.is_enabled,
})
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let proxy_nodes = self.list_proxy_nodes().await?; let proxy_nodes = self.list_proxy_nodes().await?;
let proxy_nodes_data = proxy_nodes let proxy_nodes_data = proxy_nodes
.iter() .iter()
.map(|node| { .map(|node| AdminSystemConfigProxyNode {
json!({ id: Some(node.id.clone()),
"id": node.id, name: Some(node.name.clone()),
"name": node.name, ip: Some(node.ip.clone()),
"ip": node.ip, port: Some(node.port),
"port": node.port, region: node.region.clone(),
"region": node.region, is_manual: Some(node.is_manual),
"is_manual": node.is_manual, proxy_url: node.proxy_url.clone(),
"proxy_url": node.proxy_url, proxy_username: node.proxy_username.clone(),
"proxy_username": node.proxy_username, proxy_password: node.proxy_password.clone(),
"proxy_password": node.proxy_password, tunnel_mode: Some(node.tunnel_mode),
"tunnel_mode": node.tunnel_mode, heartbeat_interval: Some(node.heartbeat_interval),
"heartbeat_interval": node.heartbeat_interval, remote_config: node.remote_config.clone(),
"remote_config": node.remote_config, config_version: Some(node.config_version),
"config_version": node.config_version,
})
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(json!({ let document = AdminSystemConfigDocument {
"version": ADMIN_SYSTEM_CONFIG_EXPORT_VERSION, version: ADMIN_SYSTEM_CONFIG_EXPORT_VERSION.to_string(),
"exported_at": Utc::now().to_rfc3339(), exported_at: Utc::now().to_rfc3339(),
"global_models": global_models_data, global_models: global_models_data,
"providers": providers_data, providers: providers_data,
"proxy_nodes": proxy_nodes_data, proxy_nodes: proxy_nodes_data,
"ldap_config": ldap_data, ldap_config: ldap_data,
"oauth_providers": oauth_data, oauth_providers: oauth_data,
"system_configs": system_configs_data, system_configs: system_configs_data,
})) };
serde_json::to_value(document).map_err(|err| GatewayError::Internal(err.to_string()))
} }
pub(crate) async fn build_admin_system_users_export_payload( pub(crate) async fn build_admin_system_users_export_payload(

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ use crate::GatewayError;
mod adaptive; mod adaptive;
mod export; mod export;
mod import;
mod modules; mod modules;
mod proxy_nodes; mod proxy_nodes;
mod templates; mod templates;

View File

@@ -1 +1,126 @@
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AdminJsonFieldState {
Missing,
Null,
Present,
}
impl AdminJsonFieldState {
pub(crate) fn is_present(self) -> bool {
!matches!(self, Self::Missing)
}
pub(crate) fn is_null(self) -> bool {
matches!(self, Self::Null)
}
}
#[derive(Debug, Clone)]
pub(crate) struct AdminJsonObjectPatch {
field_states: BTreeMap<String, AdminJsonFieldState>,
}
impl AdminJsonObjectPatch {
fn from_object(raw_payload: &Map<String, Value>) -> Self {
let field_states = raw_payload
.iter()
.map(|(key, value)| {
let state = if value.is_null() {
AdminJsonFieldState::Null
} else {
AdminJsonFieldState::Present
};
(key.clone(), state)
})
.collect();
Self { field_states }
}
pub(crate) fn state(&self, field: &str) -> AdminJsonFieldState {
self.field_states
.get(field)
.copied()
.unwrap_or(AdminJsonFieldState::Missing)
}
pub(crate) fn contains(&self, field: &str) -> bool {
self.state(field).is_present()
}
pub(crate) fn is_null(&self, field: &str) -> bool {
self.state(field).is_null()
}
}
#[derive(Debug, Clone)]
pub(crate) struct AdminTypedObjectPatch<T> {
fields: AdminJsonObjectPatch,
pub(crate) payload: T,
}
impl<T> AdminTypedObjectPatch<T>
where
T: DeserializeOwned,
{
pub(crate) fn from_object(raw_payload: Map<String, Value>) -> Result<Self, serde_json::Error> {
let fields = AdminJsonObjectPatch::from_object(&raw_payload);
let payload = serde_json::from_value(Value::Object(raw_payload))?;
Ok(Self { fields, payload })
}
pub(crate) fn contains(&self, field: &str) -> bool {
self.fields.contains(field)
}
pub(crate) fn is_null(&self, field: &str) -> bool {
self.fields.is_null(field)
}
pub(crate) fn into_parts(self) -> (AdminJsonObjectPatch, T) {
(self.fields, self.payload)
}
}
#[cfg(test)]
mod tests {
use super::{AdminJsonFieldState, AdminTypedObjectPatch};
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize)]
struct ExamplePatchPayload {
#[serde(default)]
description: Option<String>,
#[serde(default)]
enabled: Option<bool>,
}
#[test]
fn admin_typed_object_patch_tracks_field_presence() {
let patch = AdminTypedObjectPatch::<ExamplePatchPayload>::from_object(
json!({
"description": null,
"enabled": true,
})
.as_object()
.cloned()
.expect("object"),
)
.expect("patch");
assert_eq!(patch.payload.description, None);
assert_eq!(patch.payload.enabled, Some(true));
assert!(patch.contains("description"));
assert!(patch.is_null("description"));
assert!(patch.contains("enabled"));
assert!(!patch.is_null("enabled"));
assert_eq!(
patch.into_parts().0.state("missing_field"),
AdminJsonFieldState::Missing
);
}
}

View File

@@ -97,6 +97,33 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
))); )));
} }
if decision.route_kind.as_deref() == Some("config_import")
&& request_method == http::Method::POST
&& request_path == "/api/admin/system/config/import"
{
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求数据验证失败" })),
)
.into_response(),
));
};
return Ok(Some(
match state.import_admin_system_config(request_body).await? {
Ok(payload) => attach_admin_audit_response(
Json(payload).into_response(),
"admin_system_config_imported",
"import_system_config",
"system_config_import",
"global",
),
Err((status, payload)) => (status, Json(payload)).into_response(),
},
));
}
if decision.route_kind.as_deref() == Some("users_export") if decision.route_kind.as_deref() == Some("users_export")
&& request_method == http::Method::GET && request_method == http::Method::GET
&& request_path == "/api/admin/system/users/export" && request_path == "/api/admin/system/users/export"
@@ -113,8 +140,7 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
if matches!( if matches!(
decision.route_kind.as_deref(), decision.route_kind.as_deref(),
Some( Some(
"config_import" "users_import"
| "users_import"
| "smtp_test" | "smtp_test"
| "cleanup" | "cleanup"
| "purge_config" | "purge_config"

View File

@@ -5,14 +5,17 @@ use super::support::{
}; };
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
use crate::GatewayError; use crate::GatewayError;
use aether_admin::system::{
AdminSystemConfigEndpoint, AdminSystemConfigProvider, AdminSystemConfigProviderKey,
AdminSystemConfigProviderModel,
};
use aether_data_contracts::repository::global_models::AdminProviderModelListQuery; use aether_data_contracts::repository::global_models::AdminProviderModelListQuery;
use serde_json::json;
use std::collections::BTreeMap; use std::collections::BTreeMap;
pub(crate) async fn build_admin_system_export_providers_payload( pub(crate) async fn build_admin_system_export_providers_payload(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
global_model_name_by_id: &BTreeMap<String, String>, global_model_name_by_id: &BTreeMap<String, String>,
) -> Result<Vec<serde_json::Value>, GatewayError> { ) -> Result<Vec<AdminSystemConfigProvider>, GatewayError> {
let providers = state.list_provider_catalog_providers(false).await?; let providers = state.list_provider_catalog_providers(false).await?;
let provider_ids = providers let provider_ids = providers
.iter() .iter()
@@ -56,24 +59,24 @@ pub(crate) async fn build_admin_system_export_providers_payload(
Ok(providers Ok(providers
.iter() .iter()
.map(|provider| { .map(|provider| {
let endpoints = endpoints_by_provider.remove(&provider.id).unwrap_or_default(); let endpoints = endpoints_by_provider
.remove(&provider.id)
.unwrap_or_default();
let provider_endpoint_formats = let provider_endpoint_formats =
collect_admin_system_export_provider_endpoint_formats(&endpoints); collect_admin_system_export_provider_endpoint_formats(&endpoints);
let endpoints_data = endpoints let endpoints_data = endpoints
.iter() .iter()
.map(|endpoint| { .map(|endpoint| AdminSystemConfigEndpoint {
json!({ api_format: endpoint.api_format.clone(),
"api_format": endpoint.api_format, base_url: endpoint.base_url.clone(),
"base_url": endpoint.base_url, header_rules: endpoint.header_rules.clone(),
"header_rules": endpoint.header_rules, body_rules: endpoint.body_rules.clone(),
"body_rules": endpoint.body_rules, max_retries: endpoint.max_retries,
"max_retries": endpoint.max_retries, is_active: endpoint.is_active,
"is_active": endpoint.is_active, custom_path: endpoint.custom_path.clone(),
"custom_path": endpoint.custom_path, config: endpoint.config.clone(),
"config": endpoint.config, format_acceptance_config: endpoint.format_acceptance_config.clone(),
"format_acceptance_config": endpoint.format_acceptance_config, proxy: endpoint.proxy.clone(),
"proxy": endpoint.proxy,
})
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -95,38 +98,76 @@ pub(crate) async fn build_admin_system_export_providers_payload(
key.api_formats.as_ref(), key.api_formats.as_ref(),
&provider_endpoint_formats, &provider_endpoint_formats,
); );
let mut payload = json!({ let auth_config = key
"api_formats": api_formats, .encrypted_auth_config
"supported_endpoints": api_formats, .as_deref()
"auth_type": key.auth_type, .and_then(|ciphertext| {
"name": key.name,
"note": key.note,
"rate_multipliers": key.rate_multipliers,
"internal_priority": key.internal_priority,
"global_priority_by_format": key.global_priority_by_format,
"rpm_limit": key.rpm_limit,
"allowed_models": key.allowed_models,
"capabilities": key.capabilities,
"cache_ttl_minutes": key.cache_ttl_minutes,
"max_probe_interval_minutes": key.max_probe_interval_minutes,
"is_active": key.is_active,
"proxy": key.proxy,
"fingerprint": key.fingerprint,
"auto_fetch_models": key.auto_fetch_models,
"locked_models": key.locked_models,
"model_include_patterns": key.model_include_patterns,
"model_exclude_patterns": key.model_exclude_patterns,
"api_key": decrypt_admin_system_export_secret(state, &key.encrypted_api_key)
.unwrap_or_default(),
});
if let Some(ciphertext) = key.encrypted_auth_config.as_deref() {
if let Some(plaintext) =
decrypt_admin_system_export_secret(state, ciphertext) decrypt_admin_system_export_secret(state, ciphertext)
{ })
payload["auth_config"] = json!(plaintext); .map(serde_json::Value::String);
} AdminSystemConfigProviderKey {
api_key: Some(
decrypt_admin_system_export_secret(state, &key.encrypted_api_key)
.unwrap_or_default(),
),
auth_type: Some(key.auth_type.clone()),
auth_config,
name: Some(key.name.clone()),
note: key.note.clone(),
api_formats: Some(api_formats.clone()),
supported_endpoints: Some(api_formats),
rate_multipliers: key.rate_multipliers.clone(),
internal_priority: Some(key.internal_priority),
global_priority_by_format: key.global_priority_by_format.clone(),
rpm_limit: key.rpm_limit,
allowed_models: key.allowed_models.as_ref().and_then(|value| {
value.as_array().map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
}),
capabilities: key.capabilities.clone(),
cache_ttl_minutes: Some(key.cache_ttl_minutes),
max_probe_interval_minutes: Some(key.max_probe_interval_minutes),
auto_fetch_models: Some(key.auto_fetch_models),
locked_models: key.locked_models.as_ref().and_then(|value| {
value.as_array().map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
}),
model_include_patterns: key.model_include_patterns.as_ref().and_then(
|value| {
value.as_array().map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
},
),
model_exclude_patterns: key.model_exclude_patterns.as_ref().and_then(
|value| {
value.as_array().map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
},
),
is_active: key.is_active,
proxy: key.proxy.clone(),
fingerprint: key.fingerprint.clone(),
} }
payload
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -134,46 +175,47 @@ pub(crate) async fn build_admin_system_export_providers_payload(
.remove(&provider.id) .remove(&provider.id)
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|model| { .map(|model| AdminSystemConfigProviderModel {
json!({ global_model_name: global_model_name_by_id.get(&model.global_model_id).cloned(),
"provider_model_name": model.provider_model_name, provider_model_name: model.provider_model_name,
"provider_model_mappings": model.provider_model_mappings, provider_model_mappings: model.provider_model_mappings,
"price_per_request": model.price_per_request, price_per_request: model.price_per_request,
"tiered_pricing": model.tiered_pricing, tiered_pricing: model.tiered_pricing,
"supports_vision": model.supports_vision, supports_vision: model.supports_vision,
"supports_function_calling": model.supports_function_calling, supports_function_calling: model.supports_function_calling,
"supports_streaming": model.supports_streaming, supports_streaming: model.supports_streaming,
"supports_extended_thinking": model.supports_extended_thinking, supports_extended_thinking: model.supports_extended_thinking,
"supports_image_generation": model.supports_image_generation, supports_image_generation: model.supports_image_generation,
"is_active": model.is_active, is_active: model.is_active,
"config": model.config, config: model.config,
"global_model_name": global_model_name_by_id.get(&model.global_model_id),
})
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
json!({ AdminSystemConfigProvider {
"name": provider.name, name: provider.name.clone(),
"description": provider.description, description: provider.description.clone(),
"website": provider.website, website: provider.website.clone(),
"provider_type": provider.provider_type, provider_type: Some(provider.provider_type.clone()),
"billing_type": provider.billing_type, billing_type: provider.billing_type.clone(),
"monthly_quota_usd": provider.monthly_quota_usd, monthly_quota_usd: provider.monthly_quota_usd,
"quota_reset_day": provider.quota_reset_day, quota_reset_day: provider.quota_reset_day,
"provider_priority": provider.provider_priority, provider_priority: Some(provider.provider_priority),
"keep_priority_on_conversion": provider.keep_priority_on_conversion, keep_priority_on_conversion: Some(provider.keep_priority_on_conversion),
"enable_format_conversion": provider.enable_format_conversion, enable_format_conversion: Some(provider.enable_format_conversion),
"is_active": provider.is_active, is_active: provider.is_active,
"concurrent_limit": provider.concurrent_limit, concurrent_limit: provider.concurrent_limit,
"max_retries": provider.max_retries, max_retries: provider.max_retries,
"proxy": provider.proxy, stream_first_byte_timeout: provider.stream_first_byte_timeout_secs,
"request_timeout": provider.request_timeout_secs, request_timeout: provider.request_timeout_secs,
"stream_first_byte_timeout": provider.stream_first_byte_timeout_secs, proxy: provider.proxy.clone(),
"config": decrypt_admin_system_export_provider_config(state, provider.config.as_ref()), config: decrypt_admin_system_export_provider_config(
"endpoints": endpoints_data, state,
"api_keys": keys_data, provider.config.as_ref(),
"models": models_data, ),
}) endpoints: endpoints_data,
api_keys: keys_data,
models: models_data,
}
}) })
.collect::<Vec<_>>()) .collect::<Vec<_>>())
} }

View File

@@ -2,23 +2,12 @@ use super::super::configs::is_sensitive_admin_system_config_key;
use crate::api::ai::admin_endpoint_signature_parts; use crate::api::ai::admin_endpoint_signature_parts;
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
use crate::handlers::shared::decrypt_catalog_secret_with_fallbacks; use crate::handlers::shared::decrypt_catalog_secret_with_fallbacks;
pub(crate) use aether_admin::system::ADMIN_SYSTEM_CONFIG_EXPORT_VERSION;
use aether_admin::system::ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint; use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
pub(crate) const ADMIN_SYSTEM_CONFIG_EXPORT_VERSION: &str = "2.2";
pub(crate) const ADMIN_SYSTEM_EXPORT_PAGE_LIMIT: usize = 10_000; pub(crate) const ADMIN_SYSTEM_EXPORT_PAGE_LIMIT: usize = 10_000;
const PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS: &[&str] = &[
"api_key",
"password",
"refresh_token",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
];
pub(crate) fn decrypt_admin_system_export_secret( pub(crate) fn decrypt_admin_system_export_secret(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
ciphertext: &str, ciphertext: &str,
@@ -74,7 +63,7 @@ pub(super) fn decrypt_admin_system_export_provider_config(
return Some(decrypted); return Some(decrypted);
}; };
for field in PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS { for field in ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS {
let Some(serde_json::Value::String(ciphertext)) = credentials.get(*field).cloned() else { let Some(serde_json::Value::String(ciphertext)) = credentials.get(*field).cloned() else {
continue; continue;
}; };

View File

@@ -2,8 +2,7 @@ use super::super::{
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response, build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
build_admin_users_read_only_response, normalize_admin_optional_user_email, build_admin_users_read_only_response, normalize_admin_optional_user_email,
normalize_admin_user_api_formats, normalize_admin_user_role, normalize_admin_user_string_list, normalize_admin_user_api_formats, normalize_admin_user_role, normalize_admin_user_string_list,
normalize_admin_username, validate_admin_user_password, AdminUpdateUserFieldPresence, normalize_admin_username, validate_admin_user_password, AdminUpdateUserPatch,
AdminUpdateUserRequest,
}; };
use super::support::{ use super::support::{
admin_user_id_from_detail_path, admin_user_password_policy, build_admin_user_payload, admin_user_id_from_detail_path, admin_user_password_policy, build_admin_user_payload,
@@ -52,14 +51,7 @@ pub(in super::super) async fn build_admin_update_user_response(
.into_response()) .into_response())
} }
}; };
let field_presence = AdminUpdateUserFieldPresence { let patch = match AdminUpdateUserPatch::from_object(raw_payload.clone()) {
allowed_providers: raw_payload.contains_key("allowed_providers"),
allowed_api_formats: raw_payload.contains_key("allowed_api_formats"),
allowed_models: raw_payload.contains_key("allowed_models"),
};
let payload = match serde_json::from_value::<AdminUpdateUserRequest>(serde_json::Value::Object(
raw_payload.clone(),
)) {
Ok(value) => value, Ok(value) => value,
Err(_) => { Err(_) => {
return Ok(( return Ok((
@@ -69,6 +61,7 @@ pub(in super::super) async fn build_admin_update_user_response(
.into_response()) .into_response())
} }
}; };
let (field_presence, payload) = patch.into_parts();
let email = match payload.email.as_deref() { let email = match payload.email.as_deref() {
Some(value) => match normalize_admin_optional_user_email(Some(value)) { Some(value) => match normalize_admin_optional_user_email(Some(value)) {
@@ -142,7 +135,7 @@ pub(in super::super) async fn build_admin_update_user_response(
) )
.into_response()); .into_response());
} }
let allowed_providers = if field_presence.allowed_providers { let allowed_providers = if field_presence.contains("allowed_providers") {
match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") { match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") {
Ok(value) => value, Ok(value) => value,
Err(detail) => { Err(detail) => {
@@ -156,7 +149,7 @@ pub(in super::super) async fn build_admin_update_user_response(
} else { } else {
None None
}; };
let allowed_api_formats = if field_presence.allowed_api_formats { let allowed_api_formats = if field_presence.contains("allowed_api_formats") {
match normalize_admin_user_api_formats(payload.allowed_api_formats) { match normalize_admin_user_api_formats(payload.allowed_api_formats) {
Ok(value) => value, Ok(value) => value,
Err(detail) => { Err(detail) => {
@@ -170,7 +163,7 @@ pub(in super::super) async fn build_admin_update_user_response(
} else { } else {
None None
}; };
let allowed_models = if field_presence.allowed_models { let allowed_models = if field_presence.contains("allowed_models") {
match normalize_admin_user_string_list(payload.allowed_models, "allowed_models") { match normalize_admin_user_string_list(payload.allowed_models, "allowed_models") {
Ok(value) => value, Ok(value) => value,
Err(detail) => { Err(detail) => {
@@ -188,9 +181,9 @@ pub(in super::super) async fn build_admin_update_user_response(
|| username.is_some() || username.is_some()
|| payload.password.is_some() || payload.password.is_some()
|| role.is_some() || role.is_some()
|| field_presence.allowed_providers || field_presence.contains("allowed_providers")
|| field_presence.allowed_api_formats || field_presence.contains("allowed_api_formats")
|| field_presence.allowed_models || field_presence.contains("allowed_models")
|| payload.rate_limit.is_some() || payload.rate_limit.is_some()
|| payload.is_active.is_some(); || payload.is_active.is_some();
if needs_auth_user_write && !state.has_auth_user_write_capability() { if needs_auth_user_write && !state.has_auth_user_write_capability() {
@@ -251,9 +244,9 @@ pub(in super::super) async fn build_admin_update_user_response(
} }
if role.is_some() if role.is_some()
|| field_presence.allowed_providers || field_presence.contains("allowed_providers")
|| field_presence.allowed_api_formats || field_presence.contains("allowed_api_formats")
|| field_presence.allowed_models || field_presence.contains("allowed_models")
|| payload.rate_limit.is_some() || payload.rate_limit.is_some()
|| payload.is_active.is_some() || payload.is_active.is_some()
{ {
@@ -261,11 +254,11 @@ pub(in super::super) async fn build_admin_update_user_response(
.update_local_auth_user_admin_fields( .update_local_auth_user_admin_fields(
&user_id, &user_id,
role, role,
field_presence.allowed_providers, field_presence.contains("allowed_providers"),
allowed_providers, allowed_providers,
field_presence.allowed_api_formats, field_presence.contains("allowed_api_formats"),
allowed_api_formats, allowed_api_formats,
field_presence.allowed_models, field_presence.contains("allowed_models"),
allowed_models, allowed_models,
payload.rate_limit, payload.rate_limit,
payload.is_active, payload.is_active,

View File

@@ -28,13 +28,14 @@ use self::sessions::{
build_admin_delete_user_session_response, build_admin_delete_user_sessions_response, build_admin_delete_user_session_response, build_admin_delete_user_sessions_response,
build_admin_list_user_sessions_response, build_admin_list_user_sessions_response,
}; };
use self::shared::AdminUpdateUserPatch;
use self::shared::{ use self::shared::{
admin_default_user_initial_gift, build_admin_users_bad_request_response, admin_default_user_initial_gift, build_admin_users_bad_request_response,
build_admin_users_data_unavailable_response, build_admin_users_read_only_response, build_admin_users_data_unavailable_response, build_admin_users_read_only_response,
format_optional_datetime_iso8601, normalize_admin_optional_user_email, format_optional_datetime_iso8601, normalize_admin_optional_user_email,
normalize_admin_user_role, normalize_admin_username, validate_admin_user_password, normalize_admin_user_role, normalize_admin_username, validate_admin_user_password,
AdminCreateUserApiKeyRequest, AdminCreateUserRequest, AdminToggleUserApiKeyLockRequest, AdminCreateUserApiKeyRequest, AdminCreateUserRequest, AdminToggleUserApiKeyLockRequest,
AdminUpdateUserApiKeyRequest, AdminUpdateUserFieldPresence, AdminUpdateUserRequest, AdminUpdateUserApiKeyRequest,
}; };
pub(crate) use self::shared::{normalize_admin_user_api_formats, normalize_admin_user_string_list}; pub(crate) use self::shared::{normalize_admin_user_api_formats, normalize_admin_user_string_list};

View File

@@ -1,4 +1,5 @@
use super::ADMIN_USERS_DATA_UNAVAILABLE_DETAIL; use super::ADMIN_USERS_DATA_UNAVAILABLE_DETAIL;
use crate::handlers::admin::shared::AdminTypedObjectPatch;
use axum::{ use axum::{
body::Body, body::Body,
http, http,
@@ -94,12 +95,7 @@ pub(super) struct AdminUpdateUserRequest {
pub(super) is_active: Option<bool>, pub(super) is_active: Option<bool>,
} }
#[derive(Debug, Default)] pub(super) type AdminUpdateUserPatch = AdminTypedObjectPatch<AdminUpdateUserRequest>;
pub(super) struct AdminUpdateUserFieldPresence {
pub(super) allowed_providers: bool,
pub(super) allowed_api_formats: bool,
pub(super) allowed_models: bool,
}
pub(super) fn build_admin_users_data_unavailable_response() -> Response<Body> { pub(super) fn build_admin_users_data_unavailable_response() -> Response<Body> {
( (

View File

@@ -244,6 +244,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
) )
| (Some("provider_oauth_manage"), http::Method::POST, Some("device_authorize")) | (Some("provider_oauth_manage"), http::Method::POST, Some("device_authorize"))
| (Some("provider_oauth_manage"), http::Method::POST, Some("device_poll")) | (Some("provider_oauth_manage"), http::Method::POST, Some("device_poll"))
| (Some("system_manage"), http::Method::POST, Some("config_import"))
| (Some("system_manage"), http::Method::PUT, Some("settings_set")) | (Some("system_manage"), http::Method::PUT, Some("settings_set"))
| (Some("system_manage"), http::Method::PUT, Some("config_set")) | (Some("system_manage"), http::Method::PUT, Some("config_set"))
| (Some("system_manage"), http::Method::PUT, Some("email_template_set")) | (Some("system_manage"), http::Method::PUT, Some("email_template_set"))

View File

@@ -18,6 +18,7 @@ mod proxy_nodes;
mod security; mod security;
mod stats; mod stats;
mod system; mod system;
mod system_import;
mod usage; mod usage;
mod users; mod users;
mod video_tasks; mod video_tasks;

View File

@@ -1136,6 +1136,47 @@ async fn gateway_handles_admin_system_config_detail_locally_with_trusted_admin_p
upstream_handle.abort(); upstream_handle.abort();
} }
#[tokio::test]
async fn gateway_handles_admin_system_format_conversion_default_as_disabled() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/system/configs/enable_format_conversion",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/system/configs/enable_format_conversion"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["key"], "enable_format_conversion");
assert_eq!(payload["value"], json!(false));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test] #[tokio::test]
async fn gateway_handles_admin_system_provider_priority_mode_locally_with_bearer_admin_session() { async fn gateway_handles_admin_system_provider_priority_mode_locally_with_bearer_admin_session() {
let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits = Arc::new(Mutex::new(0usize));

View File

@@ -0,0 +1,552 @@
use std::sync::{Arc, Mutex};
use aether_crypto::{decrypt_python_fernet_ciphertext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data::repository::auth_modules::{
AuthModuleReadRepository, InMemoryAuthModuleReadRepository, StoredOAuthProviderModuleConfig,
};
use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
use aether_data::repository::oauth_providers::{
InMemoryOAuthProviderRepository, OAuthProviderReadRepository, StoredOAuthProviderConfig,
};
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::global_models::{
AdminGlobalModelListQuery, AdminProviderModelListQuery, GlobalModelReadRepository,
StoredPublicGlobalModel,
};
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
use axum::body::Body;
use axum::routing::any;
use axum::{extract::Request, Router};
use http::StatusCode;
use serde_json::{json, Value};
use super::super::{build_router_with_state, start_server, AppState};
use crate::constants::{
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
TRUSTED_ADMIN_USER_ROLE_HEADER,
};
use crate::data::GatewayDataState;
fn build_empty_admin_system_data_state() -> GatewayDataState {
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
Vec::new(),
Vec::new(),
Vec::new(),
));
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(Vec::<
StoredPublicGlobalModel,
>::new()));
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
Vec::<StoredOAuthProviderModuleConfig>::new(),
None,
));
let oauth_provider_repository = Arc::new(InMemoryOAuthProviderRepository::seed(Vec::<
StoredOAuthProviderConfig,
>::new()));
GatewayDataState::with_provider_catalog_repository_for_tests(provider_catalog_repository)
.with_global_model_repository_for_tests(global_model_repository)
.attach_auth_module_repository_for_tests(auth_module_repository)
.attach_oauth_provider_repository_for_tests(oauth_provider_repository)
.with_system_config_values_for_tests(Vec::<(String, Value)>::new())
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
}
fn sample_system_import_payload() -> Value {
json!({
"version": "2.2",
"merge_mode": "overwrite",
"global_models": [{
"name": "gpt-5",
"display_name": "GPT 5",
"default_price_per_request": 0.03,
"default_tiered_pricing": {
"tiers": [{
"up_to": null,
"input_price_per_1m": 4.0,
"output_price_per_1m": 20.0,
}]
},
"supported_capabilities": ["streaming", "vision"],
"config": { "quality": "high" },
"is_active": true
}],
"providers": [{
"name": "import-openai",
"provider_type": "custom",
"website": "https://example.com",
"billing_type": "pay_as_you_go",
"provider_priority": 10,
"keep_priority_on_conversion": false,
"enable_format_conversion": true,
"is_active": true,
"max_retries": 2,
"request_timeout": 30.0,
"stream_first_byte_timeout": 15.0,
"config": {
"provider_ops": {
"connector": {
"credentials": {
"api_key": "ops-secret"
}
}
}
},
"endpoints": [{
"api_format": "openai:chat",
"base_url": "https://api.example.com",
"max_retries": 2,
"is_active": true
}],
"api_keys": [{
"name": "primary",
"api_formats": ["openai:chat"],
"auth_type": "api_key",
"api_key": "sk-import-123",
"internal_priority": 5,
"is_active": true
}],
"models": [{
"global_model_name": "gpt-5",
"provider_model_name": "gpt-5",
"price_per_request": 0.03,
"tiered_pricing": {
"tiers": [{
"up_to": null,
"input_price_per_1m": 4.0,
"output_price_per_1m": 20.0,
}]
},
"supports_vision": true,
"supports_function_calling": true,
"supports_streaming": true,
"supports_extended_thinking": false,
"supports_image_generation": false,
"is_active": true,
"config": {
"kind": "chat"
}
}]
}],
"ldap_config": {
"server_url": "ldaps://ldap.example.com",
"bind_dn": "cn=admin,dc=example,dc=com",
"bind_password": "bind-secret",
"base_dn": "dc=example,dc=com",
"user_search_filter": "(uid={username})",
"username_attr": "uid",
"email_attr": "mail",
"display_name_attr": "displayName",
"is_enabled": false,
"is_exclusive": false,
"use_starttls": true,
"connect_timeout": 10
},
"oauth_providers": [{
"provider_type": "linuxdo",
"display_name": "Linux Do",
"client_id": "linuxdo-client",
"client_secret": "linuxdo-secret",
"authorization_url_override": "https://connect.linux.do/oauth2/authorize",
"token_url_override": "https://connect.linux.do/oauth2/token",
"userinfo_url_override": "https://connect.linux.do/api/user",
"scopes": ["openid", "profile"],
"redirect_uri": "https://backend.example.com/oauth/callback",
"frontend_callback_url": "https://frontend.example.com/auth/callback",
"attribute_mapping": { "email": "email" },
"extra_config": { "team": true },
"is_enabled": true
}],
"system_configs": [
{
"key": "site_name",
"value": "Imported Aether",
"description": "Site name"
},
{
"key": "smtp_password",
"value": "smtp-secret",
"description": "SMTP secret"
}
]
})
}
fn fixture_system_import_payload(name: &str) -> Value {
let raw = match name {
"v20" => include_str!("../../fixtures/admin_system/config_export_v20.json"),
"v21" => include_str!("../../fixtures/admin_system/config_export_v21.json"),
"v22" => include_str!("../../fixtures/admin_system/config_export_v22.json"),
_ => panic!("unknown fixture: {name}"),
};
serde_json::from_str(raw).expect("fixture json should parse")
}
#[tokio::test]
async fn gateway_imports_admin_system_config_locally_and_persists_data() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().fallback(any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
Vec::new(),
Vec::new(),
Vec::new(),
));
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(Vec::<
StoredPublicGlobalModel,
>::new()));
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
Vec::<StoredOAuthProviderModuleConfig>::new(),
None,
));
let oauth_provider_repository = Arc::new(InMemoryOAuthProviderRepository::seed(Vec::<
StoredOAuthProviderConfig,
>::new()));
let data_state = GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
&provider_catalog_repository,
))
.with_global_model_repository_for_tests(Arc::clone(&global_model_repository))
.attach_auth_module_repository_for_tests(Arc::clone(&auth_module_repository))
.attach_oauth_provider_repository_for_tests(Arc::clone(&oauth_provider_repository))
.with_system_config_values_for_tests(Vec::<(String, Value)>::new())
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let response = client
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&sample_system_import_payload())
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: Value = response.json().await.expect("json body should parse");
assert_eq!(payload["message"], "配置导入成功");
assert_eq!(payload["stats"]["global_models"]["created"], json!(1));
assert_eq!(payload["stats"]["providers"]["created"], json!(1));
assert_eq!(payload["stats"]["endpoints"]["created"], json!(1));
assert_eq!(payload["stats"]["keys"]["created"], json!(1));
assert_eq!(payload["stats"]["models"]["created"], json!(1));
assert_eq!(payload["stats"]["ldap"]["created"], json!(1));
assert_eq!(payload["stats"]["oauth"]["created"], json!(1));
assert_eq!(payload["stats"]["system_configs"]["created"], json!(2));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let global_models = global_model_repository
.list_admin_global_models(&AdminGlobalModelListQuery {
offset: 0,
limit: 10_000,
is_active: None,
search: None,
})
.await
.expect("global models should load");
assert_eq!(global_models.items.len(), 1);
assert_eq!(global_models.items[0].name, "gpt-5");
let providers = provider_catalog_repository
.list_providers(false)
.await
.expect("providers should load");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].name, "import-openai");
assert!(providers[0].enable_format_conversion);
let provider_ids = providers
.iter()
.map(|provider| provider.id.clone())
.collect::<Vec<_>>();
let endpoints = provider_catalog_repository
.list_endpoints_by_provider_ids(&provider_ids)
.await
.expect("endpoints should load");
assert_eq!(endpoints.len(), 1);
assert_eq!(endpoints[0].api_format, "openai:chat");
let keys = provider_catalog_repository
.list_keys_by_provider_ids(&provider_ids)
.await
.expect("keys should load");
assert_eq!(keys.len(), 1);
assert_eq!(
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, &keys[0].encrypted_api_key)
.expect("api key should decrypt"),
"sk-import-123"
);
let provider_models = global_model_repository
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: providers[0].id.clone(),
is_active: None,
offset: 0,
limit: 10_000,
})
.await
.expect("provider models should load");
assert_eq!(provider_models.len(), 1);
assert_eq!(provider_models[0].provider_model_name, "gpt-5");
assert_eq!(
provider_models[0].global_model_id,
global_models.items[0].id
);
let ldap_config = auth_module_repository
.get_ldap_config()
.await
.expect("ldap config should load")
.expect("ldap config should exist");
assert_eq!(ldap_config.server_url, "ldaps://ldap.example.com");
assert_eq!(
decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
ldap_config
.bind_password_encrypted
.as_deref()
.expect("bind password should exist"),
)
.expect("ldap password should decrypt"),
"bind-secret"
);
let oauth_provider = oauth_provider_repository
.get_oauth_provider_config("linuxdo")
.await
.expect("oauth config should load")
.expect("oauth config should exist");
assert_eq!(oauth_provider.client_id, "linuxdo-client");
assert_eq!(
decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
oauth_provider
.client_secret_encrypted
.as_deref()
.expect("oauth secret should exist"),
)
.expect("oauth secret should decrypt"),
"linuxdo-secret"
);
let export_response = client
.get(format!("{gateway_url}/api/admin/system/config/export"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("export request should succeed");
assert_eq!(export_response.status(), StatusCode::OK);
let export_payload: Value = export_response
.json()
.await
.expect("export json should parse");
let exported_provider = export_payload["providers"]
.as_array()
.and_then(|items| items.first())
.expect("provider export should exist");
assert_eq!(
exported_provider["config"]["provider_ops"]["connector"]["credentials"]["api_key"],
"ops-secret"
);
let exported_ldap = export_payload["ldap_config"]
.as_object()
.expect("ldap export should exist");
assert_eq!(exported_ldap["bind_password"], "bind-secret");
let exported_oauth = export_payload["oauth_providers"]
.as_array()
.and_then(|items| items.first())
.expect("oauth export should exist");
assert_eq!(exported_oauth["client_secret"], "linuxdo-secret");
let exported_system_configs = export_payload["system_configs"]
.as_array()
.expect("system configs export should exist");
let exported_site_name = exported_system_configs
.iter()
.find(|entry| entry["key"] == "site_name")
.expect("site_name should exist");
let exported_smtp_password = exported_system_configs
.iter()
.find(|entry| entry["key"] == "smtp_password")
.expect("smtp_password should exist");
assert_eq!(exported_site_name["value"], "Imported Aether");
assert_eq!(exported_smtp_password["value"], "smtp-secret");
gateway_handle.abort();
upstream_handle.abort();
let _ = upstream_url;
}
#[tokio::test]
async fn gateway_returns_503_for_admin_system_config_import_when_local_data_is_unavailable() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().fallback(any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({ "version": "2.2" }))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "Admin system data unavailable");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
let _ = upstream_url;
}
#[tokio::test]
async fn gateway_accepts_legacy_admin_system_config_import_versions() {
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(build_empty_admin_system_data_state()),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
for version in ["2.0", "2.1"] {
let response = client
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"version": version,
"merge_mode": "skip",
"global_models": [],
"providers": []
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: Value = response.json().await.expect("json body should parse");
assert_eq!(payload["message"], "配置导入成功");
assert_eq!(payload["stats"]["errors"], json!([]));
}
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_imports_admin_system_config_fixtures_from_legacy_exports() {
for fixture in ["v20", "v21", "v22"] {
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(build_empty_admin_system_data_state()),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&fixture_system_import_payload(fixture))
.send()
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::OK,
"fixture {fixture} should import"
);
let payload: Value = response.json().await.expect("json body should parse");
assert_eq!(payload["message"], "配置导入成功");
gateway_handle.abort();
}
}
#[tokio::test]
async fn gateway_skips_proxy_nodes_during_admin_system_config_import() {
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(build_empty_admin_system_data_state()),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"version": "2.2",
"merge_mode": "overwrite",
"global_models": [],
"providers": [],
"proxy_nodes": [{
"id": "legacy-node-1",
"name": "Legacy Node",
"ip": "127.0.0.1",
"port": 8080
}]
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: Value = response.json().await.expect("json body should parse");
assert_eq!(payload["stats"]["proxy_nodes"]["skipped"], json!(1));
assert!(payload["stats"]["errors"]
.as_array()
.expect("errors should be an array")
.iter()
.any(|item| item
.as_str()
.is_some_and(|value| value.contains("暂不支持导入代理节点"))));
gateway_handle.abort();
}

View File

@@ -0,0 +1,97 @@
{
"version": "2.0",
"exported_at": "2026-04-11T10:00:00Z",
"global_models": [
{
"name": "legacy-gpt-5-v20",
"display_name": "Legacy GPT 5 v20",
"default_price_per_request": 0.03,
"default_tiered_pricing": {
"tiers": [
{
"up_to": null,
"input_price_per_1m": 4.0,
"output_price_per_1m": 20.0
}
]
},
"supported_capabilities": [
"streaming",
"vision"
],
"config": {
"quality": "balanced"
},
"is_active": true
}
],
"providers": [
{
"name": "legacy-provider-v20",
"provider_type": "custom",
"website": "https://legacy-v20.example.com",
"billing_type": "pay_as_you_go",
"provider_priority": 10,
"keep_priority_on_conversion": false,
"enable_format_conversion": true,
"is_active": true,
"max_retries": 2,
"request_timeout": 30.0,
"stream_first_byte_timeout": 15.0,
"config": {
"provider_ops": {
"connector": {
"credentials": {
"api_key": "ops-secret-v20"
}
}
}
},
"endpoints": [
{
"api_format": "openai:chat",
"base_url": "https://legacy-v20.example.com/v1",
"max_retries": 2,
"is_active": true
}
],
"api_keys": [
{
"name": "legacy-key-v20",
"api_formats": [
"openai:chat"
],
"auth_type": "api_key",
"api_key": "sk-legacy-v20",
"internal_priority": 5,
"is_active": true
}
],
"models": [
{
"global_model_name": "legacy-gpt-5-v20",
"provider_model_name": "legacy-gpt-5-v20",
"price_per_request": 0.03,
"tiered_pricing": {
"tiers": [
{
"up_to": null,
"input_price_per_1m": 4.0,
"output_price_per_1m": 20.0
}
]
},
"supports_vision": true,
"supports_function_calling": true,
"supports_streaming": true,
"supports_extended_thinking": false,
"supports_image_generation": false,
"is_active": true,
"config": {
"kind": "chat"
}
}
]
}
]
}

View File

@@ -0,0 +1,122 @@
{
"version": "2.1",
"exported_at": "2026-04-11T10:00:01Z",
"global_models": [
{
"name": "legacy-gpt-5-v21",
"display_name": "Legacy GPT 5 v21",
"default_price_per_request": 0.03,
"default_tiered_pricing": {
"tiers": [
{
"up_to": null,
"input_price_per_1m": 4.0,
"output_price_per_1m": 20.0
}
]
},
"supported_capabilities": [
"streaming"
],
"config": {
"quality": "high"
},
"is_active": true
}
],
"providers": [
{
"name": "legacy-provider-v21",
"provider_type": "custom",
"website": "https://legacy-v21.example.com",
"billing_type": "pay_as_you_go",
"provider_priority": 11,
"keep_priority_on_conversion": false,
"enable_format_conversion": true,
"is_active": true,
"max_retries": 2,
"request_timeout": 30.0,
"stream_first_byte_timeout": 15.0,
"endpoints": [
{
"api_format": "openai:chat",
"base_url": "https://legacy-v21.example.com/v1",
"max_retries": 2,
"is_active": true
}
],
"api_keys": [
{
"name": "legacy-key-v21",
"api_formats": [
"openai:chat"
],
"auth_type": "api_key",
"api_key": "sk-legacy-v21",
"internal_priority": 5,
"is_active": true
}
],
"models": [
{
"global_model_name": "legacy-gpt-5-v21",
"provider_model_name": "legacy-gpt-5-v21",
"price_per_request": 0.03,
"tiered_pricing": {
"tiers": [
{
"up_to": null,
"input_price_per_1m": 4.0,
"output_price_per_1m": 20.0
}
]
},
"supports_vision": false,
"supports_function_calling": true,
"supports_streaming": true,
"supports_extended_thinking": false,
"supports_image_generation": false,
"is_active": true
}
]
}
],
"ldap_config": {
"server_url": "ldaps://legacy-v21.example.com",
"bind_dn": "cn=admin,dc=example,dc=com",
"bind_password": "bind-secret-v21",
"base_dn": "dc=example,dc=com",
"user_search_filter": "(uid={username})",
"username_attr": "uid",
"email_attr": "mail",
"display_name_attr": "displayName",
"is_enabled": false,
"is_exclusive": false,
"use_starttls": true,
"connect_timeout": 10
},
"oauth_providers": [
{
"provider_type": "linuxdo-v21",
"display_name": "Linux Do v21",
"client_id": "linuxdo-client-v21",
"client_secret": "linuxdo-secret-v21",
"authorization_url_override": "https://connect.linux.do/oauth2/authorize",
"token_url_override": "https://connect.linux.do/oauth2/token",
"userinfo_url_override": "https://connect.linux.do/api/user",
"scopes": [
"openid",
"profile"
],
"redirect_uri": "https://backend.example.com/oauth/callback",
"frontend_callback_url": "https://frontend.example.com/auth/callback",
"attribute_mapping": {
"email": "email"
},
"extra_config": {
"team": true
},
"is_enabled": true
}
]
}

View File

@@ -0,0 +1,97 @@
{
"version": "2.2",
"exported_at": "2026-04-11T10:00:02Z",
"global_models": [
{
"name": "legacy-gpt-5-v22",
"display_name": "Legacy GPT 5 v22",
"default_price_per_request": 0.03,
"default_tiered_pricing": {
"tiers": [
{
"up_to": null,
"input_price_per_1m": 4.0,
"output_price_per_1m": 20.0
}
]
},
"supported_capabilities": [
"streaming",
"vision"
],
"config": {
"quality": "high"
},
"is_active": true
}
],
"providers": [
{
"name": "legacy-provider-v22",
"provider_type": "custom",
"website": "https://legacy-v22.example.com",
"billing_type": "pay_as_you_go",
"provider_priority": 12,
"keep_priority_on_conversion": false,
"enable_format_conversion": true,
"is_active": true,
"max_retries": 2,
"request_timeout": 30.0,
"stream_first_byte_timeout": 15.0,
"endpoints": [
{
"api_format": "openai:chat",
"base_url": "https://legacy-v22.example.com/v1",
"max_retries": 2,
"is_active": true
}
],
"api_keys": [
{
"name": "legacy-key-v22",
"api_formats": [
"openai:chat"
],
"auth_type": "api_key",
"api_key": "sk-legacy-v22",
"internal_priority": 5,
"is_active": true
}
],
"models": [
{
"global_model_name": "legacy-gpt-5-v22",
"provider_model_name": "legacy-gpt-5-v22",
"price_per_request": 0.03,
"tiered_pricing": {
"tiers": [
{
"up_to": null,
"input_price_per_1m": 4.0,
"output_price_per_1m": 20.0
}
]
},
"supports_vision": true,
"supports_function_calling": true,
"supports_streaming": true,
"supports_extended_thinking": false,
"supports_image_generation": false,
"is_active": true
}
]
}
],
"system_configs": [
{
"key": "site_name",
"value": "Legacy Fixture v22",
"description": "Site name from fixture"
},
{
"key": "smtp_password",
"value": "smtp-secret-v22",
"description": "SMTP password from fixture"
}
]
}

View File

@@ -2,7 +2,7 @@ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
}; };
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use serde_json::{json, Map, Value}; use serde_json::{json, Value};
use std::collections::BTreeMap; use std::collections::BTreeMap;
fn unix_secs_to_rfc3339(unix_secs: u64) -> Option<String> { fn unix_secs_to_rfc3339(unix_secs: u64) -> Option<String> {
@@ -173,16 +173,21 @@ pub fn build_admin_provider_endpoint_record(
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())
} }
pub fn apply_admin_provider_endpoint_update_fields( pub fn apply_admin_provider_endpoint_update_fields<FC, FN>(
existing_endpoint: &StoredProviderCatalogEndpoint, existing_endpoint: &StoredProviderCatalogEndpoint,
raw_payload: &Map<String, Value>, contains_field: FC,
is_null_field: FN,
payload: &AdminProviderEndpointUpdateFields, payload: &AdminProviderEndpointUpdateFields,
) -> Result<StoredProviderCatalogEndpoint, String> { ) -> Result<StoredProviderCatalogEndpoint, String>
where
FC: Fn(&str) -> bool,
FN: Fn(&str) -> bool,
{
let mut updated = existing_endpoint.clone(); let mut updated = existing_endpoint.clone();
if let Some(value) = raw_payload.get("base_url") { if contains_field("base_url") {
let Some(base_url) = payload.base_url.as_deref() else { let Some(base_url) = payload.base_url.as_deref() else {
return Err(if value.is_null() { return Err(if is_null_field("base_url") {
"base_url 不能为空".to_string() "base_url 不能为空".to_string()
} else { } else {
"base_url 必须是字符串".to_string() "base_url 必须是字符串".to_string()
@@ -191,35 +196,41 @@ pub fn apply_admin_provider_endpoint_update_fields(
updated.base_url = base_url.to_string(); updated.base_url = base_url.to_string();
} }
if raw_payload.contains_key("custom_path") { if contains_field("custom_path") {
updated.custom_path = payload.custom_path.clone(); updated.custom_path = payload.custom_path.clone();
} }
if let Some(value) = raw_payload.get("header_rules") { if contains_field("header_rules") {
if !value.is_null() && !value.is_array() { updated.header_rules = if is_null_field("header_rules") {
return Err("header_rules 必须是数组或 null".to_string());
}
updated.header_rules = if value.is_null() {
None None
} else { } else {
payload.header_rules.clone() let Some(header_rules) = payload.header_rules.as_ref() else {
return Err("header_rules 必须是数组或 null".to_string());
};
if !header_rules.is_array() {
return Err("header_rules 必须是数组或 null".to_string());
}
Some(header_rules.clone())
}; };
} }
if let Some(value) = raw_payload.get("body_rules") { if contains_field("body_rules") {
if !value.is_null() && !value.is_array() { updated.body_rules = if is_null_field("body_rules") {
return Err("body_rules 必须是数组或 null".to_string());
}
updated.body_rules = if value.is_null() {
None None
} else { } else {
payload.body_rules.clone() let Some(body_rules) = payload.body_rules.as_ref() else {
return Err("body_rules 必须是数组或 null".to_string());
};
if !body_rules.is_array() {
return Err("body_rules 必须是数组或 null".to_string());
}
Some(body_rules.clone())
}; };
} }
if let Some(value) = raw_payload.get("max_retries") { if contains_field("max_retries") {
let Some(max_retries) = payload.max_retries else { let Some(max_retries) = payload.max_retries else {
return Err(if value.is_null() { return Err(if is_null_field("max_retries") {
"max_retries 必须是 0 到 999 之间的整数".to_string() "max_retries 必须是 0 到 999 之间的整数".to_string()
} else { } else {
"max_retries 必须是整数".to_string() "max_retries 必须是整数".to_string()
@@ -231,26 +242,29 @@ pub fn apply_admin_provider_endpoint_update_fields(
updated.max_retries = Some(max_retries); updated.max_retries = Some(max_retries);
} }
if raw_payload.contains_key("is_active") { if contains_field("is_active") {
let Some(is_active) = payload.is_active else { let Some(is_active) = payload.is_active else {
return Err("is_active 必须是布尔值".to_string()); return Err("is_active 必须是布尔值".to_string());
}; };
updated.is_active = is_active; updated.is_active = is_active;
} }
if let Some(value) = raw_payload.get("config") { if contains_field("config") {
if !value.is_null() && !value.is_object() { updated.config = if is_null_field("config") {
return Err("config 必须是对象或 null".to_string());
}
updated.config = if value.is_null() {
None None
} else { } else {
payload.config.clone() let Some(config) = payload.config.as_ref() else {
return Err("config 必须是对象或 null".to_string());
};
if !config.is_object() {
return Err("config 必须是对象或 null".to_string());
}
Some(config.clone())
}; };
} }
if let Some(value) = raw_payload.get("proxy") { if contains_field("proxy") {
if value.is_null() { if is_null_field("proxy") {
updated.proxy = None; updated.proxy = None;
} else { } else {
let Some(mut proxy) = payload let Some(mut proxy) = payload
@@ -276,14 +290,17 @@ pub fn apply_admin_provider_endpoint_update_fields(
} }
} }
if let Some(value) = raw_payload.get("format_acceptance_config") { if contains_field("format_acceptance_config") {
if !value.is_null() && !value.is_object() { updated.format_acceptance_config = if is_null_field("format_acceptance_config") {
return Err("format_acceptance_config 必须是对象或 null".to_string());
}
updated.format_acceptance_config = if value.is_null() {
None None
} else { } else {
payload.format_acceptance_config.clone() let Some(config) = payload.format_acceptance_config.as_ref() else {
return Err("format_acceptance_config 必须是对象或 null".to_string());
};
if !config.is_object() {
return Err("format_acceptance_config 必须是对象或 null".to_string());
}
Some(config.clone())
}; };
} }

View File

@@ -13,7 +13,8 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
}; };
use serde_json::json; use serde::{de, de::DeserializeOwned, Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::BTreeSet; use std::collections::BTreeSet;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -37,6 +38,453 @@ pub struct AdminEmailTemplateUpdate {
pub html: Option<String>, pub html: Option<String>,
} }
pub const ADMIN_SYSTEM_CONFIG_EXPORT_VERSION: &str = "2.2";
pub const ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS: &[&str] = &["2.0", "2.1", "2.2"];
pub const ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS: &[&str] = &[
"api_key",
"password",
"refresh_token",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
];
fn default_true() -> bool {
true
}
fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, serde_json::Value) {
(
http::StatusCode::BAD_REQUEST,
json!({ "detail": detail.into() }),
)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AdminImportMergeMode {
#[default]
Skip,
Overwrite,
Error,
}
impl AdminImportMergeMode {
fn parse_json_value(
value: Option<&serde_json::Value>,
) -> Result<Self, (http::StatusCode, serde_json::Value)> {
match value
.and_then(serde_json::Value::as_str)
.unwrap_or("skip")
.trim()
{
"" | "skip" => Ok(Self::Skip),
"overwrite" => Ok(Self::Overwrite),
"error" => Ok(Self::Error),
_ => Err(invalid_request(
"merge_mode 仅支持 skip / overwrite / error",
)),
}
}
}
impl<'de> Deserialize<'de> for AdminImportMergeMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
match value {
None | Some(serde_json::Value::Null) => Ok(Self::Skip),
Some(serde_json::Value::String(raw)) => match raw.trim() {
"" | "skip" => Ok(Self::Skip),
"overwrite" => Ok(Self::Overwrite),
"error" => Ok(Self::Error),
_ => Err(de::Error::custom(
"merge_mode 仅支持 skip / overwrite / error",
)),
},
Some(_) => Err(de::Error::custom(
"merge_mode 仅支持 skip / overwrite / error",
)),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct AdminSystemConfigImportCounter {
pub created: u64,
pub updated: u64,
pub skipped: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct AdminSystemConfigImportStats {
pub global_models: AdminSystemConfigImportCounter,
pub proxy_nodes: AdminSystemConfigImportCounter,
pub providers: AdminSystemConfigImportCounter,
pub endpoints: AdminSystemConfigImportCounter,
pub keys: AdminSystemConfigImportCounter,
pub models: AdminSystemConfigImportCounter,
pub ldap: AdminSystemConfigImportCounter,
pub oauth: AdminSystemConfigImportCounter,
pub system_configs: AdminSystemConfigImportCounter,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigGlobalModel {
pub name: String,
pub display_name: String,
#[serde(default)]
pub default_price_per_request: Option<f64>,
#[serde(default)]
pub default_tiered_pricing: Option<Value>,
#[serde(default)]
pub supported_capabilities: Option<Vec<String>>,
#[serde(default)]
pub config: Option<Value>,
#[serde(default = "default_true")]
pub is_active: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigEndpoint {
pub api_format: String,
pub base_url: String,
#[serde(default)]
pub header_rules: Option<Value>,
#[serde(default)]
pub body_rules: Option<Value>,
#[serde(default)]
pub max_retries: Option<i32>,
#[serde(default = "default_true")]
pub is_active: bool,
#[serde(default)]
pub custom_path: Option<String>,
#[serde(default)]
pub config: Option<Value>,
#[serde(default)]
pub format_acceptance_config: Option<Value>,
#[serde(default)]
pub proxy: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigProviderKey {
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub auth_type: Option<String>,
#[serde(default)]
pub auth_config: Option<Value>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub api_formats: Option<Vec<String>>,
#[serde(default)]
pub supported_endpoints: Option<Vec<String>>,
#[serde(default)]
pub rate_multipliers: Option<Value>,
#[serde(default)]
pub internal_priority: Option<i32>,
#[serde(default)]
pub global_priority_by_format: Option<Value>,
#[serde(default)]
pub rpm_limit: Option<u32>,
#[serde(default)]
pub allowed_models: Option<Vec<String>>,
#[serde(default)]
pub capabilities: Option<Value>,
#[serde(default)]
pub cache_ttl_minutes: Option<i32>,
#[serde(default)]
pub max_probe_interval_minutes: Option<i32>,
#[serde(default)]
pub auto_fetch_models: Option<bool>,
#[serde(default)]
pub locked_models: Option<Vec<String>>,
#[serde(default)]
pub model_include_patterns: Option<Vec<String>>,
#[serde(default)]
pub model_exclude_patterns: Option<Vec<String>>,
#[serde(default = "default_true")]
pub is_active: bool,
#[serde(default)]
pub proxy: Option<Value>,
#[serde(default)]
pub fingerprint: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigProviderModel {
#[serde(default)]
pub global_model_name: Option<String>,
pub provider_model_name: String,
#[serde(default)]
pub provider_model_mappings: Option<Value>,
#[serde(default)]
pub price_per_request: Option<f64>,
#[serde(default)]
pub tiered_pricing: Option<Value>,
#[serde(default)]
pub supports_vision: Option<bool>,
#[serde(default)]
pub supports_function_calling: Option<bool>,
#[serde(default)]
pub supports_streaming: Option<bool>,
#[serde(default)]
pub supports_extended_thinking: Option<bool>,
#[serde(default)]
pub supports_image_generation: Option<bool>,
#[serde(default = "default_true")]
pub is_active: bool,
#[serde(default)]
pub config: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigProvider {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub website: Option<String>,
#[serde(default)]
pub provider_type: Option<String>,
#[serde(default)]
pub billing_type: Option<String>,
#[serde(default)]
pub monthly_quota_usd: Option<f64>,
#[serde(default)]
pub quota_reset_day: Option<u64>,
#[serde(default)]
pub provider_priority: Option<i32>,
#[serde(default)]
pub keep_priority_on_conversion: Option<bool>,
#[serde(default)]
pub enable_format_conversion: Option<bool>,
#[serde(default = "default_true")]
pub is_active: bool,
#[serde(default)]
pub concurrent_limit: Option<i32>,
#[serde(default)]
pub max_retries: Option<i32>,
#[serde(default)]
pub stream_first_byte_timeout: Option<f64>,
#[serde(default)]
pub request_timeout: Option<f64>,
#[serde(default)]
pub proxy: Option<Value>,
#[serde(default)]
pub config: Option<Value>,
#[serde(default)]
pub endpoints: Vec<AdminSystemConfigEndpoint>,
#[serde(default)]
pub api_keys: Vec<AdminSystemConfigProviderKey>,
#[serde(default)]
pub models: Vec<AdminSystemConfigProviderModel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigProxyNode {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub ip: Option<String>,
#[serde(default)]
pub port: Option<i32>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub is_manual: Option<bool>,
#[serde(default)]
pub proxy_url: Option<String>,
#[serde(default)]
pub proxy_username: Option<String>,
#[serde(default)]
pub proxy_password: Option<String>,
#[serde(default)]
pub tunnel_mode: Option<bool>,
#[serde(default)]
pub heartbeat_interval: Option<i32>,
#[serde(default)]
pub remote_config: Option<Value>,
#[serde(default)]
pub config_version: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigLdap {
pub server_url: String,
pub bind_dn: String,
#[serde(default)]
pub bind_password: Option<String>,
pub base_dn: String,
#[serde(default)]
pub user_search_filter: Option<String>,
#[serde(default)]
pub username_attr: Option<String>,
#[serde(default)]
pub email_attr: Option<String>,
#[serde(default)]
pub display_name_attr: Option<String>,
#[serde(default)]
pub is_enabled: bool,
#[serde(default)]
pub is_exclusive: bool,
#[serde(default)]
pub use_starttls: bool,
#[serde(default)]
pub connect_timeout: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigOAuthProvider {
pub provider_type: String,
pub display_name: String,
pub client_id: String,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default)]
pub authorization_url_override: Option<String>,
#[serde(default)]
pub token_url_override: Option<String>,
#[serde(default)]
pub userinfo_url_override: Option<String>,
#[serde(default)]
pub scopes: Option<Vec<String>>,
pub redirect_uri: String,
pub frontend_callback_url: String,
#[serde(default)]
pub attribute_mapping: Option<Value>,
#[serde(default)]
pub extra_config: Option<Value>,
#[serde(default)]
pub is_enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigEntry {
pub key: String,
#[serde(default)]
pub value: Value,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigDocument {
pub version: String,
#[serde(default)]
pub exported_at: String,
#[serde(default)]
pub global_models: Vec<AdminSystemConfigGlobalModel>,
#[serde(default)]
pub providers: Vec<AdminSystemConfigProvider>,
#[serde(default)]
pub proxy_nodes: Vec<AdminSystemConfigProxyNode>,
#[serde(default)]
pub ldap_config: Option<AdminSystemConfigLdap>,
#[serde(default)]
pub oauth_providers: Vec<AdminSystemConfigOAuthProvider>,
#[serde(default)]
pub system_configs: Vec<AdminSystemConfigEntry>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSystemConfigImportRequest {
#[serde(flatten)]
pub document: AdminSystemConfigDocument,
#[serde(default)]
pub merge_mode: AdminImportMergeMode,
}
#[derive(Debug, Clone)]
pub struct ParsedAdminSystemConfigImportRequest {
pub request: AdminSystemConfigImportRequest,
pub root: Map<String, Value>,
}
#[derive(Debug, Clone)]
pub struct ParsedAdminSystemConfigObject<T> {
pub raw: Map<String, Value>,
pub value: T,
}
impl<T> ParsedAdminSystemConfigObject<T> {
pub fn into_parts(self) -> (Map<String, Value>, T) {
(self.raw, self.value)
}
}
fn parse_admin_system_config_object<T: DeserializeOwned>(
item: Value,
field_name: &str,
) -> Result<ParsedAdminSystemConfigObject<T>, (http::StatusCode, Value)> {
let raw = item
.as_object()
.cloned()
.ok_or_else(|| invalid_request(format!("{field_name} 项必须是对象")))?;
let value = serde_json::from_value::<T>(Value::Object(raw.clone()))
.map_err(|_| invalid_request(format!("{field_name} 项格式无效")))?;
Ok(ParsedAdminSystemConfigObject { raw, value })
}
pub fn parse_admin_system_config_array<T: DeserializeOwned>(
root: &Map<String, Value>,
field_name: &str,
) -> Result<Vec<ParsedAdminSystemConfigObject<T>>, (http::StatusCode, Value)> {
let Some(value) = root.get(field_name) else {
return Ok(Vec::new());
};
let items = value
.as_array()
.ok_or_else(|| invalid_request(format!("{field_name} 必须是数组")))?;
items
.iter()
.cloned()
.map(|item| parse_admin_system_config_object(item, field_name))
.collect()
}
pub fn parse_admin_system_config_optional_object<T: DeserializeOwned>(
root: &Map<String, Value>,
field_name: &str,
) -> Result<Option<ParsedAdminSystemConfigObject<T>>, (http::StatusCode, Value)> {
let Some(value) = root.get(field_name) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
parse_admin_system_config_object(value.clone(), field_name).map(Some)
}
pub fn parse_admin_system_config_nested_array<T: DeserializeOwned>(
parent: &Map<String, Value>,
field_name: &str,
) -> Result<Vec<ParsedAdminSystemConfigObject<T>>, (http::StatusCode, Value)> {
let Some(value) = parent.get(field_name) else {
return Ok(Vec::new());
};
let items = value
.as_array()
.ok_or_else(|| invalid_request(format!("{field_name} 必须是数组")))?;
items
.iter()
.cloned()
.map(|item| parse_admin_system_config_object(item, field_name))
.collect()
}
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct AdminApiFormatDefinition { struct AdminApiFormatDefinition {
value: &'static str, value: &'static str,
@@ -659,6 +1107,42 @@ pub fn serialize_admin_system_users_export_wallet(
})) }))
} }
pub fn parse_admin_system_config_import_request(
request_body: &[u8],
) -> Result<ParsedAdminSystemConfigImportRequest, (http::StatusCode, serde_json::Value)> {
let root = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(serde_json::Value::Object(root)) => root,
_ => return Err(invalid_request("请求数据验证失败")),
};
let version = root
.get("version")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| invalid_request("version 为必填字段"))?;
if !ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS.contains(&version) {
return Err(invalid_request(format!(
"不支持的配置版本: {version},支持的版本: {}",
ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS.join(", ")
)));
}
let merge_mode = AdminImportMergeMode::parse_json_value(root.get("merge_mode"))?;
let document = serde_json::from_value::<AdminSystemConfigDocument>(serde_json::Value::Object(
root.clone(),
))
.map_err(|_| invalid_request("请求数据验证失败"))?;
Ok(ParsedAdminSystemConfigImportRequest {
request: AdminSystemConfigImportRequest {
document,
merge_mode,
},
root,
})
}
pub fn normalize_admin_system_config_key(requested_key: &str) -> String { pub fn normalize_admin_system_config_key(requested_key: &str) -> String {
let trimmed = requested_key.trim(); let trimmed = requested_key.trim();
if trimmed.eq_ignore_ascii_case(LEGACY_REQUEST_LOG_LEVEL_KEY) { if trimmed.eq_ignore_ascii_case(LEGACY_REQUEST_LOG_LEVEL_KEY) {
@@ -717,7 +1201,7 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
"auto_delete_expired_keys" => Some(json!(false)), "auto_delete_expired_keys" => Some(json!(false)),
"email_suffix_mode" => Some(json!("none")), "email_suffix_mode" => Some(json!("none")),
"email_suffix_list" => Some(json!([])), "email_suffix_list" => Some(json!([])),
"enable_format_conversion" => Some(json!(true)), "enable_format_conversion" => Some(json!(false)),
"keep_priority_on_conversion" => Some(json!(false)), "keep_priority_on_conversion" => Some(json!(false)),
"audit_log_retention_days" => Some(json!(30)), "audit_log_retention_days" => Some(json!(30)),
"enable_db_maintenance" => Some(json!(true)), "enable_db_maintenance" => Some(json!(true)),
@@ -1344,3 +1828,67 @@ fn mask_admin_proxy_node_password(password: Option<&str>) -> Option<String> {
&password[password.len() - 2..] &password[password.len() - 2..]
)) ))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_admin_system_config_import_request_accepts_supported_versions() {
for version in ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS {
let parsed = parse_admin_system_config_import_request(
json!({
"version": version,
"global_models": [],
"providers": [],
})
.to_string()
.as_bytes(),
)
.expect("supported version should parse");
assert_eq!(parsed.request.document.version, *version);
assert_eq!(parsed.request.merge_mode, AdminImportMergeMode::Skip);
assert!(parsed.request.document.oauth_providers.is_empty());
assert!(parsed.request.document.system_configs.is_empty());
assert!(parsed.request.document.ldap_config.is_none());
}
}
#[test]
fn parse_admin_system_config_import_request_rejects_invalid_merge_mode() {
let err = parse_admin_system_config_import_request(
json!({
"version": "2.2",
"merge_mode": "replace_all",
})
.to_string()
.as_bytes(),
)
.expect_err("invalid merge mode should fail");
assert_eq!(err.0, http::StatusCode::BAD_REQUEST);
assert_eq!(
err.1["detail"],
"merge_mode 仅支持 skip / overwrite / error"
);
}
#[test]
fn resolve_admin_system_export_key_api_formats_uses_endpoint_fallback() {
let provider_formats = vec!["openai:chat".to_string(), "claude:chat".to_string()];
let resolved =
resolve_admin_system_export_key_api_formats(None, &provider_formats, |value| {
Some(value.to_string())
});
assert_eq!(resolved, provider_formats);
}
#[test]
fn sensitive_admin_system_config_keys_are_case_insensitive() {
assert!(is_sensitive_admin_system_config_key("smtp_password"));
assert!(is_sensitive_admin_system_config_key("SMTP_PASSWORD"));
assert!(!is_sensitive_admin_system_config_key("site_name"));
}
}

View File

@@ -334,6 +334,7 @@ export interface ConfigImportResponse {
models: { created: number; updated: number; skipped: number } models: { created: number; updated: number; skipped: number }
ldap?: { created: number; updated: number; skipped: number } ldap?: { created: number; updated: number; skipped: number }
oauth?: { created: number; updated: number; skipped: number } oauth?: { created: number; updated: number; skipped: number }
system_configs?: { created: number; updated: number; skipped: number }
errors: string[] errors: string[]
} }
} }