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,
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,
AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyFieldPresence,
AdminStandaloneApiKeyToggleRequest, AdminStandaloneApiKeyUpdateRequest,
AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyToggleRequest,
};
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,
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,
AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyFieldPresence,
AdminStandaloneApiKeyToggleRequest, AdminStandaloneApiKeyUpdateRequest,
AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyToggleRequest,
AdminStandaloneApiKeyUpdatePatch,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
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 {
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),
) {
let patch = match AdminStandaloneApiKeyUpdatePatch::from_object(raw_payload) {
Ok(value) => value,
Err(_) => {
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()
|| payload.unlimited_balance.is_some()
|| payload.expire_days.is_some()
@@ -197,7 +191,7 @@ pub(super) async fn build_admin_update_api_key_response(
"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") {
Ok(value) => Some(value),
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 {
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) {
Ok(value) => Some(value),
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 {
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") {
Ok(value) => Some(value),
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::shared::query_param_value;
use crate::handlers::admin::shared::{query_param_value, AdminTypedObjectPatch};
use crate::handlers::admin::users::{
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) type AdminStandaloneApiKeyUpdatePatch =
AdminTypedObjectPatch<AdminStandaloneApiKeyUpdateRequest>;
#[derive(Debug, Default, serde::Deserialize)]
pub(super) struct AdminStandaloneApiKeyToggleRequest {
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> {
(
http::StatusCode::SERVICE_UNAVAILABLE,

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
use super::payloads::{normalize_optional_price, normalize_required_trimmed_string};
use crate::handlers::admin::model::shared::{
AdminGlobalModelCreateRequest, AdminGlobalModelUpdateRequest,
AdminGlobalModelCreateRequest, AdminGlobalModelUpdatePatch,
};
use crate::handlers::admin::request::AdminAppState;
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(
_state: &AdminAppState<'_>,
existing: &StoredAdminGlobalModel,
raw_payload: &serde_json::Map<String, serde_json::Value>,
payload: AdminGlobalModelUpdateRequest,
patch: AdminGlobalModelUpdatePatch,
) -> 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 {
return Err(if value.is_null() {
return Err(if fields.is_null("display_name") {
"display_name 不能为空".to_string()
} else {
"display_name 必须是字符串".to_string()
@@ -65,7 +65,7 @@ pub(crate) async fn build_admin_global_model_update_record(
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(
payload.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
};
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")?
} else {
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))
} else {
existing.supported_capabilities.clone()
};
let config = if raw_payload.contains_key("config") {
let config = if fields.contains("config") {
normalize_json_object(payload.config, "config")?
} else {
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,
};
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::request::{AdminAppState, AdminRequestContext};
@@ -137,8 +137,8 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
"请求体必须是合法的 JSON 对象",
)));
};
let payload = match serde_json::from_value::<AdminProviderUpdateRequest>(raw_value) {
Ok(payload) => payload,
let patch = match AdminProviderUpdatePatch::from_object(raw_payload) {
Ok(patch) => patch,
Err(_) => {
return Ok(Some(build_admin_provider_bad_request_response(
"请求体必须是合法的 JSON 对象",
@@ -156,7 +156,7 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
))));
};
let updated_record = match state
.build_admin_update_provider_record(&existing_provider, &raw_payload, payload)
.build_admin_update_provider_record(&existing_provider, patch)
.await
{
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::payloads::AdminProviderKeyUpdateRequest;
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::GatewayError;
use axum::{
@@ -46,8 +46,8 @@ pub(super) async fn maybe_handle(
let Some(raw_payload) = raw_value.as_object().cloned() else {
return Ok(Some(bad_request_response("请求体必须是合法的 JSON 对象")));
};
let payload = match serde_json::from_value::<AdminProviderKeyUpdateRequest>(raw_value) {
Ok(payload) => payload,
let patch = match AdminProviderKeyUpdatePatch::from_object(raw_payload) {
Ok(patch) => patch,
Err(_) => return Ok(Some(bad_request_response("请求体必须是合法的 JSON 对象"))),
};
@@ -72,7 +72,7 @@ pub(super) async fn maybe_handle(
};
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
{
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_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
@@ -82,3 +83,6 @@ pub(crate) struct AdminProviderEndpointUpdateRequest {
#[serde(default)]
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::payloads::{
build_admin_provider_endpoint_response, endpoint_key_counts_by_format,
AdminProviderEndpointUpdateRequest,
AdminProviderEndpointUpdatePatch,
};
use super::support::build_admin_endpoints_data_unavailable_response;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -75,8 +75,8 @@ pub(super) async fn maybe_handle(
.into_response(),
));
};
let payload = match serde_json::from_value::<AdminProviderEndpointUpdateRequest>(raw_value) {
Ok(payload) => payload,
let patch = match AdminProviderEndpointUpdatePatch::from_object(raw_payload) {
Ok(patch) => patch,
Err(_) => {
return Ok(Some(
(
@@ -118,12 +118,7 @@ pub(super) async fn maybe_handle(
));
};
let updated_record = match state
.build_admin_update_provider_endpoint_record(
&provider,
&existing_endpoint,
&raw_payload,
payload,
)
.build_admin_update_provider_endpoint_record(&provider, &existing_endpoint, patch)
.await
{
Ok(record) => record,

View File

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

View File

@@ -1,3 +1,4 @@
use crate::handlers::admin::shared::AdminTypedObjectPatch;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
@@ -83,6 +84,8 @@ pub(crate) struct AdminProviderKeyUpdateRequest {
pub(crate) fingerprint: Option<serde_json::Value>,
}
pub(crate) type AdminProviderKeyUpdatePatch = AdminTypedObjectPatch<AdminProviderKeyUpdateRequest>;
#[derive(Debug, Deserialize)]
pub(crate) struct AdminProviderKeyBatchDeleteRequest {
pub(crate) ids: Vec<String>,
@@ -187,6 +190,8 @@ pub(crate) struct AdminProviderUpdateRequest {
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 KIRO_USAGE_LIMITS_PATH: &str = "/getUsageLimits";
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) type AdminProviderModelUpdatePatch =
AdminTypedObjectPatch<AdminProviderModelUpdateRequest>;
#[derive(Debug, Deserialize)]
pub(crate) struct AdminBatchAssignGlobalModelsRequest {
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::{
normalize_auth_type, validate_vertex_api_formats,
};
@@ -17,11 +17,11 @@ pub(crate) async fn build_admin_update_provider_key_record(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
existing: &StoredProviderCatalogKey,
raw_payload: &serde_json::Map<String, serde_json::Value>,
payload: AdminProviderKeyUpdateRequest,
patch: AdminProviderKeyUpdatePatch,
) -> Result<StoredProviderCatalogKey, String> {
let state = state.as_ref();
let mut updated = existing.clone();
let (fields, payload) = patch.into_parts();
let current_auth_type = normalize_auth_type(Some(&existing.auth_type))?;
let target_auth_type = payload
.auth_type
@@ -34,7 +34,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
.as_deref()
.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
.api_key
.as_deref()
@@ -44,7 +44,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
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_object = auth_config
.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)
.ok_or_else(|| "api_formats 为必填字段".to_string())?;
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();
}
if raw_payload.contains_key("rate_multipliers") {
if fields.contains("rate_multipliers") {
updated.rate_multipliers =
normalize_json_object(payload.rate_multipliers, "rate_multipliers")?;
}
if let Some(internal_priority) = payload.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(
payload.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;
if payload.rpm_limit.is_none() {
updated.learned_rpm_limit = None;
}
}
if raw_payload.contains_key("allowed_models") {
if fields.contains("allowed_models") {
updated.allowed_models =
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")?;
}
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 {
updated.is_active = is_active;
}
if raw_payload.contains_key("note") {
if fields.contains("note") {
updated.note = payload
.note
.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 {
updated.auto_fetch_models = auto_fetch_models;
}
if raw_payload.contains_key("locked_models") {
if fields.contains("locked_models") {
updated.locked_models =
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 =
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 =
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")?;
}
if raw_payload.contains_key("fingerprint") {
if fields.contains("fingerprint") {
updated.fingerprint = normalize_json_object(payload.fingerprint, "fingerprint")?;
}
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::{
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(
state: &AdminAppState<'_>,
existing: &StoredProviderCatalogProvider,
raw_payload: &serde_json::Map<String, serde_json::Value>,
payload: AdminProviderUpdateRequest,
patch: AdminProviderUpdatePatch,
) -> Result<StoredProviderCatalogProvider, String> {
let state = state.as_ref();
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 {
return Err(if value.is_null() {
return Err(if fields.is_null("name") {
"name 不能为空".to_string()
} else {
"name 必须是字符串".to_string()
@@ -41,9 +41,9 @@ pub(crate) async fn build_admin_update_provider_record(
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 {
return Err(if value.is_null() {
return Err(if fields.is_null("provider_type") {
"provider_type 不能为空".to_string()
} else {
"provider_type 必须是字符串".to_string()
@@ -56,17 +56,17 @@ pub(crate) async fn build_admin_update_provider_record(
updated.provider_type.clone()
};
if raw_payload.contains_key("description") {
if fields.contains("description") {
updated.description = payload
.description
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
}
if let Some(value) = raw_payload.get("website") {
if fields.contains("website") {
updated.website = match payload.website {
None => {
if value.is_null() {
if fields.is_null("website") {
None
} else {
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 {
return Err(if value.is_null() {
return Err(if fields.is_null("billing_type") {
"billing_type 不能为空".to_string()
} else {
"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)?);
}
if let Some(value) = raw_payload.get("monthly_quota_usd") {
if value.is_null() {
if fields.contains("monthly_quota_usd") {
if fields.is_null("monthly_quota_usd") {
updated.monthly_quota_usd = None;
} 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 value.is_null() {
if fields.contains("quota_reset_day") {
if fields.is_null("quota_reset_day") {
updated.quota_reset_day = None;
} 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 value.is_null() {
if fields.contains("quota_last_reset_at") {
if fields.is_null("quota_last_reset_at") {
updated.quota_last_reset_at_unix_secs = None;
} 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 value.is_null() {
if fields.contains("quota_expires_at") {
if fields.is_null("quota_expires_at") {
updated.quota_expires_at_unix_secs = None;
} 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 {
return Err(if value.is_null() {
return Err(if fields.is_null("provider_priority") {
"provider_priority 不能为空".to_string()
} else {
"provider_priority 必须是整数".to_string()
@@ -164,21 +164,21 @@ pub(crate) async fn build_admin_update_provider_record(
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 {
return Err("keep_priority_on_conversion 必须是布尔值".to_string());
};
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 {
return Err("is_active 必须是布尔值".to_string());
};
updated.is_active = is_active;
}
if raw_payload.contains_key("concurrent_limit") {
if fields.contains("concurrent_limit") {
updated.concurrent_limit = match payload.concurrent_limit {
Some(value) if value >= 0 => Some(value),
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 {
Some(value) if (0..=999).contains(&value) => Some(value),
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")?;
}
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 {
Some(value) if (1.0..=300.0).contains(&value) => Some(value),
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 {
Some(value) if (1.0..=600.0).contains(&value) => Some(value),
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 {
return Err("enable_format_conversion 必须是布尔值".to_string());
};
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")?
} else {
updated.config.clone()
@@ -232,11 +232,8 @@ pub(crate) async fn build_admin_update_provider_record(
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
if raw_payload.contains_key("claude_code_advanced") {
if raw_payload
.get("claude_code_advanced")
.is_some_and(serde_json::Value::is_null)
{
if fields.contains("claude_code_advanced") {
if fields.is_null("claude_code_advanced") {
config_map.remove("claude_code_advanced");
} else {
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");
}
if raw_payload.contains_key("pool_advanced") {
if raw_payload
.get("pool_advanced")
.is_some_and(serde_json::Value::is_null)
{
if fields.contains("pool_advanced") {
if fields.is_null("pool_advanced") {
config_map.remove("pool_advanced");
} else {
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 raw_payload
.get("failover_rules")
.is_some_and(serde_json::Value::is_null)
{
if fields.contains("failover_rules") {
if fields.is_null("failover_rules") {
config_map.remove("failover_rules");
} else {
let value = normalize_json_object(payload.failover_rules, "failover_rules")?

View File

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

View File

@@ -23,16 +23,11 @@ impl<'a> AdminAppState<'a> {
&self,
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
existing: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
raw_payload: &serde_json::Map<String, serde_json::Value>,
payload: crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdateRequest,
patch: crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch,
) -> Result<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey, String>
{
crate::handlers::admin::provider::write::keys::build_admin_update_provider_key_record(
self,
provider,
existing,
raw_payload,
payload,
self, provider, existing, patch,
)
.await
}
@@ -130,17 +125,13 @@ impl<'a> AdminAppState<'a> {
pub(crate) async fn build_admin_update_provider_record(
&self,
existing: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
raw_payload: &serde_json::Map<String, serde_json::Value>,
payload: crate::handlers::admin::provider::shared::payloads::AdminProviderUpdateRequest,
patch: crate::handlers::admin::provider::shared::payloads::AdminProviderUpdatePatch,
) -> Result<
aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
String,
> {
crate::handlers::admin::provider::write::provider::build_admin_update_provider_record(
self,
existing,
raw_payload,
payload,
self, existing, patch,
)
.await
}
@@ -277,8 +268,7 @@ impl<'a> AdminAppState<'a> {
&self,
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
existing_endpoint: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint,
raw_payload: &serde_json::Map<String, serde_json::Value>,
payload: crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdateRequest,
patch: crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdatePatch,
) -> Result<
aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint,
String,
@@ -286,9 +276,10 @@ impl<'a> AdminAppState<'a> {
use crate::api::ai::admin_endpoint_signature_parts;
use crate::handlers::public::{admin_requested_force_stream, normalize_admin_base_url};
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)
&& (raw_payload.contains_key("base_url") || raw_payload.contains_key("custom_path"))
&& (fields.contains("base_url") || fields.contains("custom_path"))
{
return Err(
"固定类型 Provider 的 Endpoint 不允许修改 base_url/custom_path".to_string(),
@@ -312,13 +303,14 @@ impl<'a> AdminAppState<'a> {
let mut updated =
admin_provider_endpoints_pure::apply_admin_provider_endpoint_update_fields(
existing_endpoint,
raw_payload,
|field| fields.contains(field),
|field| fields.is_null(field),
&update_fields,
)?;
let provider_type = provider.provider_type.trim().to_ascii_lowercase();
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 {
updated
.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::export::{
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::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 chrono::Utc;
use serde_json::json;
@@ -31,42 +35,50 @@ impl<'a> AdminAppState<'a> {
.collect::<BTreeMap<_, _>>();
let global_models_data = global_models
.iter()
.map(|model| {
json!({
"name": model.name,
"display_name": model.display_name,
"default_price_per_request": model.default_price_per_request,
"default_tiered_pricing": model.default_tiered_pricing,
"supported_capabilities": model.supported_capabilities,
"config": model.config,
"is_active": model.is_active,
})
.map(|model| AdminSystemConfigGlobalModel {
name: model.name.clone(),
display_name: model.display_name.clone(),
default_price_per_request: model.default_price_per_request,
default_tiered_pricing: model.default_tiered_pricing.clone(),
supported_capabilities: model.supported_capabilities.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<_>>()
})
}),
config: model.config.clone(),
is_active: model.is_active,
})
.collect::<Vec<_>>();
let providers_data =
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 bind_password = config
.bind_password_encrypted
.as_deref()
.and_then(|ciphertext| decrypt_admin_system_export_secret(self, ciphertext))
.unwrap_or_default();
json!({
"server_url": config.server_url,
"bind_dn": config.bind_dn,
"bind_password": bind_password,
"base_dn": config.base_dn,
"user_search_filter": config.user_search_filter,
"username_attr": config.username_attr,
"email_attr": config.email_attr,
"display_name_attr": config.display_name_attr,
"is_enabled": config.is_enabled,
"is_exclusive": config.is_exclusive,
"use_starttls": config.use_starttls,
"connect_timeout": config.connect_timeout,
})
});
let ldap_data = self
.get_ldap_module_config()
.await?
.map(|config| AdminSystemConfigLdap {
server_url: config.server_url,
bind_dn: config.bind_dn,
bind_password: Some(
config
.bind_password_encrypted
.as_deref()
.and_then(|ciphertext| decrypt_admin_system_export_secret(self, ciphertext))
.unwrap_or_default(),
),
base_dn: config.base_dn,
user_search_filter: config.user_search_filter,
username_attr: config.username_attr,
email_attr: config.email_attr,
display_name_attr: config.display_name_attr,
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_data = system_configs
@@ -82,73 +94,72 @@ impl<'a> AdminAppState<'a> {
} else {
entry.value.clone()
};
json!({
"key": entry.key,
"value": value,
"description": entry.description,
})
AdminSystemConfigEntry {
key: entry.key.clone(),
value,
description: entry.description.clone(),
}
})
.collect::<Vec<_>>();
let oauth_providers = self.list_oauth_provider_configs().await?;
let oauth_data = oauth_providers
.iter()
.map(|provider| {
let client_secret = provider
.client_secret_encrypted
.as_deref()
.and_then(|ciphertext| decrypt_admin_system_export_secret(self, ciphertext))
.unwrap_or_default();
json!({
"provider_type": provider.provider_type,
"display_name": provider.display_name,
"client_id": provider.client_id,
"client_secret": client_secret,
"authorization_url_override": provider.authorization_url_override,
"token_url_override": provider.token_url_override,
"userinfo_url_override": provider.userinfo_url_override,
"scopes": provider.scopes,
"redirect_uri": provider.redirect_uri,
"frontend_callback_url": provider.frontend_callback_url,
"attribute_mapping": provider.attribute_mapping,
"extra_config": provider.extra_config,
"is_enabled": provider.is_enabled,
})
.map(|provider| AdminSystemConfigOAuthProvider {
provider_type: provider.provider_type.clone(),
display_name: provider.display_name.clone(),
client_id: provider.client_id.clone(),
client_secret: Some(
provider
.client_secret_encrypted
.as_deref()
.and_then(|ciphertext| decrypt_admin_system_export_secret(self, ciphertext))
.unwrap_or_default(),
),
authorization_url_override: provider.authorization_url_override.clone(),
token_url_override: provider.token_url_override.clone(),
userinfo_url_override: provider.userinfo_url_override.clone(),
scopes: provider.scopes.clone(),
redirect_uri: provider.redirect_uri.clone(),
frontend_callback_url: provider.frontend_callback_url.clone(),
attribute_mapping: provider.attribute_mapping.clone(),
extra_config: provider.extra_config.clone(),
is_enabled: provider.is_enabled,
})
.collect::<Vec<_>>();
let proxy_nodes = self.list_proxy_nodes().await?;
let proxy_nodes_data = proxy_nodes
.iter()
.map(|node| {
json!({
"id": node.id,
"name": node.name,
"ip": node.ip,
"port": node.port,
"region": node.region,
"is_manual": node.is_manual,
"proxy_url": node.proxy_url,
"proxy_username": node.proxy_username,
"proxy_password": node.proxy_password,
"tunnel_mode": node.tunnel_mode,
"heartbeat_interval": node.heartbeat_interval,
"remote_config": node.remote_config,
"config_version": node.config_version,
})
.map(|node| AdminSystemConfigProxyNode {
id: Some(node.id.clone()),
name: Some(node.name.clone()),
ip: Some(node.ip.clone()),
port: Some(node.port),
region: node.region.clone(),
is_manual: Some(node.is_manual),
proxy_url: node.proxy_url.clone(),
proxy_username: node.proxy_username.clone(),
proxy_password: node.proxy_password.clone(),
tunnel_mode: Some(node.tunnel_mode),
heartbeat_interval: Some(node.heartbeat_interval),
remote_config: node.remote_config.clone(),
config_version: Some(node.config_version),
})
.collect::<Vec<_>>();
Ok(json!({
"version": ADMIN_SYSTEM_CONFIG_EXPORT_VERSION,
"exported_at": Utc::now().to_rfc3339(),
"global_models": global_models_data,
"providers": providers_data,
"proxy_nodes": proxy_nodes_data,
"ldap_config": ldap_data,
"oauth_providers": oauth_data,
"system_configs": system_configs_data,
}))
let document = AdminSystemConfigDocument {
version: ADMIN_SYSTEM_CONFIG_EXPORT_VERSION.to_string(),
exported_at: Utc::now().to_rfc3339(),
global_models: global_models_data,
providers: providers_data,
proxy_nodes: proxy_nodes_data,
ldap_config: ldap_data,
oauth_providers: oauth_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(

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ use crate::GatewayError;
mod adaptive;
mod export;
mod import;
mod modules;
mod proxy_nodes;
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")
&& request_method == http::Method::GET
&& request_path == "/api/admin/system/users/export"
@@ -113,8 +140,7 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
if matches!(
decision.route_kind.as_deref(),
Some(
"config_import"
| "users_import"
"users_import"
| "smtp_test"
| "cleanup"
| "purge_config"

View File

@@ -5,14 +5,17 @@ use super::support::{
};
use crate::handlers::admin::request::AdminAppState;
use crate::GatewayError;
use aether_admin::system::{
AdminSystemConfigEndpoint, AdminSystemConfigProvider, AdminSystemConfigProviderKey,
AdminSystemConfigProviderModel,
};
use aether_data_contracts::repository::global_models::AdminProviderModelListQuery;
use serde_json::json;
use std::collections::BTreeMap;
pub(crate) async fn build_admin_system_export_providers_payload(
state: &AdminAppState<'_>,
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 provider_ids = providers
.iter()
@@ -56,24 +59,24 @@ pub(crate) async fn build_admin_system_export_providers_payload(
Ok(providers
.iter()
.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 =
collect_admin_system_export_provider_endpoint_formats(&endpoints);
let endpoints_data = endpoints
.iter()
.map(|endpoint| {
json!({
"api_format": endpoint.api_format,
"base_url": endpoint.base_url,
"header_rules": endpoint.header_rules,
"body_rules": endpoint.body_rules,
"max_retries": endpoint.max_retries,
"is_active": endpoint.is_active,
"custom_path": endpoint.custom_path,
"config": endpoint.config,
"format_acceptance_config": endpoint.format_acceptance_config,
"proxy": endpoint.proxy,
})
.map(|endpoint| AdminSystemConfigEndpoint {
api_format: endpoint.api_format.clone(),
base_url: endpoint.base_url.clone(),
header_rules: endpoint.header_rules.clone(),
body_rules: endpoint.body_rules.clone(),
max_retries: endpoint.max_retries,
is_active: endpoint.is_active,
custom_path: endpoint.custom_path.clone(),
config: endpoint.config.clone(),
format_acceptance_config: endpoint.format_acceptance_config.clone(),
proxy: endpoint.proxy.clone(),
})
.collect::<Vec<_>>();
@@ -95,38 +98,76 @@ pub(crate) async fn build_admin_system_export_providers_payload(
key.api_formats.as_ref(),
&provider_endpoint_formats,
);
let mut payload = json!({
"api_formats": api_formats,
"supported_endpoints": api_formats,
"auth_type": key.auth_type,
"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) =
let auth_config = key
.encrypted_auth_config
.as_deref()
.and_then(|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<_>>();
@@ -134,46 +175,47 @@ pub(crate) async fn build_admin_system_export_providers_payload(
.remove(&provider.id)
.unwrap_or_default()
.into_iter()
.map(|model| {
json!({
"provider_model_name": model.provider_model_name,
"provider_model_mappings": model.provider_model_mappings,
"price_per_request": model.price_per_request,
"tiered_pricing": model.tiered_pricing,
"supports_vision": model.supports_vision,
"supports_function_calling": model.supports_function_calling,
"supports_streaming": model.supports_streaming,
"supports_extended_thinking": model.supports_extended_thinking,
"supports_image_generation": model.supports_image_generation,
"is_active": model.is_active,
"config": model.config,
"global_model_name": global_model_name_by_id.get(&model.global_model_id),
})
.map(|model| AdminSystemConfigProviderModel {
global_model_name: global_model_name_by_id.get(&model.global_model_id).cloned(),
provider_model_name: model.provider_model_name,
provider_model_mappings: model.provider_model_mappings,
price_per_request: model.price_per_request,
tiered_pricing: model.tiered_pricing,
supports_vision: model.supports_vision,
supports_function_calling: model.supports_function_calling,
supports_streaming: model.supports_streaming,
supports_extended_thinking: model.supports_extended_thinking,
supports_image_generation: model.supports_image_generation,
is_active: model.is_active,
config: model.config,
})
.collect::<Vec<_>>();
json!({
"name": provider.name,
"description": provider.description,
"website": provider.website,
"provider_type": provider.provider_type,
"billing_type": provider.billing_type,
"monthly_quota_usd": provider.monthly_quota_usd,
"quota_reset_day": provider.quota_reset_day,
"provider_priority": provider.provider_priority,
"keep_priority_on_conversion": provider.keep_priority_on_conversion,
"enable_format_conversion": provider.enable_format_conversion,
"is_active": provider.is_active,
"concurrent_limit": provider.concurrent_limit,
"max_retries": provider.max_retries,
"proxy": provider.proxy,
"request_timeout": provider.request_timeout_secs,
"stream_first_byte_timeout": provider.stream_first_byte_timeout_secs,
"config": decrypt_admin_system_export_provider_config(state, provider.config.as_ref()),
"endpoints": endpoints_data,
"api_keys": keys_data,
"models": models_data,
})
AdminSystemConfigProvider {
name: provider.name.clone(),
description: provider.description.clone(),
website: provider.website.clone(),
provider_type: Some(provider.provider_type.clone()),
billing_type: provider.billing_type.clone(),
monthly_quota_usd: provider.monthly_quota_usd,
quota_reset_day: provider.quota_reset_day,
provider_priority: Some(provider.provider_priority),
keep_priority_on_conversion: Some(provider.keep_priority_on_conversion),
enable_format_conversion: Some(provider.enable_format_conversion),
is_active: provider.is_active,
concurrent_limit: provider.concurrent_limit,
max_retries: provider.max_retries,
stream_first_byte_timeout: provider.stream_first_byte_timeout_secs,
request_timeout: provider.request_timeout_secs,
proxy: provider.proxy.clone(),
config: decrypt_admin_system_export_provider_config(
state,
provider.config.as_ref(),
),
endpoints: endpoints_data,
api_keys: keys_data,
models: models_data,
}
})
.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::handlers::admin::request::AdminAppState;
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;
pub(crate) const ADMIN_SYSTEM_CONFIG_EXPORT_VERSION: &str = "2.2";
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(
state: &AdminAppState<'_>,
ciphertext: &str,
@@ -74,7 +63,7 @@ pub(super) fn decrypt_admin_system_export_provider_config(
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 {
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_read_only_response, normalize_admin_optional_user_email,
normalize_admin_user_api_formats, normalize_admin_user_role, normalize_admin_user_string_list,
normalize_admin_username, validate_admin_user_password, AdminUpdateUserFieldPresence,
AdminUpdateUserRequest,
normalize_admin_username, validate_admin_user_password, AdminUpdateUserPatch,
};
use super::support::{
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())
}
};
let field_presence = AdminUpdateUserFieldPresence {
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(),
)) {
let patch = match AdminUpdateUserPatch::from_object(raw_payload.clone()) {
Ok(value) => value,
Err(_) => {
return Ok((
@@ -69,6 +61,7 @@ pub(in super::super) async fn build_admin_update_user_response(
.into_response())
}
};
let (field_presence, payload) = patch.into_parts();
let email = match payload.email.as_deref() {
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());
}
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") {
Ok(value) => value,
Err(detail) => {
@@ -156,7 +149,7 @@ pub(in super::super) async fn build_admin_update_user_response(
} else {
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) {
Ok(value) => value,
Err(detail) => {
@@ -170,7 +163,7 @@ pub(in super::super) async fn build_admin_update_user_response(
} else {
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") {
Ok(value) => value,
Err(detail) => {
@@ -188,9 +181,9 @@ pub(in super::super) async fn build_admin_update_user_response(
|| username.is_some()
|| payload.password.is_some()
|| role.is_some()
|| field_presence.allowed_providers
|| field_presence.allowed_api_formats
|| field_presence.allowed_models
|| field_presence.contains("allowed_providers")
|| field_presence.contains("allowed_api_formats")
|| field_presence.contains("allowed_models")
|| payload.rate_limit.is_some()
|| payload.is_active.is_some();
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()
|| field_presence.allowed_providers
|| field_presence.allowed_api_formats
|| field_presence.allowed_models
|| field_presence.contains("allowed_providers")
|| field_presence.contains("allowed_api_formats")
|| field_presence.contains("allowed_models")
|| payload.rate_limit.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(
&user_id,
role,
field_presence.allowed_providers,
field_presence.contains("allowed_providers"),
allowed_providers,
field_presence.allowed_api_formats,
field_presence.contains("allowed_api_formats"),
allowed_api_formats,
field_presence.allowed_models,
field_presence.contains("allowed_models"),
allowed_models,
payload.rate_limit,
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_list_user_sessions_response,
};
use self::shared::AdminUpdateUserPatch;
use self::shared::{
admin_default_user_initial_gift, build_admin_users_bad_request_response,
build_admin_users_data_unavailable_response, build_admin_users_read_only_response,
format_optional_datetime_iso8601, normalize_admin_optional_user_email,
normalize_admin_user_role, normalize_admin_username, validate_admin_user_password,
AdminCreateUserApiKeyRequest, AdminCreateUserRequest, AdminToggleUserApiKeyLockRequest,
AdminUpdateUserApiKeyRequest, AdminUpdateUserFieldPresence, AdminUpdateUserRequest,
AdminUpdateUserApiKeyRequest,
};
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 crate::handlers::admin::shared::AdminTypedObjectPatch;
use axum::{
body::Body,
http,
@@ -94,12 +95,7 @@ pub(super) struct AdminUpdateUserRequest {
pub(super) is_active: Option<bool>,
}
#[derive(Debug, Default)]
pub(super) struct AdminUpdateUserFieldPresence {
pub(super) allowed_providers: bool,
pub(super) allowed_api_formats: bool,
pub(super) allowed_models: bool,
}
pub(super) type AdminUpdateUserPatch = AdminTypedObjectPatch<AdminUpdateUserRequest>;
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_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("config_set"))
| (Some("system_manage"), http::Method::PUT, Some("email_template_set"))

View File

@@ -18,6 +18,7 @@ mod proxy_nodes;
mod security;
mod stats;
mod system;
mod system_import;
mod usage;
mod users;
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();
}
#[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]
async fn gateway_handles_admin_system_provider_priority_mode_locally_with_bearer_admin_session() {
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"
}
]
}