mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(admin): 实现系统数据导入导出功能,支持提供商和模型批量配置
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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")?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
1197
apps/aether-gateway/src/handlers/admin/request/system/import.rs
Normal file
1197
apps/aether-gateway/src/handlers/admin/request/system/import.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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<_>>())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
552
apps/aether-gateway/src/tests/control/admin/system_import.rs
Normal file
552
apps/aether-gateway/src/tests/control/admin/system_import.rs
Normal 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();
|
||||||
|
}
|
||||||
97
apps/aether-gateway/src/tests/fixtures/admin_system/config_export_v20.json
vendored
Normal file
97
apps/aether-gateway/src/tests/fixtures/admin_system/config_export_v20.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
122
apps/aether-gateway/src/tests/fixtures/admin_system/config_export_v21.json
vendored
Normal file
122
apps/aether-gateway/src/tests/fixtures/admin_system/config_export_v21.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
97
apps/aether-gateway/src/tests/fixtures/admin_system/config_export_v22.json
vendored
Normal file
97
apps/aether-gateway/src/tests/fixtures/admin_system/config_export_v22.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user