mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Redesign sensitive info protection settings
This commit is contained in:
@@ -37,9 +37,9 @@ use crate::ai_serving::{
|
||||
};
|
||||
use crate::ai_serving::{ConversionMode, ExecutionStrategy};
|
||||
use crate::privacy::{
|
||||
build_redaction_session_config, provider_chat_pii_redaction_enabled,
|
||||
read_chat_pii_redaction_runtime_config, try_mask_chat_request_json_with_cache_options,
|
||||
MaskChatRequestOptions, RedactionMaskError, RedactionSessionSlot, RedisRedactionMappingCache,
|
||||
build_redaction_session_config, read_chat_pii_redaction_runtime_config,
|
||||
try_mask_chat_request_json_with_cache_options, MaskChatRequestOptions, RedactionMaskError,
|
||||
RedactionSessionSlot, RedisRedactionMappingCache,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use tracing::warn;
|
||||
@@ -89,6 +89,76 @@ impl<'a> ProviderChatRequestRedaction<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ChatPiiRedactionFeatureSettings {
|
||||
enabled: Option<bool>,
|
||||
inject_model_instruction: Option<bool>,
|
||||
}
|
||||
|
||||
impl ChatPiiRedactionFeatureSettings {
|
||||
fn merge_from_value(&mut self, value: Option<&Value>) {
|
||||
let Some(settings) = value
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|features| features.get("chat_pii_redaction"))
|
||||
.and_then(Value::as_object)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(enabled) = settings.get("enabled").and_then(Value::as_bool) {
|
||||
self.enabled = Some(enabled);
|
||||
}
|
||||
if let Some(inject_model_instruction) = settings
|
||||
.get("inject_model_instruction")
|
||||
.and_then(Value::as_bool)
|
||||
{
|
||||
self.inject_model_instruction = Some(inject_model_instruction);
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_enabled(self) -> bool {
|
||||
self.enabled.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn effective_inject_model_instruction(self) -> bool {
|
||||
self.inject_model_instruction.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_chat_pii_redaction_feature_settings(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
) -> Result<ChatPiiRedactionFeatureSettings, GatewayError> {
|
||||
let user_settings = state
|
||||
.read_user_feature_settings(&input.auth_context.user_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
error = ?err,
|
||||
"gateway failed to read user chat pii redaction feature settings"
|
||||
);
|
||||
GatewayError::Internal("chat pii redaction setup failed".to_string())
|
||||
})?;
|
||||
let key_settings = state
|
||||
.read_auth_api_key_feature_settings(
|
||||
&input.auth_context.user_id,
|
||||
&input.auth_context.api_key_id,
|
||||
input.auth_context.api_key_is_standalone,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
error = ?err,
|
||||
"gateway failed to read api key chat pii redaction feature settings"
|
||||
);
|
||||
GatewayError::Internal("chat pii redaction setup failed".to_string())
|
||||
})?;
|
||||
|
||||
let mut settings = ChatPiiRedactionFeatureSettings::default();
|
||||
settings.merge_from_value(user_settings.as_ref());
|
||||
settings.merge_from_value(key_settings.as_ref());
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
state: &AppState,
|
||||
@@ -117,7 +187,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
)
|
||||
.await;
|
||||
let redaction =
|
||||
resolve_provider_chat_request_redaction(state, parts, body_json, transport, candidate_id)
|
||||
resolve_provider_chat_request_redaction(state, parts, body_json, input, candidate_id)
|
||||
.await?;
|
||||
let body_json = redaction.body_json.as_ref();
|
||||
|
||||
@@ -712,7 +782,7 @@ async fn resolve_provider_chat_request_redaction<'a>(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &'a Value,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
candidate_id: &str,
|
||||
) -> Result<ProviderChatRequestRedaction<'a>, GatewayError> {
|
||||
if parts.uri.path() != "/v1/chat/completions" {
|
||||
@@ -730,7 +800,11 @@ async fn resolve_provider_chat_request_redaction<'a>(
|
||||
);
|
||||
GatewayError::Internal("chat pii redaction setup failed".to_string())
|
||||
})?;
|
||||
if !provider_chat_pii_redaction_enabled(transport.provider.config.as_ref(), &runtime_config) {
|
||||
if !runtime_config.enabled {
|
||||
return Ok(ProviderChatRequestRedaction::disabled(body_json, parts));
|
||||
}
|
||||
let feature_settings = resolve_chat_pii_redaction_feature_settings(state, input).await?;
|
||||
if !feature_settings.effective_enabled() {
|
||||
return Ok(ProviderChatRequestRedaction::disabled(body_json, parts));
|
||||
}
|
||||
let Some(hmac_key) = state.encryption_key().map(str::as_bytes).map(Vec::from) else {
|
||||
@@ -754,7 +828,7 @@ async fn resolve_provider_chat_request_redaction<'a>(
|
||||
let masked = try_mask_chat_request_json_with_cache_options(
|
||||
&body_bytes,
|
||||
build_redaction_session_config(hmac_key, &runtime_config, now_unix_secs),
|
||||
MaskChatRequestOptions::runtime(runtime_config.inject_model_instruction),
|
||||
MaskChatRequestOptions::runtime(feature_settings.effective_inject_model_instruction()),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -447,6 +447,19 @@ impl GatewayDataState {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_user_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, DataLayerError> {
|
||||
let Some(repository) = self.user_reader.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
repository
|
||||
.update_user_feature_settings(user_id, settings)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_local_auth_user_profile(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -1379,6 +1392,27 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_auth_api_key_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
is_standalone: bool,
|
||||
) -> Result<Option<serde_json::Value>, DataLayerError> {
|
||||
if is_standalone {
|
||||
return Ok(self
|
||||
.find_auth_api_key_export_standalone_record_by_id(api_key_id)
|
||||
.await?
|
||||
.and_then(|record| record.feature_settings));
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.list_auth_api_key_export_records_by_ids(&[api_key_id.to_string()])
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|record| record.user_id == user_id && !record.is_standalone)
|
||||
.and_then(|record| record.feature_settings))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_auth_api_key_export_records_by_name_search(
|
||||
&self,
|
||||
name_search: &str,
|
||||
@@ -1598,6 +1632,37 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
|
||||
match &self.auth_api_key_writer {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.set_user_api_key_feature_settings(user_id, api_key_id, feature_settings)
|
||||
.await
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn set_standalone_api_key_feature_settings(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
|
||||
match &self.auth_api_key_writer {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.set_standalone_api_key_feature_settings(api_key_id, feature_settings)
|
||||
.await
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_user_api_key(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -1459,6 +1459,21 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_user_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<serde_json::Value>, DataLayerError> {
|
||||
if let Some(user) = self.find_export_user_by_id(user_id).await? {
|
||||
return Ok(user.feature_settings);
|
||||
}
|
||||
Ok(self
|
||||
.list_non_admin_export_users()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|user| user.id == user_id)
|
||||
.and_then(|user| user.feature_settings))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_non_admin_export_users(
|
||||
&self,
|
||||
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
|
||||
@@ -9,8 +9,8 @@ use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
use crate::handlers::admin::users::{
|
||||
default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
|
||||
generate_admin_user_api_key_plaintext, hash_admin_user_api_key, masked_user_api_key_display,
|
||||
normalize_admin_optional_api_key_name, normalize_admin_user_api_formats,
|
||||
normalize_admin_user_string_list,
|
||||
normalize_admin_feature_settings, normalize_admin_optional_api_key_name,
|
||||
normalize_admin_user_api_formats, normalize_admin_user_string_list,
|
||||
};
|
||||
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
|
||||
use crate::GatewayError;
|
||||
@@ -137,6 +137,10 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
"设置 auto_delete_on_expiry 前必须提供 expires_at",
|
||||
));
|
||||
}
|
||||
let feature_settings = match normalize_admin_feature_settings(payload.feature_settings) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
|
||||
};
|
||||
|
||||
let plaintext_key = generate_admin_user_api_key_plaintext();
|
||||
let Some(key_encrypted) = state.encrypt_catalog_secret_with_fallbacks(&plaintext_key) else {
|
||||
@@ -180,6 +184,14 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
Some(wallet) => wallet,
|
||||
None => return Ok(build_admin_api_keys_data_unavailable_response()),
|
||||
};
|
||||
let created = if feature_settings.is_some() {
|
||||
state
|
||||
.set_standalone_api_key_feature_settings(&created.api_key_id, feature_settings.clone())
|
||||
.await?
|
||||
.unwrap_or(created)
|
||||
} else {
|
||||
created
|
||||
};
|
||||
|
||||
Ok(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
@@ -196,6 +208,7 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
"allowed_models": created.allowed_models,
|
||||
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
|
||||
"auto_delete_on_expiry": created.auto_delete_on_expiry,
|
||||
"feature_settings": created.feature_settings,
|
||||
"wallet": serialize_admin_system_users_export_wallet(Some(&wallet)),
|
||||
"message": "独立余额Key创建成功,请妥善保存完整密钥,后续将无法查看",
|
||||
}))
|
||||
@@ -245,6 +258,14 @@ pub(super) async fn build_admin_update_api_key_response(
|
||||
let null_auto_delete_on_expiry =
|
||||
patch.contains("auto_delete_on_expiry") && patch.is_null("auto_delete_on_expiry");
|
||||
let (field_presence, payload) = patch.into_parts();
|
||||
let feature_settings = if field_presence.contains("feature_settings") {
|
||||
match normalize_admin_feature_settings(payload.feature_settings.flatten()) {
|
||||
Ok(value) => Some(value),
|
||||
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if null_unlimited_balance {
|
||||
return Ok(build_admin_api_keys_bad_request_response(
|
||||
"unlimited_balance 必须是布尔值",
|
||||
@@ -387,6 +408,14 @@ pub(super) async fn build_admin_update_api_key_response(
|
||||
else {
|
||||
return Ok(build_admin_api_keys_data_unavailable_response());
|
||||
};
|
||||
let updated = if let Some(feature_settings) = feature_settings {
|
||||
state
|
||||
.set_standalone_api_key_feature_settings(&api_key_id, feature_settings)
|
||||
.await?
|
||||
.unwrap_or(updated)
|
||||
} else {
|
||||
updated
|
||||
};
|
||||
|
||||
if wallet.is_none() {
|
||||
wallet = state
|
||||
|
||||
@@ -10,7 +10,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const ADMIN_API_KEYS_DATA_UNAVAILABLE_DETAIL: &str = "Admin standalone API key data unavailable";
|
||||
|
||||
@@ -27,6 +27,7 @@ pub(super) struct AdminStandaloneApiKeyCreateRequest {
|
||||
pub(super) expire_days: Option<i32>,
|
||||
pub(super) expires_at: Option<String>,
|
||||
pub(super) auto_delete_on_expiry: Option<bool>,
|
||||
pub(super) feature_settings: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
@@ -42,6 +43,7 @@ pub(super) struct AdminStandaloneApiKeyUpdateRequest {
|
||||
pub(super) expire_days: Option<i32>,
|
||||
pub(super) expires_at: Option<String>,
|
||||
pub(super) auto_delete_on_expiry: Option<bool>,
|
||||
pub(super) feature_settings: Option<Option<Value>>,
|
||||
}
|
||||
|
||||
pub(super) type AdminStandaloneApiKeyUpdatePatch =
|
||||
@@ -164,6 +166,7 @@ pub(super) fn build_admin_api_key_list_item_payload(
|
||||
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
"updated_at": format_optional_unix_secs_iso8601(record.updated_at_unix_secs),
|
||||
"auto_delete_on_expiry": record.auto_delete_on_expiry,
|
||||
"feature_settings": record.feature_settings,
|
||||
"wallet": serialize_admin_system_users_export_wallet(wallet),
|
||||
})
|
||||
}
|
||||
@@ -193,6 +196,7 @@ pub(super) fn build_admin_api_key_detail_payload(
|
||||
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
"updated_at": format_optional_unix_secs_iso8601(record.updated_at_unix_secs),
|
||||
"auto_delete_on_expiry": record.auto_delete_on_expiry,
|
||||
"feature_settings": record.feature_settings,
|
||||
"wallet": serialize_admin_system_users_export_wallet(wallet),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -275,6 +275,7 @@ impl<'a> AdminAppState<'a> {
|
||||
"rate_limit": user.rate_limit,
|
||||
"rate_limit_mode": user.rate_limit_mode.clone(),
|
||||
"model_capability_settings": user.model_capability_settings.clone(),
|
||||
"feature_settings": user.feature_settings.clone(),
|
||||
"group_ids": group_ids,
|
||||
"group_names": group_names,
|
||||
"unlimited": wallet
|
||||
@@ -334,6 +335,10 @@ impl<'a> AdminAppState<'a> {
|
||||
"force_capabilities".to_string(),
|
||||
json!(key.force_capabilities.clone()),
|
||||
),
|
||||
(
|
||||
"feature_settings".to_string(),
|
||||
json!(key.feature_settings.clone()),
|
||||
),
|
||||
("is_active".to_string(), json!(key.is_active)),
|
||||
(
|
||||
"expires_at".to_string(),
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::handlers::admin::shared::{
|
||||
};
|
||||
use crate::handlers::admin::system::shared::configs::apply_admin_system_config_update;
|
||||
use crate::handlers::admin::users::{
|
||||
hash_admin_user_api_key, normalize_admin_list_policy_mode,
|
||||
hash_admin_user_api_key, normalize_admin_feature_settings, normalize_admin_list_policy_mode,
|
||||
normalize_admin_rate_limit_policy_mode, normalize_admin_user_api_formats,
|
||||
normalize_admin_user_string_list,
|
||||
};
|
||||
@@ -2104,6 +2104,11 @@ impl<'a> AdminAppState<'a> {
|
||||
user.get("model_capability_settings"),
|
||||
"model_capability_settings"
|
||||
));
|
||||
let feature_settings = invalid_value!(imported_optional_json_object(
|
||||
user.get("feature_settings"),
|
||||
"feature_settings"
|
||||
)
|
||||
.and_then(normalize_admin_feature_settings));
|
||||
let wallet_payload = match user.get("wallet") {
|
||||
Some(Value::Object(map)) => Some(map),
|
||||
Some(Value::Null) | None => None,
|
||||
@@ -2221,6 +2226,14 @@ impl<'a> AdminAppState<'a> {
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if user.contains_key("feature_settings") {
|
||||
let _ = self
|
||||
.update_user_feature_settings(
|
||||
&existing.id,
|
||||
feature_settings.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if allowed_providers_mode.is_some()
|
||||
|| allowed_api_formats_mode.is_some()
|
||||
|| allowed_models_mode.is_some()
|
||||
@@ -2284,6 +2297,11 @@ impl<'a> AdminAppState<'a> {
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if user.contains_key("feature_settings") {
|
||||
let _ = self
|
||||
.update_user_feature_settings(&created.id, feature_settings.clone())
|
||||
.await?;
|
||||
}
|
||||
let created = if allowed_providers_mode.is_some()
|
||||
|| allowed_api_formats_mode.is_some()
|
||||
|| allowed_models_mode.is_some()
|
||||
@@ -2402,6 +2420,11 @@ impl<'a> AdminAppState<'a> {
|
||||
"total_cost_usd"
|
||||
))
|
||||
.unwrap_or(0.0);
|
||||
let feature_settings = invalid_value!(imported_optional_json_object(
|
||||
key.get("feature_settings"),
|
||||
"feature_settings"
|
||||
)
|
||||
.and_then(normalize_admin_feature_settings));
|
||||
|
||||
if let Some(existing_key) = existing_api_keys_by_hash.get(&key_hash).cloned() {
|
||||
match merge_mode {
|
||||
@@ -2450,6 +2473,15 @@ impl<'a> AdminAppState<'a> {
|
||||
force_capabilities.clone(),
|
||||
)
|
||||
.await?;
|
||||
if key.contains_key("feature_settings") {
|
||||
let _ = self
|
||||
.set_user_api_key_feature_settings(
|
||||
&user_id,
|
||||
&existing_key.api_key_id,
|
||||
feature_settings.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let _ = self
|
||||
.set_user_api_key_active(
|
||||
&user_id,
|
||||
@@ -2503,6 +2535,15 @@ impl<'a> AdminAppState<'a> {
|
||||
json!({ "detail": "Admin system data unavailable" }),
|
||||
)));
|
||||
};
|
||||
if key.contains_key("feature_settings") {
|
||||
let _ = self
|
||||
.set_user_api_key_feature_settings(
|
||||
&user_id,
|
||||
&created.api_key_id,
|
||||
feature_settings.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
existing_api_keys_by_hash.insert(key_hash, created);
|
||||
stats.api_keys.created += 1;
|
||||
}
|
||||
@@ -2596,6 +2637,11 @@ impl<'a> AdminAppState<'a> {
|
||||
"total_cost_usd"
|
||||
))
|
||||
.unwrap_or(0.0);
|
||||
let feature_settings = invalid_value!(imported_optional_json_object(
|
||||
key.get("feature_settings"),
|
||||
"feature_settings"
|
||||
)
|
||||
.and_then(normalize_admin_feature_settings));
|
||||
let wallet_payload = match key.get("wallet") {
|
||||
Some(Value::Object(map)) => Some(map),
|
||||
Some(Value::Null) | None => None,
|
||||
@@ -2643,6 +2689,14 @@ impl<'a> AdminAppState<'a> {
|
||||
let _ = self
|
||||
.set_standalone_api_key_active(&existing_key.api_key_id, is_active)
|
||||
.await?;
|
||||
if key.contains_key("feature_settings") {
|
||||
let _ = self
|
||||
.set_standalone_api_key_feature_settings(
|
||||
&existing_key.api_key_id,
|
||||
feature_settings.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if key.contains_key("expires_at")
|
||||
|| key.contains_key("auto_delete_on_expiry")
|
||||
|| key.contains_key("force_capabilities")
|
||||
@@ -2697,6 +2751,14 @@ impl<'a> AdminAppState<'a> {
|
||||
json!({ "detail": "Admin system data unavailable" }),
|
||||
)));
|
||||
};
|
||||
if key.contains_key("feature_settings") {
|
||||
let _ = self
|
||||
.set_standalone_api_key_feature_settings(
|
||||
&created.api_key_id,
|
||||
feature_settings.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
self.sync_imported_api_key_wallet(
|
||||
&created.api_key_id,
|
||||
&wallet_target,
|
||||
|
||||
@@ -421,6 +421,16 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_user_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
self.app
|
||||
.update_user_feature_settings(user_id, settings)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn count_user_pending_refunds(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -619,6 +629,17 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_standalone_api_key_feature_settings(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
self.app
|
||||
.set_standalone_api_key_feature_settings(api_key_id, feature_settings)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_active(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -666,6 +687,18 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
self.app
|
||||
.set_user_api_key_feature_settings(user_id, api_key_id, feature_settings)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_user_api_key(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
|
||||
},
|
||||
AdminModuleDefinition {
|
||||
name: "chat_pii_redaction",
|
||||
display_name: "敏感信息替换保护",
|
||||
display_name: "敏感信息保护",
|
||||
description: "发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。",
|
||||
category: "security",
|
||||
env_key: "CHAT_PII_REDACTION_AVAILABLE",
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(super) fn build_admin_user_api_key_detail_payload(
|
||||
"total_cost_usd": record.total_cost_usd,
|
||||
"rate_limit": record.rate_limit,
|
||||
"concurrent_limit": record.concurrent_limit,
|
||||
"feature_settings": record.feature_settings,
|
||||
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
|
||||
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::super::super::{
|
||||
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
|
||||
build_admin_users_read_only_response, AdminCreateUserApiKeyRequest,
|
||||
build_admin_users_read_only_response, normalize_admin_feature_settings,
|
||||
AdminCreateUserApiKeyRequest,
|
||||
};
|
||||
use super::super::helpers::{
|
||||
attach_audit_response, default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
|
||||
@@ -74,6 +75,16 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let feature_settings = match normalize_admin_feature_settings(payload.feature_settings) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
let name = match normalize_admin_optional_api_key_name(payload.name) {
|
||||
Ok(Some(value)) => value,
|
||||
@@ -161,6 +172,21 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
} else {
|
||||
created
|
||||
};
|
||||
let created = if feature_settings.is_some() {
|
||||
match state
|
||||
.set_user_api_key_feature_settings(
|
||||
&user_id,
|
||||
&created.api_key_id,
|
||||
feature_settings.clone(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(updated) => updated,
|
||||
None => created,
|
||||
}
|
||||
} else {
|
||||
created
|
||||
};
|
||||
|
||||
Ok(attach_audit_response(
|
||||
Json(json!({
|
||||
@@ -173,6 +199,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
|
||||
"last_used_at": format_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
|
||||
"created_at": format_optional_unix_secs_iso8601(created.created_at_unix_secs),
|
||||
"feature_settings": created.feature_settings,
|
||||
"message": "API Key创建成功,请妥善保存完整密钥",
|
||||
}))
|
||||
.into_response(),
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(crate) async fn build_admin_list_user_api_keys_response(
|
||||
"total_cost_usd": record.total_cost_usd,
|
||||
"rate_limit": record.rate_limit,
|
||||
"concurrent_limit": record.concurrent_limit,
|
||||
"feature_settings": record.feature_settings,
|
||||
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
|
||||
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::super::super::{
|
||||
build_admin_users_bad_request_response, build_admin_users_read_only_response,
|
||||
AdminUpdateUserApiKeyRequest,
|
||||
normalize_admin_feature_settings, AdminUpdateUserApiKeyRequest,
|
||||
};
|
||||
use super::super::helpers::{
|
||||
attach_audit_response, build_admin_user_api_key_detail_payload,
|
||||
@@ -52,6 +52,20 @@ pub(crate) async fn build_admin_update_user_api_key_response(
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
let feature_settings = if let Some(feature_settings) = payload.feature_settings {
|
||||
match normalize_admin_feature_settings(feature_settings) {
|
||||
Ok(value) => Some(value),
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let name = match normalize_admin_optional_api_key_name(payload.name) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
@@ -83,7 +97,7 @@ pub(crate) async fn build_admin_update_user_api_key_response(
|
||||
|
||||
let Some(updated) = state
|
||||
.update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord {
|
||||
user_id,
|
||||
user_id: user_id.clone(),
|
||||
api_key_id: api_key_id.clone(),
|
||||
name,
|
||||
rate_limit: payload.rate_limit,
|
||||
@@ -97,6 +111,14 @@ pub(crate) async fn build_admin_update_user_api_key_response(
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
let updated = if let Some(feature_settings) = feature_settings {
|
||||
state
|
||||
.set_user_api_key_feature_settings(&user_id, &api_key_id, feature_settings)
|
||||
.await?
|
||||
.unwrap_or(updated)
|
||||
} else {
|
||||
updated
|
||||
};
|
||||
|
||||
let is_locked = state
|
||||
.list_auth_api_key_snapshots_by_ids(std::slice::from_ref(&api_key_id))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::super::{
|
||||
admin_default_user_initial_gift, build_admin_users_read_only_response,
|
||||
disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_optional_user_email,
|
||||
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
|
||||
validate_admin_user_password, AdminCreateUserRequest,
|
||||
disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_feature_settings,
|
||||
normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role,
|
||||
normalize_admin_username, validate_admin_user_password, AdminCreateUserRequest,
|
||||
};
|
||||
use super::support::{admin_user_password_policy, build_admin_user_payload_with_groups};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
@@ -66,6 +66,16 @@ pub(in super::super) async fn build_admin_create_user_response(
|
||||
.into_response())
|
||||
}
|
||||
};
|
||||
let feature_settings = match normalize_admin_feature_settings(payload.feature_settings) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
};
|
||||
|
||||
let email = match normalize_admin_optional_user_email(payload.email.as_deref()) {
|
||||
Ok(value) => value,
|
||||
@@ -210,16 +220,21 @@ pub(in super::super) async fn build_admin_create_user_response(
|
||||
.replace_user_groups_for_user(&user.id, &group_ids)
|
||||
.await?;
|
||||
}
|
||||
let feature_settings = if feature_settings.is_some() {
|
||||
state
|
||||
.update_user_feature_settings(&user.id, feature_settings.clone())
|
||||
.await?
|
||||
.or(feature_settings)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut payload =
|
||||
build_admin_user_payload_with_groups(&user, None, None, payload.unlimited, &groups);
|
||||
payload["feature_settings"] = feature_settings.unwrap_or(Value::Null);
|
||||
|
||||
Ok(attach_admin_audit_response(
|
||||
Json(build_admin_user_payload_with_groups(
|
||||
&user,
|
||||
None,
|
||||
None,
|
||||
payload.unlimited,
|
||||
&groups,
|
||||
))
|
||||
.into_response(),
|
||||
Json(payload).into_response(),
|
||||
"admin_user_created",
|
||||
"create_user",
|
||||
"user",
|
||||
|
||||
@@ -144,12 +144,16 @@ pub(in super::super) async fn build_admin_get_user_response(
|
||||
let unlimited = wallet
|
||||
.as_ref()
|
||||
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
|
||||
Ok(Json(build_admin_user_payload_with_groups(
|
||||
let mut payload = build_admin_user_payload_with_groups(
|
||||
&user,
|
||||
export_row.as_ref().and_then(|row| row.rate_limit),
|
||||
export_row.as_ref().map(|row| row.rate_limit_mode.as_str()),
|
||||
unlimited,
|
||||
&groups,
|
||||
))
|
||||
.into_response())
|
||||
);
|
||||
payload["feature_settings"] = export_row
|
||||
.as_ref()
|
||||
.and_then(|row| row.feature_settings.clone())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
Ok(Json(payload).into_response())
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ pub(super) fn build_admin_user_export_payload(
|
||||
"allowed_models_mode": row.allowed_models_mode,
|
||||
"rate_limit": row.rate_limit,
|
||||
"rate_limit_mode": row.rate_limit_mode,
|
||||
"feature_settings": row.feature_settings,
|
||||
"unlimited": unlimited,
|
||||
"is_active": row.is_active,
|
||||
"created_at": format_optional_datetime_iso8601(created_at),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::super::{
|
||||
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
|
||||
build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field,
|
||||
normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role,
|
||||
normalize_admin_username, validate_admin_user_password, AdminUpdateUserPatch,
|
||||
normalize_admin_feature_settings, normalize_admin_optional_user_email,
|
||||
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
|
||||
validate_admin_user_password, AdminUpdateUserPatch,
|
||||
};
|
||||
use super::support::{
|
||||
admin_user_id_from_detail_path, admin_user_password_policy,
|
||||
@@ -17,7 +18,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(in super::super) async fn build_admin_update_user_response(
|
||||
state: &AdminAppState<'_>,
|
||||
@@ -69,6 +70,20 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
}
|
||||
};
|
||||
let (field_presence, payload) = patch.into_parts();
|
||||
let feature_settings = if field_presence.contains("feature_settings") {
|
||||
match normalize_admin_feature_settings(payload.feature_settings.flatten()) {
|
||||
Ok(value) => Some(value),
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let email = match payload.email.as_deref() {
|
||||
Some(value) => match normalize_admin_optional_user_email(Some(value)) {
|
||||
@@ -175,7 +190,8 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
|| payload.password.is_some()
|
||||
|| role.is_some()
|
||||
|| payload.is_active.is_some()
|
||||
|| group_ids.is_some();
|
||||
|| group_ids.is_some()
|
||||
|| feature_settings.is_some();
|
||||
if needs_auth_user_write && !state.has_auth_user_write_capability() {
|
||||
return Ok(build_admin_users_read_only_response(
|
||||
"当前为只读模式,无法更新用户",
|
||||
@@ -293,6 +309,11 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(feature_settings) = feature_settings {
|
||||
state
|
||||
.update_user_feature_settings(&user_id, feature_settings)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let Some(user) = state.find_user_auth_by_id(&user_id).await? else {
|
||||
return Ok((
|
||||
@@ -313,15 +334,20 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
let groups = state.list_user_groups_for_user(&user_id).await?;
|
||||
let rate_limit = export_row.as_ref().and_then(|row| row.rate_limit);
|
||||
|
||||
let mut payload = build_admin_user_payload_with_groups(
|
||||
&user,
|
||||
rate_limit,
|
||||
export_row.as_ref().map(|row| row.rate_limit_mode.as_str()),
|
||||
unlimited,
|
||||
&groups,
|
||||
);
|
||||
payload["feature_settings"] = export_row
|
||||
.as_ref()
|
||||
.and_then(|row| row.feature_settings.clone())
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
Ok(attach_admin_audit_response(
|
||||
Json(build_admin_user_payload_with_groups(
|
||||
&user,
|
||||
rate_limit,
|
||||
export_row.as_ref().map(|row| row.rate_limit_mode.as_str()),
|
||||
unlimited,
|
||||
&groups,
|
||||
))
|
||||
.into_response(),
|
||||
Json(payload).into_response(),
|
||||
"admin_user_updated",
|
||||
"update_user",
|
||||
"user",
|
||||
|
||||
@@ -53,6 +53,7 @@ pub(crate) use self::shared::{
|
||||
normalize_admin_list_policy_mode, normalize_admin_rate_limit_policy_mode,
|
||||
normalize_admin_user_api_formats, normalize_admin_user_string_list,
|
||||
};
|
||||
pub(crate) use crate::handlers::shared::normalize_feature_settings as normalize_admin_feature_settings;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_users_response(
|
||||
request: AdminRouteRequest<'_>,
|
||||
|
||||
@@ -7,7 +7,7 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use regex::Regex;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(super) struct AdminCreateUserApiKeyRequest {
|
||||
@@ -35,6 +35,8 @@ pub(super) struct AdminCreateUserApiKeyRequest {
|
||||
pub(super) is_standalone: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(super) auto_delete_on_expiry: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(super) feature_settings: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -45,6 +47,8 @@ pub(super) struct AdminUpdateUserApiKeyRequest {
|
||||
pub(super) rate_limit: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub(super) concurrent_limit: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub(super) feature_settings: Option<Option<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -67,6 +71,8 @@ pub(super) struct AdminCreateUserRequest {
|
||||
pub(super) unlimited: bool,
|
||||
#[serde(default)]
|
||||
pub(super) group_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(super) feature_settings: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -85,6 +91,8 @@ pub(super) struct AdminUpdateUserRequest {
|
||||
pub(super) group_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(super) is_active: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(super) feature_settings: Option<Option<Value>>,
|
||||
}
|
||||
|
||||
pub(super) type AdminUpdateUserPatch = AdminTypedObjectPatch<AdminUpdateUserRequest>;
|
||||
|
||||
@@ -174,6 +174,7 @@ pub(crate) fn build_auth_wallet_summary_payload(
|
||||
fn build_auth_me_payload(
|
||||
user: &aether_data::repository::users::StoredUserAuthRecord,
|
||||
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> serde_json::Value {
|
||||
let billing = build_auth_wallet_summary_payload(wallet);
|
||||
let has_password = user
|
||||
@@ -194,6 +195,7 @@ fn build_auth_me_payload(
|
||||
"last_login_at": user.last_login_at.map(|value| value.to_rfc3339()),
|
||||
"auth_source": user.auth_source,
|
||||
"has_password": has_password,
|
||||
"feature_settings": feature_settings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -336,9 +338,19 @@ pub(crate) async fn handle_auth_me(
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let feature_settings = match state.read_user_feature_settings(&auth.user.id).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("user feature settings lookup failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
build_auth_json_response(
|
||||
http::StatusCode::OK,
|
||||
build_auth_me_payload(&auth.user, wallet.as_ref()),
|
||||
build_auth_me_payload(&auth.user, wallet.as_ref(), feature_settings),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::handlers::shared::{
|
||||
api_key_placeholder_display, generate_gateway_api_key_plaintext,
|
||||
masked_gateway_api_key_display, normalize_optional_api_key_concurrent_limit,
|
||||
api_key_placeholder_display, deserialize_optional_json_patch,
|
||||
generate_gateway_api_key_plaintext, masked_gateway_api_key_display, normalize_feature_settings,
|
||||
normalize_optional_api_key_concurrent_limit,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -31,6 +32,8 @@ struct UsersMeCreateApiKeyRequest {
|
||||
rate_limit: Option<i32>,
|
||||
#[serde(default)]
|
||||
concurrent_limit: Option<i32>,
|
||||
#[serde(default)]
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -41,6 +44,8 @@ struct UsersMeUpdateApiKeyRequest {
|
||||
rate_limit: Option<i32>,
|
||||
#[serde(default)]
|
||||
concurrent_limit: Option<i32>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_json_patch")]
|
||||
feature_settings: Option<Option<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -156,6 +161,7 @@ fn build_users_me_api_key_list_payload(
|
||||
"concurrent_limit": record.concurrent_limit,
|
||||
"allowed_providers": record.allowed_providers,
|
||||
"force_capabilities": record.force_capabilities,
|
||||
"feature_settings": record.feature_settings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,6 +178,7 @@ fn build_users_me_api_key_detail_payload(
|
||||
"is_locked": is_locked,
|
||||
"allowed_providers": record.allowed_providers,
|
||||
"force_capabilities": record.force_capabilities,
|
||||
"feature_settings": record.feature_settings,
|
||||
"rate_limit": record.rate_limit,
|
||||
"concurrent_limit": record.concurrent_limit,
|
||||
"last_used_at": format_users_me_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
@@ -529,6 +536,12 @@ pub(super) async fn handle_users_me_api_key_create(
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
}
|
||||
};
|
||||
let feature_settings = match normalize_feature_settings(payload.feature_settings) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
}
|
||||
};
|
||||
|
||||
let plaintext_key = generate_users_me_api_key_plaintext();
|
||||
let Some(key_encrypted) = encrypt_catalog_secret_with_fallbacks(state, &plaintext_key) else {
|
||||
@@ -569,6 +582,28 @@ pub(super) async fn handle_users_me_api_key_create(
|
||||
}) else {
|
||||
return build_users_me_api_key_writer_unavailable_response();
|
||||
};
|
||||
let created = if feature_settings.is_some() {
|
||||
match state
|
||||
.set_user_api_key_feature_settings(
|
||||
&auth.user.id,
|
||||
&created.api_key_id,
|
||||
feature_settings.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => return build_users_me_api_key_writer_unavailable_response(),
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("user api key feature settings update failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
created
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"id": created.api_key_id,
|
||||
@@ -579,6 +614,7 @@ pub(super) async fn handle_users_me_api_key_create(
|
||||
"is_locked": false,
|
||||
"rate_limit": created.rate_limit,
|
||||
"concurrent_limit": created.concurrent_limit,
|
||||
"feature_settings": created.feature_settings,
|
||||
"last_used_at": format_users_me_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
|
||||
"created_at": format_users_me_optional_unix_secs_iso8601(created.created_at_unix_secs),
|
||||
"total_requests": created.total_requests,
|
||||
@@ -650,6 +686,15 @@ pub(super) async fn handle_users_me_api_key_update(
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
}
|
||||
};
|
||||
let feature_settings = match payload.feature_settings {
|
||||
Some(value) => match normalize_feature_settings(value) {
|
||||
Ok(value) => Some(value),
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let Some(updated) = (match state
|
||||
.update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord {
|
||||
@@ -672,6 +717,28 @@ pub(super) async fn handle_users_me_api_key_update(
|
||||
}) else {
|
||||
return build_users_me_api_key_writer_unavailable_response();
|
||||
};
|
||||
let updated = if let Some(feature_settings) = feature_settings {
|
||||
match state
|
||||
.set_user_api_key_feature_settings(
|
||||
&auth.user.id,
|
||||
&snapshot.api_key_id,
|
||||
feature_settings,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => return build_users_me_api_key_writer_unavailable_response(),
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("user api key feature settings update failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updated
|
||||
};
|
||||
|
||||
let mut payload =
|
||||
build_users_me_api_key_detail_payload(state, &updated, snapshot.api_key_is_locked);
|
||||
|
||||
@@ -7,6 +7,8 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::handlers::shared::{deserialize_optional_json_patch, normalize_feature_settings};
|
||||
|
||||
use super::{
|
||||
auth_password_policy_level, build_auth_error_response, resolve_authenticated_local_user,
|
||||
validate_auth_register_password, AppState, GatewayPublicRequestContext,
|
||||
@@ -21,6 +23,8 @@ struct UsersMeUpdateProfileRequest {
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_json_patch")]
|
||||
feature_settings: Option<Option<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -60,6 +64,15 @@ pub(super) async fn handle_users_me_detail_put(
|
||||
|
||||
let email = normalize_users_me_optional_non_empty_string(payload.email);
|
||||
let username = normalize_users_me_optional_non_empty_string(payload.username);
|
||||
let feature_settings = match payload.feature_settings {
|
||||
Some(value) => match normalize_feature_settings(value) {
|
||||
Ok(value) => Some(value),
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
if let Some(email) = email.as_deref() {
|
||||
match state
|
||||
@@ -111,7 +124,24 @@ pub(super) async fn handle_users_me_detail_put(
|
||||
.update_local_auth_user_profile(&auth.user.id, email, username)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => Json(json!({ "message": "个人信息更新成功" })).into_response(),
|
||||
Ok(Some(_)) => {
|
||||
if let Some(feature_settings) = feature_settings {
|
||||
match state
|
||||
.update_user_feature_settings(&auth.user.id, feature_settings)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("user feature settings update failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Json(json!({ "message": "个人信息更新成功" })).into_response()
|
||||
}
|
||||
Ok(None) => build_auth_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
USERS_ME_PROFILE_STORAGE_UNAVAILABLE_DETAIL,
|
||||
|
||||
@@ -34,7 +34,8 @@ pub(crate) use self::email_templates::{
|
||||
};
|
||||
pub(crate) use self::external_models::OFFICIAL_EXTERNAL_MODEL_PROVIDERS;
|
||||
pub(crate) use self::normalize::{
|
||||
normalize_json_array, normalize_json_object, normalize_string_list,
|
||||
deserialize_optional_json_patch, normalize_feature_settings, normalize_json_array,
|
||||
normalize_json_object, normalize_string_list,
|
||||
};
|
||||
pub(crate) use self::payloads::{
|
||||
InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(crate) fn normalize_string_list(values: Option<Vec<String>>) -> Option<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
@@ -42,3 +44,65 @@ pub(crate) fn normalize_json_array(
|
||||
_ => Err(format!("{field_name} 必须是 JSON 数组")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_feature_settings(value: Option<Value>) -> Result<Option<Value>, String> {
|
||||
let Some(mut value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::Object(ref mut settings) => {
|
||||
normalize_chat_pii_redaction_feature_settings(settings)?;
|
||||
if settings.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(value))
|
||||
}
|
||||
}
|
||||
_ => Err("feature_settings 必须是对象".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn deserialize_optional_json_patch<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<Value>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
<Option<Value> as serde::Deserialize>::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
fn normalize_chat_pii_redaction_feature_settings(
|
||||
settings: &mut Map<String, Value>,
|
||||
) -> Result<(), String> {
|
||||
let Some(value) = settings.get_mut("chat_pii_redaction") else {
|
||||
return Ok(());
|
||||
};
|
||||
match value {
|
||||
Value::Null => {
|
||||
settings.remove("chat_pii_redaction");
|
||||
Ok(())
|
||||
}
|
||||
Value::Object(feature) => {
|
||||
normalize_chat_pii_redaction_feature_object(feature)?;
|
||||
if feature.is_empty() {
|
||||
settings.remove("chat_pii_redaction");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("chat_pii_redaction 必须是对象".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_chat_pii_redaction_feature_object(
|
||||
feature: &mut Map<String, Value>,
|
||||
) -> Result<(), String> {
|
||||
for key in ["enabled", "inject_model_instruction"] {
|
||||
if let Some(value) = feature.get(key) {
|
||||
if !value.is_boolean() {
|
||||
return Err(format!("chat_pii_redaction.{key} 必须是布尔值"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,18 @@ impl AppState {
|
||||
.and_then(|record| record.force_capabilities))
|
||||
}
|
||||
|
||||
pub(crate) async fn read_auth_api_key_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
is_standalone: bool,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
self.data
|
||||
.read_auth_api_key_feature_settings(user_id, api_key_id, is_standalone)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_auth_api_key_export_records_by_user_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
@@ -329,6 +341,41 @@ impl AppState {
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
let api_key = self
|
||||
.data
|
||||
.set_user_api_key_feature_settings(user_id, api_key_id, feature_settings)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if api_key.is_some() {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_standalone_api_key_feature_settings(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
let api_key = self
|
||||
.data
|
||||
.set_standalone_api_key_feature_settings(api_key_id, feature_settings)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if api_key.is_some() {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_user_api_key(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -52,6 +52,32 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn read_user_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
self.data
|
||||
.read_user_feature_settings(user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_user_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
let updated = self
|
||||
.data
|
||||
.update_user_feature_settings(user_id, settings)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if updated.is_some() {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(crate) async fn find_active_provider_name(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
|
||||
@@ -58,6 +58,67 @@ fn auth_snapshot() -> StoredAuthApiKeySnapshot {
|
||||
.expect("auth snapshot should build")
|
||||
}
|
||||
|
||||
fn auth_export_record(
|
||||
snapshot: &StoredAuthApiKeySnapshot,
|
||||
key_hash: String,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> aether_data::repository::auth::StoredAuthApiKeyExportRecord {
|
||||
aether_data::repository::auth::StoredAuthApiKeyExportRecord::new(
|
||||
snapshot.user_id.clone(),
|
||||
snapshot.api_key_id.clone(),
|
||||
key_hash,
|
||||
None,
|
||||
snapshot.api_key_name.clone(),
|
||||
snapshot
|
||||
.api_key_allowed_providers
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
snapshot
|
||||
.api_key_allowed_api_formats
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
snapshot
|
||||
.api_key_allowed_models
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
snapshot.api_key_rate_limit,
|
||||
snapshot.api_key_concurrent_limit,
|
||||
None,
|
||||
snapshot.api_key_is_active,
|
||||
snapshot
|
||||
.api_key_expires_at_unix_secs
|
||||
.map(|value| value as i64),
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
0.0,
|
||||
snapshot.api_key_is_standalone,
|
||||
)
|
||||
.expect("auth api key export record should build")
|
||||
.with_feature_settings(feature_settings)
|
||||
}
|
||||
|
||||
fn auth_repository_with_redaction_feature_settings() -> Arc<InMemoryAuthApiKeySnapshotRepository> {
|
||||
let snapshot = auth_snapshot();
|
||||
let key_hash = hash_api_key("sk-client-ai-execute-stream-pii-redaction");
|
||||
Arc::new(
|
||||
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(key_hash.clone()),
|
||||
snapshot.clone(),
|
||||
)])
|
||||
.with_export_records(vec![auth_export_record(
|
||||
&snapshot,
|
||||
key_hash,
|
||||
Some(json!({
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true,
|
||||
"inject_model_instruction": true,
|
||||
}
|
||||
})),
|
||||
)]),
|
||||
)
|
||||
}
|
||||
|
||||
fn candidate_row() -> StoredMinimalCandidateSelectionRow {
|
||||
StoredMinimalCandidateSelectionRow {
|
||||
provider_id: "provider-ai-execute-stream-pii-redaction".to_string(),
|
||||
@@ -233,10 +294,7 @@ async fn ai_execute_stream_pii_redaction_round_trip() {
|
||||
}),
|
||||
);
|
||||
let (provider_url, provider_handle) = start_server(provider_app).await;
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key("sk-client-ai-execute-stream-pii-redaction")),
|
||||
auth_snapshot(),
|
||||
)]));
|
||||
let auth_repository = auth_repository_with_redaction_feature_settings();
|
||||
let candidate_selection_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
candidate_row(),
|
||||
@@ -257,21 +315,20 @@ async fn ai_execute_stream_pii_redaction_round_trip() {
|
||||
.with_system_config_values_for_tests(vec![
|
||||
("module.chat_pii_redaction.enabled".to_string(), json!(true)),
|
||||
(
|
||||
"module.chat_pii_redaction.provider_scope".to_string(),
|
||||
json!("selected_providers"),
|
||||
),
|
||||
(
|
||||
"module.chat_pii_redaction.entities".to_string(),
|
||||
json!(["email"]),
|
||||
"module.chat_pii_redaction.rules".to_string(),
|
||||
json!([{
|
||||
"id": "email",
|
||||
"name": "邮箱",
|
||||
"pattern": r"(?i)[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}",
|
||||
"enabled": true,
|
||||
"features": {"validator": "email"},
|
||||
"system": true
|
||||
}]),
|
||||
),
|
||||
(
|
||||
"module.chat_pii_redaction.cache_ttl_seconds".to_string(),
|
||||
json!(300),
|
||||
),
|
||||
(
|
||||
"module.chat_pii_redaction.inject_model_instruction".to_string(),
|
||||
json!(true),
|
||||
),
|
||||
]);
|
||||
let gateway_state = AppState::new()
|
||||
.expect("gateway state should build")
|
||||
|
||||
@@ -52,6 +52,68 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore
|
||||
.expect("auth snapshot should build")
|
||||
}
|
||||
|
||||
fn auth_export_record(
|
||||
snapshot: &StoredAuthApiKeySnapshot,
|
||||
key_hash: String,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> aether_data::repository::auth::StoredAuthApiKeyExportRecord {
|
||||
aether_data::repository::auth::StoredAuthApiKeyExportRecord::new(
|
||||
snapshot.user_id.clone(),
|
||||
snapshot.api_key_id.clone(),
|
||||
key_hash,
|
||||
None,
|
||||
snapshot.api_key_name.clone(),
|
||||
snapshot
|
||||
.api_key_allowed_providers
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
snapshot
|
||||
.api_key_allowed_api_formats
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
snapshot
|
||||
.api_key_allowed_models
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
snapshot.api_key_rate_limit,
|
||||
snapshot.api_key_concurrent_limit,
|
||||
None,
|
||||
snapshot.api_key_is_active,
|
||||
snapshot
|
||||
.api_key_expires_at_unix_secs
|
||||
.map(|value| value as i64),
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
0.0,
|
||||
snapshot.api_key_is_standalone,
|
||||
)
|
||||
.expect("auth api key export record should build")
|
||||
.with_feature_settings(feature_settings)
|
||||
}
|
||||
|
||||
fn auth_repository_with_redaction_feature_settings() -> Arc<InMemoryAuthApiKeySnapshotRepository>
|
||||
{
|
||||
let snapshot = auth_snapshot();
|
||||
let key_hash = hash_api_key("sk-client-redaction");
|
||||
Arc::new(
|
||||
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(key_hash.clone()),
|
||||
snapshot.clone(),
|
||||
)])
|
||||
.with_export_records(vec![auth_export_record(
|
||||
&snapshot,
|
||||
key_hash,
|
||||
Some(json!({
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true,
|
||||
"inject_model_instruction": true,
|
||||
}
|
||||
})),
|
||||
)]),
|
||||
)
|
||||
}
|
||||
|
||||
fn candidate_row() -> StoredMinimalCandidateSelectionRow {
|
||||
StoredMinimalCandidateSelectionRow {
|
||||
provider_id: "provider-redaction-1".to_string(),
|
||||
@@ -212,10 +274,7 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore
|
||||
}),
|
||||
);
|
||||
let (provider_url, provider_handle) = start_server(provider_app).await;
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key("sk-client-redaction")),
|
||||
auth_snapshot(),
|
||||
)]));
|
||||
let auth_repository = auth_repository_with_redaction_feature_settings();
|
||||
let candidate_selection_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
candidate_row(),
|
||||
@@ -236,21 +295,20 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore
|
||||
.with_system_config_values_for_tests(vec![
|
||||
("module.chat_pii_redaction.enabled".to_string(), json!(true)),
|
||||
(
|
||||
"module.chat_pii_redaction.provider_scope".to_string(),
|
||||
json!("selected_providers"),
|
||||
),
|
||||
(
|
||||
"module.chat_pii_redaction.entities".to_string(),
|
||||
json!(["email"]),
|
||||
"module.chat_pii_redaction.rules".to_string(),
|
||||
json!([{
|
||||
"id": "email",
|
||||
"name": "邮箱",
|
||||
"pattern": r"(?i)[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}",
|
||||
"enabled": true,
|
||||
"features": {"validator": "email"},
|
||||
"system": true
|
||||
}]),
|
||||
),
|
||||
(
|
||||
"module.chat_pii_redaction.cache_ttl_seconds".to_string(),
|
||||
json!(300),
|
||||
),
|
||||
(
|
||||
"module.chat_pii_redaction.inject_model_instruction".to_string(),
|
||||
json!(true),
|
||||
),
|
||||
]);
|
||||
let gateway_state = AppState::new()
|
||||
.expect("gateway state should build")
|
||||
|
||||
@@ -41,6 +41,46 @@ fn auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
|
||||
.expect("auth snapshot should build")
|
||||
}
|
||||
|
||||
fn auth_export_record(
|
||||
snapshot: &StoredAuthApiKeySnapshot,
|
||||
key_hash: String,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> aether_data::repository::auth::StoredAuthApiKeyExportRecord {
|
||||
aether_data::repository::auth::StoredAuthApiKeyExportRecord::new(
|
||||
snapshot.user_id.clone(),
|
||||
snapshot.api_key_id.clone(),
|
||||
key_hash,
|
||||
None,
|
||||
snapshot.api_key_name.clone(),
|
||||
snapshot
|
||||
.api_key_allowed_providers
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
snapshot
|
||||
.api_key_allowed_api_formats
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
snapshot
|
||||
.api_key_allowed_models
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
snapshot.api_key_rate_limit,
|
||||
snapshot.api_key_concurrent_limit,
|
||||
None,
|
||||
snapshot.api_key_is_active,
|
||||
snapshot
|
||||
.api_key_expires_at_unix_secs
|
||||
.map(|value| value as i64),
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
0.0,
|
||||
snapshot.api_key_is_standalone,
|
||||
)
|
||||
.expect("auth api key export record should build")
|
||||
.with_feature_settings(feature_settings)
|
||||
}
|
||||
|
||||
fn candidate_row(test_id: &str) -> StoredMinimalCandidateSelectionRow {
|
||||
StoredMinimalCandidateSelectionRow {
|
||||
provider_id: format!("provider-{test_id}"),
|
||||
@@ -141,37 +181,90 @@ fn key(test_id: &str) -> StoredProviderCatalogKey {
|
||||
}
|
||||
|
||||
fn redaction_config(module_enabled: bool) -> Vec<(String, serde_json::Value)> {
|
||||
redaction_config_with_entities(
|
||||
module_enabled,
|
||||
json!(["email", "access_token", "secret_key"]),
|
||||
)
|
||||
redaction_config_with_rules(module_enabled, redaction_test_rules())
|
||||
}
|
||||
|
||||
fn redaction_config_with_entities(
|
||||
fn redaction_config_with_rules(
|
||||
module_enabled: bool,
|
||||
entities: serde_json::Value,
|
||||
rules: serde_json::Value,
|
||||
) -> Vec<(String, serde_json::Value)> {
|
||||
vec![
|
||||
(
|
||||
"module.chat_pii_redaction.enabled".to_string(),
|
||||
json!(module_enabled),
|
||||
),
|
||||
(
|
||||
"module.chat_pii_redaction.provider_scope".to_string(),
|
||||
json!("selected_providers"),
|
||||
),
|
||||
("module.chat_pii_redaction.entities".to_string(), entities),
|
||||
("module.chat_pii_redaction.rules".to_string(), rules),
|
||||
(
|
||||
"module.chat_pii_redaction.cache_ttl_seconds".to_string(),
|
||||
json!(300),
|
||||
),
|
||||
(
|
||||
"module.chat_pii_redaction.inject_model_instruction".to_string(),
|
||||
json!(true),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn redaction_test_rules() -> serde_json::Value {
|
||||
json!([
|
||||
{
|
||||
"id": "email",
|
||||
"name": "邮箱",
|
||||
"pattern": r"(?i)[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}",
|
||||
"enabled": true,
|
||||
"features": {"validator": "email"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "access_token",
|
||||
"name": "Access Token",
|
||||
"pattern": r#"(?i)\baccess[_-]?token\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{20,}"#,
|
||||
"enabled": true,
|
||||
"features": {"validator": "access_token"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "secret_key",
|
||||
"name": "Secret Key",
|
||||
"pattern": r#"(?i)\bsecret[_-]?key\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{20,}"#,
|
||||
"enabled": true,
|
||||
"features": {"validator": "secret_key"},
|
||||
"system": true
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
fn chat_pii_redaction_feature_settings(
|
||||
enabled: bool,
|
||||
inject_model_instruction: bool,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"chat_pii_redaction": {
|
||||
"enabled": enabled,
|
||||
"inject_model_instruction": inject_model_instruction,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_repository_with_redaction_feature_settings(
|
||||
test_id: &str,
|
||||
feature_enabled: bool,
|
||||
inject_model_instruction: bool,
|
||||
) -> Arc<InMemoryAuthApiKeySnapshotRepository> {
|
||||
let snapshot = auth_snapshot(&format!("api-key-{test_id}"), &format!("user-{test_id}"));
|
||||
let key_hash = hash_api_key(&format!("sk-client-{test_id}"));
|
||||
Arc::new(
|
||||
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(key_hash.clone()),
|
||||
snapshot.clone(),
|
||||
)])
|
||||
.with_export_records(vec![auth_export_record(
|
||||
&snapshot,
|
||||
key_hash,
|
||||
Some(chat_pii_redaction_feature_settings(
|
||||
feature_enabled,
|
||||
inject_model_instruction,
|
||||
)),
|
||||
)]),
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_sentinels(text: &str, kind: &str) -> Vec<String> {
|
||||
let prefix = format!("<AETHER:{kind}:");
|
||||
let mut sentinels = Vec::new();
|
||||
@@ -191,13 +284,13 @@ fn collect_sentinels(text: &str, kind: &str) -> Vec<String> {
|
||||
async fn run_sync_redaction_case(
|
||||
test_id: &str,
|
||||
module_enabled: bool,
|
||||
provider_enabled: bool,
|
||||
feature_enabled: bool,
|
||||
provider_response: &'static str,
|
||||
request_body: serde_json::Value,
|
||||
) -> (serde_json::Value, SeenProviderRequest) {
|
||||
run_sync_redaction_case_with_system_config(
|
||||
test_id,
|
||||
provider_enabled,
|
||||
feature_enabled,
|
||||
provider_response,
|
||||
request_body,
|
||||
redaction_config(module_enabled),
|
||||
@@ -207,7 +300,7 @@ async fn run_sync_redaction_case(
|
||||
|
||||
async fn run_sync_redaction_case_with_system_config(
|
||||
test_id: &str,
|
||||
provider_enabled: bool,
|
||||
feature_enabled: bool,
|
||||
provider_response: &'static str,
|
||||
request_body: serde_json::Value,
|
||||
system_config: Vec<(String, serde_json::Value)>,
|
||||
@@ -279,17 +372,15 @@ async fn run_sync_redaction_case_with_system_config(
|
||||
}),
|
||||
);
|
||||
let (provider_url, provider_handle) = start_server(provider_app).await;
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key(&format!("sk-client-{test_id}"))),
|
||||
auth_snapshot(&format!("api-key-{test_id}"), &format!("user-{test_id}")),
|
||||
)]));
|
||||
let auth_repository =
|
||||
auth_repository_with_redaction_feature_settings(test_id, feature_enabled, true);
|
||||
let candidate_selection_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
candidate_row(test_id),
|
||||
]));
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider(test_id, provider_enabled)],
|
||||
vec![provider(test_id, true)],
|
||||
vec![endpoint(test_id, provider_url)],
|
||||
vec![key(test_id)],
|
||||
));
|
||||
@@ -469,7 +560,7 @@ async fn ai_execute_pii_redaction_disabled_module_passes_original_chat_through()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_execute_pii_redaction_disabled_provider_passes_original_chat_through() {
|
||||
async fn ai_execute_pii_redaction_disabled_feature_passes_original_chat_through() {
|
||||
let (response_json, seen) = run_sync_redaction_case(
|
||||
"ai-execute-pii-redaction-disabled-provider",
|
||||
true,
|
||||
@@ -492,13 +583,13 @@ async fn ai_execute_pii_redaction_disabled_provider_passes_original_chat_through
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_execute_pii_redaction_empty_entities_passes_original_chat_through() {
|
||||
async fn ai_execute_pii_redaction_empty_rules_passes_original_chat_through() {
|
||||
let (response_json, seen) = run_sync_redaction_case_with_system_config(
|
||||
"ai-execute-pii-redaction-empty-entities",
|
||||
true,
|
||||
"pass_through",
|
||||
rich_pii_request(),
|
||||
redaction_config_with_entities(true, json!([])),
|
||||
redaction_config_with_rules(true, json!([])),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -586,13 +677,8 @@ async fn ai_execute_pii_redaction_restores_executed_candidate_session_after_late
|
||||
}),
|
||||
);
|
||||
let (provider_url, provider_handle) = start_server(provider_app).await;
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key("sk-client-redaction-candidate-session")),
|
||||
auth_snapshot(
|
||||
"api-key-redaction-candidate-session",
|
||||
"user-redaction-candidate-session",
|
||||
),
|
||||
)]));
|
||||
let auth_repository =
|
||||
auth_repository_with_redaction_feature_settings("redaction-candidate-session", true, true);
|
||||
let mut later_candidate = candidate_row("redaction-candidate-session");
|
||||
later_candidate.provider_id = "provider-redaction-candidate-session-later".to_string();
|
||||
later_candidate.endpoint_id = "endpoint-redaction-candidate-session-later".to_string();
|
||||
@@ -702,10 +788,8 @@ async fn pii_redaction_performance_limits_do_not_forward_unredacted_body_upstrea
|
||||
}),
|
||||
);
|
||||
let (provider_url, provider_handle) = start_server(provider_app).await;
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key("sk-client-pii-redaction-limit")),
|
||||
auth_snapshot("api-key-pii-redaction-limit", "user-pii-redaction-limit"),
|
||||
)]));
|
||||
let auth_repository =
|
||||
auth_repository_with_redaction_feature_settings("pii-redaction-limit", true, true);
|
||||
let candidate_selection_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
candidate_row("pii-redaction-limit"),
|
||||
@@ -777,10 +861,7 @@ async fn ai_execute_pii_redaction_missing_encryption_key_fails_closed_before_pro
|
||||
);
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let test_id = "ai-execute-pii-redaction-missing-encryption-key";
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key(&format!("sk-client-{test_id}"))),
|
||||
auth_snapshot(&format!("api-key-{test_id}"), &format!("user-{test_id}")),
|
||||
)]));
|
||||
let auth_repository = auth_repository_with_redaction_feature_settings(test_id, true, true);
|
||||
let candidate_selection_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
candidate_row(test_id),
|
||||
@@ -791,17 +872,16 @@ async fn ai_execute_pii_redaction_missing_encryption_key_fails_closed_before_pro
|
||||
vec![endpoint(test_id, "https://example.com".to_string())],
|
||||
vec![key(test_id)],
|
||||
));
|
||||
let data_state = crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests(
|
||||
auth_repository,
|
||||
candidate_selection_repository,
|
||||
provider_catalog_repository,
|
||||
Arc::clone(&request_candidate_repository),
|
||||
"",
|
||||
)
|
||||
.with_system_config_values_for_tests(redaction_config(true));
|
||||
let gateway_state = build_state_with_execution_runtime_override(execution_runtime_url.clone())
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests(
|
||||
auth_repository,
|
||||
candidate_selection_repository,
|
||||
provider_catalog_repository,
|
||||
Arc::clone(&request_candidate_repository),
|
||||
"",
|
||||
)
|
||||
.with_system_config_values_for_tests(redaction_config(true)),
|
||||
);
|
||||
.with_data_state_for_tests(data_state);
|
||||
let gateway = build_router_with_state(gateway_state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
|
||||
@@ -785,7 +785,7 @@ async fn gateway_handles_admin_modules_status_locally_with_trusted_admin_princip
|
||||
assert_eq!(payload["chat_pii_redaction"]["enabled"], json!(false));
|
||||
assert_eq!(
|
||||
payload["chat_pii_redaction"]["display_name"],
|
||||
"敏感信息替换保护"
|
||||
"敏感信息保护"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["chat_pii_redaction"]["config_validated"],
|
||||
@@ -967,7 +967,7 @@ async fn gateway_handles_chat_pii_redaction_module_status_detail_locally_with_tr
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["name"], "chat_pii_redaction");
|
||||
assert_eq!(payload["display_name"], "敏感信息替换保护");
|
||||
assert_eq!(payload["display_name"], "敏感信息保护");
|
||||
assert_eq!(payload["enabled"], json!(true));
|
||||
assert_eq!(payload["active"], json!(true));
|
||||
assert_eq!(payload["config_validated"], json!(true));
|
||||
|
||||
@@ -1392,34 +1392,23 @@ async fn gateway_validates_chat_pii_redaction_system_config_locally_with_trusted
|
||||
get_config("module.chat_pii_redaction.enabled").await["value"],
|
||||
json!(false)
|
||||
);
|
||||
assert_eq!(
|
||||
get_config("module.chat_pii_redaction.provider_scope").await["value"],
|
||||
json!("selected_providers")
|
||||
);
|
||||
assert_eq!(
|
||||
get_config("module.chat_pii_redaction.inject_model_instruction").await["value"],
|
||||
json!(true)
|
||||
);
|
||||
assert_eq!(
|
||||
get_config("module.chat_pii_redaction.cache_ttl_seconds").await["value"],
|
||||
json!(300)
|
||||
);
|
||||
assert_eq!(
|
||||
get_config("module.chat_pii_redaction.entities").await["value"],
|
||||
json!([
|
||||
"email",
|
||||
"cn_phone",
|
||||
"global_phone",
|
||||
"cn_id",
|
||||
"payment_card",
|
||||
"ipv4",
|
||||
"ipv6",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"secret_key",
|
||||
"bearer_token",
|
||||
"jwt"
|
||||
])
|
||||
get_config("module.chat_pii_redaction.placeholder_prefix").await["value"],
|
||||
json!("AETHER")
|
||||
);
|
||||
let default_rules_payload = get_config("module.chat_pii_redaction.rules").await;
|
||||
let default_rules = default_rules_payload["value"]
|
||||
.as_array()
|
||||
.expect("default rules should be an array");
|
||||
assert!(
|
||||
default_rules.iter().any(|rule| {
|
||||
rule["name"] == json!("手机号") && rule["features"]["validator"] == json!("cn_phone")
|
||||
}),
|
||||
"default rules should include 手机号"
|
||||
);
|
||||
|
||||
let enabled_response = put_config("module.chat_pii_redaction.enabled", json!(true)).await;
|
||||
@@ -1430,29 +1419,34 @@ async fn gateway_validates_chat_pii_redaction_system_config_locally_with_trusted
|
||||
.expect("json body should parse");
|
||||
assert_eq!(enabled_payload["value"], json!(true));
|
||||
|
||||
let scope_response = put_config(
|
||||
"module.chat_pii_redaction.provider_scope",
|
||||
json!("all_providers"),
|
||||
let rules_response = put_config(
|
||||
"module.chat_pii_redaction.rules",
|
||||
json!([
|
||||
{
|
||||
"id": "email",
|
||||
"name": "邮箱",
|
||||
"pattern": r"(?i)[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}",
|
||||
"enabled": true,
|
||||
"features": {"validator": "email"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "custom_code",
|
||||
"name": "自定义规则",
|
||||
"pattern": r"CODE-\d{6}",
|
||||
"enabled": false,
|
||||
"features": {"validator": "custom_code", "experimental": true},
|
||||
"system": false
|
||||
}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(scope_response.status(), StatusCode::OK);
|
||||
let scope_payload: serde_json::Value =
|
||||
scope_response.json().await.expect("json body should parse");
|
||||
assert_eq!(scope_payload["value"], json!("all_providers"));
|
||||
|
||||
let selected_entities_response = put_config(
|
||||
"module.chat_pii_redaction.entities",
|
||||
json!(["email", "jwt", "cn_phone"]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(selected_entities_response.status(), StatusCode::OK);
|
||||
let selected_entities_payload: serde_json::Value = selected_entities_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(rules_response.status(), StatusCode::OK);
|
||||
let rules_payload: serde_json::Value =
|
||||
rules_response.json().await.expect("json body should parse");
|
||||
assert_eq!(
|
||||
selected_entities_payload["value"],
|
||||
json!(["email", "cn_phone", "jwt"])
|
||||
rules_payload["value"][1]["features"]["experimental"],
|
||||
json!(true)
|
||||
);
|
||||
|
||||
let ttl_response = put_config("module.chat_pii_redaction.cache_ttl_seconds", json!(3600)).await;
|
||||
@@ -1460,45 +1454,40 @@ async fn gateway_validates_chat_pii_redaction_system_config_locally_with_trusted
|
||||
let ttl_payload: serde_json::Value = ttl_response.json().await.expect("json body should parse");
|
||||
assert_eq!(ttl_payload["value"], json!(3600));
|
||||
|
||||
let instruction_response = put_config(
|
||||
"module.chat_pii_redaction.inject_model_instruction",
|
||||
json!(false),
|
||||
let prefix_response = put_config(
|
||||
"module.chat_pii_redaction.placeholder_prefix",
|
||||
json!("vendor_safe"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(instruction_response.status(), StatusCode::OK);
|
||||
let instruction_payload: serde_json::Value = instruction_response
|
||||
assert_eq!(prefix_response.status(), StatusCode::OK);
|
||||
let prefix_payload: serde_json::Value = prefix_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(instruction_payload["value"], json!(false));
|
||||
assert_eq!(prefix_payload["value"], json!("VENDOR_SAFE"));
|
||||
|
||||
let invalid_scope_response = put_config(
|
||||
"module.chat_pii_redaction.provider_scope",
|
||||
json!("enabled_providers"),
|
||||
let invalid_prefix_response = put_config(
|
||||
"module.chat_pii_redaction.placeholder_prefix",
|
||||
json!("bad-prefix"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(invalid_scope_response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(invalid_prefix_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let invalid_entities_response = put_config(
|
||||
"module.chat_pii_redaction.entities",
|
||||
json!(["email", "name"]),
|
||||
let invalid_rules_response = put_config(
|
||||
"module.chat_pii_redaction.rules",
|
||||
json!([
|
||||
{
|
||||
"id": "broken",
|
||||
"name": "坏规则",
|
||||
"pattern": "[",
|
||||
"enabled": true,
|
||||
"features": {"validator": "broken"},
|
||||
"system": false
|
||||
}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(invalid_entities_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let invalid_ttl_response =
|
||||
put_config("module.chat_pii_redaction.cache_ttl_seconds", json!(600)).await;
|
||||
assert_eq!(invalid_ttl_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let invalid_instruction_response = put_config(
|
||||
"module.chat_pii_redaction.inject_model_instruction",
|
||||
json!("yes"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
invalid_instruction_response.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(invalid_rules_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let enabled_default_response =
|
||||
put_config("module.chat_pii_redaction.enabled", serde_json::Value::Null).await;
|
||||
@@ -1509,45 +1498,21 @@ async fn gateway_validates_chat_pii_redaction_system_config_locally_with_trusted
|
||||
.expect("json body should parse");
|
||||
assert_eq!(enabled_default_payload["value"], json!(false));
|
||||
|
||||
let scope_default_response = put_config(
|
||||
"module.chat_pii_redaction.provider_scope",
|
||||
serde_json::Value::Null,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(scope_default_response.status(), StatusCode::OK);
|
||||
let scope_default_payload: serde_json::Value = scope_default_response
|
||||
let rules_default_response =
|
||||
put_config("module.chat_pii_redaction.rules", serde_json::Value::Null).await;
|
||||
assert_eq!(rules_default_response.status(), StatusCode::OK);
|
||||
let rules_default_payload: serde_json::Value = rules_default_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(scope_default_payload["value"], json!("selected_providers"));
|
||||
assert!(!rules_default_payload["value"]
|
||||
.as_array()
|
||||
.expect("default rules should be an array")
|
||||
.is_empty());
|
||||
|
||||
let entities_default_response = put_config(
|
||||
"module.chat_pii_redaction.entities",
|
||||
serde_json::Value::Null,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(entities_default_response.status(), StatusCode::OK);
|
||||
let entities_default_payload: serde_json::Value = entities_default_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(
|
||||
entities_default_payload["value"],
|
||||
json!([
|
||||
"email",
|
||||
"cn_phone",
|
||||
"global_phone",
|
||||
"cn_id",
|
||||
"payment_card",
|
||||
"ipv4",
|
||||
"ipv6",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"secret_key",
|
||||
"bearer_token",
|
||||
"jwt"
|
||||
])
|
||||
);
|
||||
let invalid_ttl_response =
|
||||
put_config("module.chat_pii_redaction.cache_ttl_seconds", json!(600)).await;
|
||||
assert_eq!(invalid_ttl_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let ttl_default_response = put_config(
|
||||
"module.chat_pii_redaction.cache_ttl_seconds",
|
||||
@@ -1561,17 +1526,17 @@ async fn gateway_validates_chat_pii_redaction_system_config_locally_with_trusted
|
||||
.expect("json body should parse");
|
||||
assert_eq!(ttl_default_payload["value"], json!(300));
|
||||
|
||||
let instruction_default_response = put_config(
|
||||
"module.chat_pii_redaction.inject_model_instruction",
|
||||
let prefix_default_response = put_config(
|
||||
"module.chat_pii_redaction.placeholder_prefix",
|
||||
serde_json::Value::Null,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(instruction_default_response.status(), StatusCode::OK);
|
||||
let instruction_default_payload: serde_json::Value = instruction_default_response
|
||||
assert_eq!(prefix_default_response.status(), StatusCode::OK);
|
||||
let prefix_default_payload: serde_json::Value = prefix_default_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(instruction_default_payload["value"], json!(true));
|
||||
assert_eq!(prefix_default_payload["value"], json!("AETHER"));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -4274,6 +4274,12 @@ async fn gateway_updates_users_me_detail_locally_without_proxying_upstream() {
|
||||
.json(&json!({
|
||||
"email": "alice+updated@example.com",
|
||||
"username": "alice-updated",
|
||||
"feature_settings": {
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true,
|
||||
"inject_model_instruction": false
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -4301,6 +4307,14 @@ async fn gateway_updates_users_me_detail_locally_without_proxying_upstream() {
|
||||
assert_eq!(get_payload["username"], "alice-updated");
|
||||
assert_eq!(get_payload["auth_source"], "local");
|
||||
assert_eq!(get_payload["has_password"], true);
|
||||
assert_eq!(
|
||||
get_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
get_payload["feature_settings"]["chat_pii_redaction"]["inject_model_instruction"],
|
||||
false
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -6334,6 +6348,7 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
|
||||
assert_eq!(create_payload["name"], "writer-key");
|
||||
assert_eq!(create_payload["rate_limit"], 120);
|
||||
assert_eq!(create_payload["concurrent_limit"], serde_json::Value::Null);
|
||||
assert_eq!(create_payload["feature_settings"], serde_json::Value::Null);
|
||||
assert_eq!(create_payload["message"], "API密钥创建成功");
|
||||
let created_at = create_payload["created_at"]
|
||||
.as_str()
|
||||
@@ -6353,7 +6368,13 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
|
||||
.json(&json!({
|
||||
"name": "writer-key-renamed",
|
||||
"rate_limit": 30,
|
||||
"concurrent_limit": 4
|
||||
"concurrent_limit": 4,
|
||||
"feature_settings": {
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true,
|
||||
"inject_model_instruction": false
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -6366,6 +6387,14 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
|
||||
assert_eq!(update_payload["name"], "writer-key-renamed");
|
||||
assert_eq!(update_payload["rate_limit"], 30);
|
||||
assert_eq!(update_payload["concurrent_limit"], 4);
|
||||
assert_eq!(
|
||||
update_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
update_payload["feature_settings"]["chat_pii_redaction"]["inject_model_instruction"],
|
||||
false
|
||||
);
|
||||
assert_eq!(update_payload["message"], "API密钥已更新");
|
||||
|
||||
let toggle_response = client
|
||||
@@ -6446,6 +6475,10 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
|
||||
assert_eq!(detail_payload["concurrent_limit"], 4);
|
||||
assert_eq!(detail_payload["force_capabilities"], json!({}));
|
||||
assert_eq!(detail_payload["created_at"], created_at);
|
||||
assert_eq!(
|
||||
detail_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
|
||||
true
|
||||
);
|
||||
|
||||
let delete_response = client
|
||||
.delete(format!("{gateway_url}/api/users/me/api-keys/{created_id}"))
|
||||
|
||||
Reference in New Issue
Block a user