Merge upstream main

This commit is contained in:
ZheFox
2026-05-20 19:28:49 +08:00
123 changed files with 5969 additions and 594 deletions
@@ -10,7 +10,8 @@ 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_feature_settings, normalize_admin_optional_api_key_name,
normalize_admin_user_api_formats, normalize_admin_user_string_list,
normalize_admin_user_api_formats, normalize_admin_user_ip_rules,
normalize_admin_user_string_list,
};
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
use crate::GatewayError;
@@ -109,6 +110,10 @@ pub(super) async fn build_admin_create_api_key_response(
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let ip_rules = match normalize_admin_user_ip_rules(payload.ip_rules) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
if payload.rate_limit.is_some_and(|value| value < 0) {
return Ok(build_admin_api_keys_bad_request_response(
"rate_limit 必须大于等于 0",
@@ -162,6 +167,7 @@ pub(super) async fn build_admin_create_api_key_response(
allowed_providers,
allowed_api_formats,
allowed_models,
ip_rules,
rate_limit: payload.rate_limit,
concurrent_limit,
force_capabilities: None,
@@ -327,6 +333,19 @@ pub(super) async fn build_admin_update_api_key_response(
} else {
None
};
let ip_rules_present =
field_presence.contains("ip_rules") || field_presence.contains("allowed_ips");
let ip_rules = if ip_rules_present {
match payload.ip_rules {
Some(value) => match normalize_admin_user_ip_rules(value) {
Ok(value) => Some(value),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
},
None => Some(None),
}
} else {
None
};
let effective_expires_at_unix_secs = if field_presence.contains("expires_at") {
match parse_standalone_api_key_expires_at(payload.expires_at.as_deref()) {
Ok(value) => value,
@@ -394,6 +413,7 @@ pub(super) async fn build_admin_update_api_key_response(
allowed_providers,
allowed_api_formats,
allowed_models,
ip_rules,
expires_at_present: field_presence.contains("expires_at"),
expires_at_unix_secs: if field_presence.contains("expires_at") {
effective_expires_at_unix_secs
@@ -3,6 +3,7 @@ use crate::handlers::admin::shared::{query_param_value, AdminTypedObjectPatch};
use crate::handlers::admin::users::{
format_optional_unix_secs_iso8601, masked_user_api_key_display,
};
use crate::handlers::shared::deserialize_optional_string_list_patch;
use aether_admin::system::serialize_admin_system_users_export_wallet;
use axum::{
body::Body,
@@ -20,6 +21,8 @@ pub(super) struct AdminStandaloneApiKeyCreateRequest {
pub(super) allowed_providers: Option<Vec<String>>,
pub(super) allowed_api_formats: Option<Vec<String>>,
pub(super) allowed_models: Option<Vec<String>>,
#[serde(default, alias = "allowed_ips")]
pub(super) ip_rules: Option<Vec<String>>,
pub(super) rate_limit: Option<i32>,
pub(super) concurrent_limit: Option<i32>,
pub(super) initial_balance_usd: Option<f64>,
@@ -36,6 +39,12 @@ pub(super) struct AdminStandaloneApiKeyUpdateRequest {
pub(super) allowed_providers: Option<Vec<String>>,
pub(super) allowed_api_formats: Option<Vec<String>>,
pub(super) allowed_models: Option<Vec<String>>,
#[serde(
default,
alias = "allowed_ips",
deserialize_with = "deserialize_optional_string_list_patch"
)]
pub(super) ip_rules: Option<Option<Vec<String>>>,
pub(super) rate_limit: Option<i32>,
pub(super) concurrent_limit: Option<i32>,
pub(super) initial_balance_usd: Option<f64>,
@@ -161,6 +170,7 @@ pub(super) fn build_admin_api_key_list_item_payload(
"allowed_providers": record.allowed_providers,
"allowed_api_formats": record.allowed_api_formats,
"allowed_models": record.allowed_models,
"ip_rules": record.ip_rules,
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
@@ -191,6 +201,7 @@ pub(super) fn build_admin_api_key_detail_payload(
"allowed_providers": record.allowed_providers,
"allowed_api_formats": record.allowed_api_formats,
"allowed_models": record.allowed_models,
"ip_rules": record.ip_rules,
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
@@ -1,3 +1,4 @@
mod balance;
mod mutations;
mod quota;
mod reads;
@@ -18,6 +19,10 @@ pub(crate) async fn maybe_build_local_admin_endpoints_keys_response(
return Ok(Some(response));
}
if let Some(response) = balance::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
if let Some(response) = mutations::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,68 @@ pub(super) fn admin_provider_ops_is_valid_action_type(action_type: &str) -> bool
)
}
pub(crate) fn admin_provider_ops_saved_connector_credentials(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
) -> serde_json::Map<String, serde_json::Value> {
admin_provider_ops_decrypted_credentials(
state,
admin_provider_ops_config_object(provider)
.and_then(admin_provider_ops_connector_object)
.and_then(|connector| connector.get("credentials")),
)
}
pub(crate) async fn admin_provider_ops_query_balance_response_for_credentials(
state: &AdminAppState<'_>,
provider_id: &str,
provider: &StoredProviderCatalogProvider,
architecture_id: &str,
base_url: &str,
provider_ops_config: &serde_json::Map<String, serde_json::Value>,
connector_config: &serde_json::Map<String, serde_json::Value>,
credentials: &serde_json::Map<String, serde_json::Value>,
request_config: Option<&serde_json::Map<String, serde_json::Value>>,
) -> serde_json::Value {
let architecture_id = normalize_architecture_id(architecture_id);
let Some(architecture) = get_architecture(architecture_id) else {
return responses::admin_provider_ops_action_not_supported(
"query_balance",
ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE,
);
};
let headers = match build_headers(architecture.architecture_id, connector_config, credentials) {
Ok(headers) => headers,
Err(message) => {
return responses::admin_provider_ops_action_not_configured("query_balance", message);
}
};
let Some(action_config) = resolve_action_config(
architecture_id,
provider_ops_config,
"query_balance",
request_config,
) else {
return responses::admin_provider_ops_action_not_supported(
"query_balance",
ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE,
);
};
query_balance::admin_provider_ops_run_query_balance_action(
state,
provider_id,
provider,
&architecture,
base_url,
&action_config,
&headers,
credentials,
None,
)
.await
}
pub(crate) async fn admin_provider_ops_local_action_response(
state: &AdminAppState<'_>,
provider_id: &str,
@@ -111,11 +111,14 @@ pub(super) async fn admin_provider_ops_run_query_balance_action(
if status != http::StatusCode::OK {
let cookie_auth = architecture.query_balance_cookie_auth_errors;
let new_api_token_auth = architecture.architecture_id == "new_api";
return match status {
http::StatusCode::UNAUTHORIZED => admin_provider_ops_action_error(
"auth_failed",
"query_balance",
if cookie_auth {
if new_api_token_auth {
"访问令牌无效,请使用 New API 个人安全设置里的访问令牌"
} else if cookie_auth {
"Cookie 已失效,请重新配置"
} else {
"认证失败"
@@ -125,7 +128,9 @@ pub(super) async fn admin_provider_ops_run_query_balance_action(
http::StatusCode::FORBIDDEN => admin_provider_ops_action_error(
"auth_failed",
"query_balance",
if cookie_auth {
if new_api_token_auth {
"访问令牌无效或无权限,请使用 New API 个人安全设置里的访问令牌"
} else if cookie_auth {
"Cookie 已失效或无权限"
} else {
"无权限访问"
@@ -8,7 +8,9 @@ use super::super::responses::{
};
use super::super::support::admin_provider_ops_json_object_map;
use crate::handlers::admin::request::AdminAppState;
use aether_admin::provider::ops::parse_sub2api_balance_payload;
use aether_admin::provider::ops::{
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
};
use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
use serde_json::{json, Value};
@@ -24,6 +26,23 @@ pub(super) async fn admin_provider_ops_sub2api_balance_payload(
proxy_snapshot: Option<&ProxySnapshot>,
) -> serde_json::Value {
let start = std::time::Instant::now();
if let Some(api_key) = credentials
.get("api_key")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return admin_provider_ops_sub2api_api_key_balance_payload(
state,
provider_id,
base_url,
action_config,
api_key,
proxy_snapshot,
start,
)
.await;
}
let (access_token, updated_credentials, _frontend_updated_credentials) =
match admin_provider_ops_sub2api_exchange_token(
state,
@@ -191,6 +210,117 @@ pub(super) async fn admin_provider_ops_sub2api_balance_payload(
)
}
async fn admin_provider_ops_sub2api_api_key_balance_payload(
state: &AdminAppState<'_>,
provider_id: &str,
base_url: &str,
action_config: &serde_json::Map<String, serde_json::Value>,
api_key: &str,
proxy_snapshot: Option<&ProxySnapshot>,
start: std::time::Instant,
) -> serde_json::Value {
let usage_endpoint = action_config
.get("api_key_usage_endpoint")
.or_else(|| action_config.get("usage_endpoint"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("/v1/usage");
let usage_url = admin_provider_ops_sub2api_request_url(base_url, usage_endpoint);
let auth_value = match reqwest::header::HeaderValue::from_str(&format!("Bearer {api_key}")) {
Ok(value) => value,
Err(_) => {
return admin_provider_ops_action_error(
"parse_error",
"query_balance",
"API Key 格式无效",
Some(start.elapsed().as_millis() as u64),
);
}
};
let auth_headers = reqwest::header::HeaderMap::from_iter([
(reqwest::header::AUTHORIZATION, auth_value),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
let request_id = format!("provider-ops-action:sub2api:usage:{provider_id}");
let result = admin_provider_ops_execute_json_request(
state,
&request_id,
reqwest::Method::GET,
&usage_url,
&auth_headers,
None,
proxy_snapshot,
)
.await;
let response_time_ms = Some(start.elapsed().as_millis() as u64);
let (status, response_json) = match result {
Ok(result) => result,
Err(AdminProviderOpsExecuteJsonError::InvalidJson(message))
| Err(AdminProviderOpsExecuteJsonError::Transport(message)) => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
network_error_message(&message),
response_time_ms,
);
}
};
if matches!(
status,
http::StatusCode::UNAUTHORIZED | http::StatusCode::FORBIDDEN
) {
return admin_provider_ops_action_error(
"auth_failed",
"query_balance",
"认证失败,请检查 API Key",
response_time_ms,
);
}
if status != http::StatusCode::OK {
return admin_provider_ops_action_error(
"unknown_error",
"query_balance",
format!(
"HTTP {}: {}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown")
),
response_time_ms,
);
}
let data = match parse_sub2api_api_key_usage_payload(action_config, &response_json) {
Ok(payload) => payload,
Err(message) => {
return admin_provider_ops_action_error(
if message.contains("无效") {
"auth_failed"
} else if message == "响应格式无效" {
"parse_error"
} else {
"unknown_error"
},
"query_balance",
message,
response_time_ms,
);
}
};
admin_provider_ops_action_response(
"success",
"query_balance",
data,
None,
response_time_ms,
86400,
)
}
fn network_error_message(error: &str) -> String {
let normalized = error.trim();
let lower = normalized.to_ascii_lowercase();
@@ -250,6 +250,9 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
provider: &StoredProviderCatalogProvider,
payload: AdminProviderOpsSaveConfigRequest,
) -> Result<serde_json::Value, String> {
let architecture_id =
admin_provider_ops_pure::normalize_architecture_id(payload.architecture_id.as_str())
.to_string();
let auth_type = payload.connector.auth_type.trim().to_string();
if auth_type.is_empty() || !admin_provider_ops_is_supported_auth_type(auth_type.as_str()) {
return Err("connector.auth_type 必须是合法的认证类型".to_string());
@@ -257,7 +260,7 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
let merged_credentials = admin_provider_ops_merge_credentials(
state,
payload.architecture_id.as_str(),
architecture_id.as_str(),
provider,
payload.connector.credentials,
);
@@ -278,7 +281,7 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
.collect::<serde_json::Map<String, serde_json::Value>>();
Ok(json!({
"architecture_id": payload.architecture_id,
"architecture_id": architecture_id,
"base_url": payload.base_url,
"connector": {
"auth_type": auth_type,
@@ -328,14 +331,16 @@ pub(super) fn build_admin_provider_ops_config_payload(
});
};
let connector = admin_provider_ops_connector_object(provider_ops_config);
let architecture_id = provider_ops_config
.get("architecture_id")
.and_then(serde_json::Value::as_str)
.map(admin_provider_ops_pure::normalize_architecture_id)
.unwrap_or("generic_api");
json!({
"provider_id": provider_id,
"is_configured": true,
"architecture_id": provider_ops_config
.get("architecture_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("generic_api"),
"architecture_id": architecture_id,
"base_url": resolve_admin_provider_ops_base_url(
provider,
endpoints,
@@ -6,7 +6,8 @@ use crate::handlers::admin::provider::ops::providers::config::persist_admin_prov
use crate::handlers::admin::request::AdminAppState;
use aether_admin::provider::ops::{
admin_provider_ops_frontend_updated_credentials, admin_provider_ops_verify_failure,
parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
admin_provider_ops_verify_success, admin_provider_ops_verify_user_payload,
parse_sub2api_api_key_usage_payload, parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
};
use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
@@ -21,6 +22,21 @@ pub(super) async fn admin_provider_ops_local_sub2api_verify_response(
credentials: &Map<String, Value>,
proxy_snapshot: Option<&ProxySnapshot>,
) -> Value {
if let Some(api_key) = credentials
.get("api_key")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return admin_provider_ops_local_sub2api_api_key_verify_response(
state,
base_url,
api_key,
proxy_snapshot,
)
.await;
}
let (access_token, updated_credentials, frontend_updated_credentials) =
match admin_provider_ops_sub2api_exchange_token(
state,
@@ -93,6 +109,78 @@ pub(super) async fn admin_provider_ops_local_sub2api_verify_response(
)
}
async fn admin_provider_ops_local_sub2api_api_key_verify_response(
state: &AdminAppState<'_>,
base_url: &str,
api_key: &str,
proxy_snapshot: Option<&ProxySnapshot>,
) -> Value {
let usage_url = admin_provider_ops_sub2api_request_url(base_url, "/v1/usage");
let auth_value = match reqwest::header::HeaderValue::from_str(&format!("Bearer {api_key}")) {
Ok(value) => value,
Err(_) => return admin_provider_ops_verify_failure("API Key 格式无效"),
};
let auth_headers = reqwest::header::HeaderMap::from_iter([
(reqwest::header::AUTHORIZATION, auth_value),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
let auth_headers =
admin_provider_ops_headers_with_transport_controls(&auth_headers, None, true);
let (status, response_json) = match admin_provider_ops_execute_json_request(
state,
"provider-ops-verify:sub2api:api-key",
reqwest::Method::GET,
&usage_url,
&auth_headers,
None,
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(AdminProviderOpsExecuteJsonError::InvalidJson(message))
| Err(AdminProviderOpsExecuteJsonError::Transport(message)) => {
return admin_provider_ops_verify_failure(
admin_provider_ops_verify_execution_error_message(&message),
);
}
};
if matches!(
status,
http::StatusCode::UNAUTHORIZED | http::StatusCode::FORBIDDEN
) {
return admin_provider_ops_verify_failure("认证失败:API Key 无效或已过期");
}
if status != http::StatusCode::OK {
return admin_provider_ops_verify_failure(format!("验证失败:HTTP {}", status.as_u16()));
}
let payload = match parse_sub2api_api_key_usage_payload(&Map::new(), &response_json) {
Ok(payload) => payload,
Err(message) => return admin_provider_ops_verify_failure(message),
};
let quota = payload.get("total_available").and_then(Value::as_f64);
let extra = payload
.get("extra")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
admin_provider_ops_verify_success(
admin_provider_ops_verify_user_payload(
Some("Sub2API API Key".to_string()),
Some("Sub2API API Key".to_string()),
None,
quota,
Some(extra),
),
None,
)
}
// 对齐 Python httpx.AsyncClient(base_url=...) 的行为:
// 以 "/" 开头的端点始终相对站点根路径解析,而不是简单字符串拼接。
pub(in super::super) fn admin_provider_ops_sub2api_request_url(
@@ -564,6 +564,29 @@ fn provider_query_build_test_request_body_for_api_format(
client_api_format.as_str(),
payload,
);
} else if matches!(
client_api_format.as_str(),
"openai:responses" | "openai:responses:compact"
) && !value_has_non_empty_text(object.get("input"))
{
if let Some(prompt) = object
.remove("prompt")
.filter(|value| value_has_non_empty_text(Some(value)))
{
object.insert("input".to_string(), prompt);
}
}
if matches!(
client_api_format.as_str(),
"openai:responses" | "openai:responses:compact"
) && value_has_non_empty_text(object.get("input"))
{
object.remove("prompt");
}
if client_api_format == "openai:responses:compact"
&& value_has_non_empty_text(object.get("input"))
{
object.remove("messages");
}
}
return body;
@@ -30,7 +30,9 @@ pub(super) fn provider_query_standard_test_client_api_format(
provider_api_format: &str,
) -> &'static str {
let normalized_api_format = crate::ai_serving::normalize_api_format_alias(provider_api_format);
if crate::ai_serving::is_embedding_api_format(&normalized_api_format) {
if normalized_api_format == "openai:responses:compact" {
"openai:responses:compact"
} else if crate::ai_serving::is_embedding_api_format(&normalized_api_format) {
"openai:embedding"
} else if crate::ai_serving::is_rerank_api_format(&normalized_api_format) {
"openai:rerank"
@@ -324,6 +324,117 @@ fn provider_query_responses_test_request_body_defaults_to_responses_input() {
assert!(body.get("messages").is_none());
}
#[test]
fn provider_query_compact_test_request_body_defaults_to_responses_input() {
let payload = json!({"message": "hello from compact"});
let client_api_format =
provider_query_standard_test_client_api_format("openai:responses:compact");
let body = provider_query_build_test_request_body_for_api_format(
&payload,
"gpt-5.4-mini",
"/api/admin/provider-query/test-model",
client_api_format,
);
assert_eq!(client_api_format, "openai:responses:compact");
assert_eq!(body["model"], json!("gpt-5.4-mini"));
assert_eq!(body["input"], json!("hello from compact"));
assert!(body.get("messages").is_none());
}
#[test]
fn provider_query_compact_test_request_body_promotes_prompt_to_input() {
let payload = json!({
"request_body": {
"model": "custom-model",
"prompt": "hello from prompt"
}
});
let body = provider_query_build_test_request_body_for_api_format(
&payload,
"fallback-model",
"/api/admin/provider-query/test-model",
"openai:responses:compact",
);
assert_eq!(body["model"], json!("custom-model"));
assert_eq!(body["input"], json!("hello from prompt"));
assert!(body.get("prompt").is_none());
assert!(provider_query_request_body_is_openai_responses_shape(&body));
}
#[test]
fn provider_query_compact_test_request_body_strips_stale_chat_fields() {
let payload = json!({
"request_body": {
"model": "custom-model",
"input": "hello from input",
"messages": [{ "role": "user", "content": "stale chat body" }],
"prompt": "stale prompt"
}
});
let body = provider_query_build_test_request_body_for_api_format(
&payload,
"fallback-model",
"/api/admin/provider-query/test-model",
"openai:responses:compact",
);
assert_eq!(body["model"], json!("custom-model"));
assert_eq!(body["input"], json!("hello from input"));
assert!(body.get("messages").is_none());
assert!(body.get("prompt").is_none());
}
#[test]
fn provider_query_compact_provider_body_builds_without_chat_conversion() {
let payload = json!({"message": "hello compact provider"});
let client_api_format =
provider_query_standard_test_client_api_format("openai:responses:compact");
let mut request_body = provider_query_build_test_request_body_for_api_format(
&payload,
"gpt-5.4-mini",
"/api/admin/provider-query/test-model",
client_api_format,
);
if let Some(object) = request_body.as_object_mut() {
object.insert("stream".to_string(), serde_json::Value::Bool(false));
}
assert!(provider_query_request_body_is_openai_responses_shape(
&request_body
));
let mut provider_request_body = crate::ai_serving::build_local_openai_responses_request_body(
&request_body,
"upstream-gpt",
false,
)
.expect("compact model test body should build from responses shape");
crate::ai_serving::apply_openai_responses_compact_special_body_edits(
&mut provider_request_body,
"openai:responses:compact",
);
crate::ai_serving::enforce_request_body_stream_field(
&mut provider_request_body,
"openai:responses:compact",
false,
true,
);
assert_eq!(provider_request_body["model"], json!("upstream-gpt"));
assert_eq!(
provider_request_body["input"],
json!("hello compact provider")
);
assert!(provider_request_body.get("messages").is_none());
assert!(provider_request_body.get("stream").is_none());
assert!(provider_request_body.get("store").is_none());
}
#[test]
fn provider_query_standard_test_rejects_gemini_success_without_visible_output() {
let result = aether_contracts::ExecutionResult {
@@ -12,6 +12,13 @@ pub(crate) fn admin_provider_id_for_refresh_quota(request_path: &str) -> Option<
.map(ToOwned::to_owned)
}
pub(crate) fn admin_provider_id_for_key_balance(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/endpoints/providers/")?
.strip_suffix("/key-balance")
.map(ToOwned::to_owned)
}
pub(crate) fn admin_reveal_key_id(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/endpoints/keys/")?
@@ -15,9 +15,9 @@ pub(crate) use self::crud::{
is_admin_providers_root,
};
pub(crate) use self::endpoint_keys::{
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_keys,
admin_provider_id_for_refresh_quota, admin_reset_cycle_stats_key_id, admin_reveal_key_id,
admin_update_key_id,
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_key_balance,
admin_provider_id_for_keys, admin_provider_id_for_refresh_quota,
admin_reset_cycle_stats_key_id, admin_reveal_key_id, admin_update_key_id,
};
pub(crate) use self::oauth::{
admin_provider_oauth_batch_import_provider_id, admin_provider_oauth_batch_import_task_path,
@@ -31,7 +31,7 @@ pub(crate) async fn build_admin_provider_summary_payload(
active_global_model_ids_result,
) = tokio::join!(
state.list_provider_catalog_endpoints_by_provider_ids(&provider_ids),
state.list_provider_catalog_key_summaries_by_provider_ids(&provider_ids),
state.list_provider_catalog_keys_by_provider_ids(&provider_ids),
state.read_provider_quota_snapshot(provider_id),
state.list_provider_model_stats(&provider_ids),
state.list_active_global_model_ids_by_provider_ids(&provider_ids),
@@ -197,7 +197,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
} else {
let (endpoints_result, keys_result, model_stats_result, active_global_model_refs_result) = tokio::join!(
state.list_provider_catalog_endpoints_by_provider_ids(&provider_ids),
state.list_provider_catalog_key_summaries_by_provider_ids(&provider_ids),
state.list_provider_catalog_keys_by_provider_ids(&provider_ids),
state.list_provider_model_stats(&provider_ids),
state.list_active_global_model_ids_by_provider_ids(&provider_ids),
);
@@ -7,7 +7,7 @@ use aether_data_contracts::repository::candidates::{
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::json;
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
fn json_truthy(value: &serde_json::Value) -> bool {
@@ -27,6 +27,80 @@ fn endpoint_timestamp_or_now(value: Option<u64>, now_unix_secs: u64) -> serde_js
.unwrap_or(serde_json::Value::Null)
}
fn finite_json_number(value: Option<&Value>) -> Option<f64> {
match value {
Some(Value::Number(number)) => number.as_f64().filter(|value| value.is_finite()),
Some(Value::String(value)) => value
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite()),
_ => None,
}
}
fn finite_json_u64(value: Option<&Value>) -> Option<u64> {
finite_json_number(value).and_then(|value| {
if value >= 0.0 {
Some(value as u64)
} else {
None
}
})
}
fn latest_key_balance_summary(keys: &[StoredProviderCatalogKey]) -> Value {
let mut selected: Option<(u64, &StoredProviderCatalogKey, &Map<String, Value>)> = None;
for key in keys {
let Some(balance) = key
.upstream_metadata
.as_ref()
.and_then(|metadata| metadata.get("balance_query"))
.and_then(Value::as_object)
else {
continue;
};
let Some(updated_at) = finite_json_u64(balance.get("updated_at")) else {
continue;
};
let has_balance_value = ["total_available", "total_used", "total_granted"]
.into_iter()
.any(|field| finite_json_number(balance.get(field)).is_some());
if !has_balance_value {
continue;
}
if selected
.as_ref()
.is_none_or(|(selected_updated_at, _, _)| updated_at > *selected_updated_at)
{
selected = Some((updated_at, key, balance));
}
}
let Some((updated_at, key, balance)) = selected else {
return Value::Null;
};
json!({
"key_id": key.id.clone(),
"key_name": key.name.clone(),
"updated_at": updated_at,
"architecture_id": balance.get("architecture_id").cloned().unwrap_or(Value::Null),
"status": balance.get("status").cloned().unwrap_or_else(|| json!("success")),
"executed_at": balance.get("executed_at").cloned().unwrap_or(Value::Null),
"response_time_ms": balance.get("response_time_ms").cloned().unwrap_or(Value::Null),
"total_available": balance.get("total_available").cloned().unwrap_or(Value::Null),
"total_used": balance.get("total_used").cloned().unwrap_or(Value::Null),
"total_granted": balance.get("total_granted").cloned().unwrap_or(Value::Null),
"currency": balance.get("currency").cloned().unwrap_or_else(|| json!("USD")),
"plan_name": balance.get("plan_name").cloned().unwrap_or(Value::Null),
"query_config": balance.get("query_config").cloned().unwrap_or(Value::Null),
"extra": balance.get("extra").cloned().unwrap_or(Value::Null),
})
}
pub(crate) fn build_admin_provider_summary_value(
provider: &StoredProviderCatalogProvider,
endpoints: &[StoredProviderCatalogEndpoint],
@@ -158,6 +232,7 @@ pub(crate) fn build_admin_provider_summary_value(
.and_then(|quota| quota.quota_expires_at_unix_secs)
.or(provider.quota_expires_at_unix_secs)
.and_then(unix_secs_to_rfc3339);
let key_balance_summary = latest_key_balance_summary(keys);
json!({
"id": provider.id.clone(),
@@ -196,6 +271,7 @@ pub(crate) fn build_admin_provider_summary_value(
"endpoint_health_details": endpoint_health_details,
"ops_configured": ops_configured,
"ops_architecture_id": ops_architecture_id,
"key_balance_summary": key_balance_summary,
"kiro_simulated_cache_enabled": kiro_simulated_cache_enabled,
"created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs),
"updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs),
@@ -177,6 +177,21 @@ impl<'a> AdminAppState<'a> {
self.app.update_provider_catalog_key(key).await
}
pub(crate) async fn update_provider_catalog_key_upstream_metadata(
&self,
key_id: &str,
upstream_metadata: Option<&serde_json::Value>,
updated_at_unix_secs: Option<u64>,
) -> Result<bool, GatewayError> {
self.app
.update_provider_catalog_key_upstream_metadata(
key_id,
upstream_metadata,
updated_at_unix_secs,
)
.await
}
pub(crate) async fn create_provider_catalog_key(
&self,
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
@@ -344,6 +344,7 @@ impl<'a> AdminAppState<'a> {
"allowed_models".to_string(),
json!(key.allowed_models.clone()),
),
("ip_rules".to_string(), json!(key.ip_rules.clone())),
("rate_limit".to_string(), json!(key.rate_limit)),
("concurrent_limit".to_string(), json!(key.concurrent_limit)),
(
@@ -14,7 +14,7 @@ use crate::handlers::admin::system::shared::configs::apply_admin_system_config_u
use crate::handlers::admin::users::{
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,
normalize_admin_user_ip_rules, normalize_admin_user_string_list,
};
use crate::handlers::public::normalize_admin_base_url;
use crate::GatewayError;
@@ -705,6 +705,27 @@ fn normalize_imported_user_api_formats(
)?)
}
fn imported_ip_rules_field<'a>(
object: &'a Map<String, Value>,
) -> (&'static str, Option<&'a Value>) {
if let Some(value) = object.get("ip_rules") {
("ip_rules", Some(value))
} else {
("allowed_ips", object.get("allowed_ips"))
}
}
fn imported_ip_rules_present(object: &Map<String, Value>) -> bool {
object.contains_key("ip_rules") || object.contains_key("allowed_ips")
}
fn normalize_imported_user_ip_rules(
object: &Map<String, Value>,
) -> Result<Option<Vec<String>>, String> {
let (field_name, value) = imported_ip_rules_field(object);
normalize_admin_user_ip_rules(imported_string_list_from_value(value, field_name)?)
}
fn build_imported_user_group_record(
group: &Map<String, Value>,
field_name: &str,
@@ -2493,6 +2514,7 @@ impl<'a> AdminAppState<'a> {
));
let allowed_models =
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
let ip_rules = invalid_value!(normalize_imported_user_ip_rules(key));
let rate_limit =
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"))
.unwrap_or(0);
@@ -2558,6 +2580,8 @@ impl<'a> AdminAppState<'a> {
} else {
None
},
ip_rules: imported_ip_rules_present(key)
.then(|| ip_rules.clone()),
},
)
.await?;
@@ -2626,6 +2650,7 @@ impl<'a> AdminAppState<'a> {
allowed_providers,
allowed_api_formats,
allowed_models,
ip_rules,
rate_limit,
concurrent_limit,
force_capabilities,
@@ -2710,6 +2735,7 @@ impl<'a> AdminAppState<'a> {
));
let allowed_models =
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
let ip_rules = invalid_value!(normalize_imported_user_ip_rules(key));
let rate_limit =
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"))
.unwrap_or(0);
@@ -2781,6 +2807,8 @@ impl<'a> AdminAppState<'a> {
allowed_providers: Some(allowed_providers.clone()),
allowed_api_formats: Some(allowed_api_formats.clone()),
allowed_models: Some(allowed_models.clone()),
ip_rules: imported_ip_rules_present(key)
.then(|| ip_rules.clone()),
expires_at_present: false,
expires_at_unix_secs: None,
auto_delete_on_expiry_present: false,
@@ -2841,6 +2869,7 @@ impl<'a> AdminAppState<'a> {
allowed_providers,
allowed_api_formats,
allowed_models,
ip_rules,
rate_limit: Some(rate_limit),
concurrent_limit,
force_capabilities,
@@ -42,6 +42,13 @@ impl<'a> AdminAppState<'a> {
self.app.list_export_users_page(query).await
}
pub(crate) async fn count_export_users(
&self,
query: &aether_data::repository::users::UserExportListQuery,
) -> Result<u64, GatewayError> {
self.app.count_export_users(query).await
}
pub(crate) async fn find_export_user_by_id(
&self,
user_id: &str,
@@ -10,7 +10,7 @@ use crate::handlers::admin::system::shared::paths::{
admin_management_token_status_id_from_path, is_admin_management_tokens_root,
};
use crate::handlers::internal::build_management_token_payload;
use crate::handlers::shared::generate_gateway_secret_plaintext;
use crate::handlers::shared::{generate_gateway_secret_plaintext, parse_json_ip_rules};
use crate::{GatewayError, LocalMutationOutcome};
use aether_data::repository::management_tokens::{
CreateManagementTokenRecord, ManagementTokenListQuery, RegenerateManagementTokenSecret,
@@ -97,59 +97,10 @@ fn admin_management_token_prefix(value: &str) -> Option<String> {
.then(|| value[..value.len().min(ADMIN_MANAGEMENT_TOKEN_DISPLAY_PREFIX_LEN)].to_string())
}
fn admin_validate_ip_or_cidr(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
if value.parse::<std::net::IpAddr>().is_ok() {
return true;
}
let Some((host, prefix)) = value.split_once('/') else {
return false;
};
let Ok(ip) = host.trim().parse::<std::net::IpAddr>() else {
return false;
};
let Ok(prefix) = prefix.trim().parse::<u8>() else {
return false;
};
match ip {
std::net::IpAddr::V4(_) => prefix <= 32,
std::net::IpAddr::V6(_) => prefix <= 128,
}
}
fn admin_parse_management_token_allowed_ips(
value: Option<&serde_json::Value>,
) -> Result<Option<serde_json::Value>, String> {
let Some(value) = value else {
return Ok(None);
};
match value {
serde_json::Value::Null => Ok(None),
serde_json::Value::Array(items) => {
if items.is_empty() {
return Err("IP 白名单不能为空列表,如需取消限制请不提供此字段".to_string());
}
let mut normalized = Vec::with_capacity(items.len());
for (index, item) in items.iter().enumerate() {
let Some(raw) = item.as_str() else {
return Err("IP 白名单必须是字符串数组".to_string());
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("IP 白名单第 {} 项为空", index + 1));
}
if !admin_validate_ip_or_cidr(trimmed) {
return Err(format!("无效的 IP 地址或 CIDR: {raw}"));
}
normalized.push(trimmed.to_string());
}
Ok(Some(json!(normalized)))
}
_ => Err("IP 白名单必须是字符串数组".to_string()),
}
parse_json_ip_rules(value)
}
fn admin_parse_management_token_expires_at(
@@ -46,7 +46,7 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
AdminModuleDefinition {
name: "management_tokens",
display_name: "访问令牌",
description: "管理 API 访问令牌,支持细粒度权限控制和 IP 白名单",
description: "管理 API 访问令牌,支持细粒度权限控制和 IP 限制",
category: "security",
env_key: "MANAGEMENT_TOKENS_AVAILABLE",
default_available: true,
@@ -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,
"ip_rules": record.ip_rules,
"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),
@@ -1,7 +1,7 @@
use super::super::super::{
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
build_admin_users_read_only_response, normalize_admin_feature_settings,
AdminCreateUserApiKeyRequest,
normalize_admin_user_ip_rules, AdminCreateUserApiKeyRequest,
};
use super::super::helpers::{
attach_audit_response, default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
@@ -71,7 +71,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
{
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "当前仅支持 name、rate_limit、concurrent_limit、allowed_providers 字段" })),
Json(json!({ "detail": "当前仅支持 name、rate_limit、concurrent_limit、allowed_providers、ip_rules 字段" })),
)
.into_response());
}
@@ -107,6 +107,16 @@ pub(crate) async fn build_admin_create_user_api_key_response(
.into_response());
}
};
let ip_rules = match normalize_admin_user_ip_rules(payload.ip_rules) {
Ok(value) => value,
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response());
}
};
let rate_limit = payload.rate_limit.unwrap_or(0);
if rate_limit < 0 {
return Ok((
@@ -146,6 +156,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
allowed_providers: None,
allowed_api_formats: None,
allowed_models: None,
ip_rules,
rate_limit,
concurrent_limit,
force_capabilities: None,
@@ -196,6 +207,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
"key_display": masked_user_api_key_display(state, created.key_encrypted.as_deref()),
"rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"ip_rules": created.ip_rules,
"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),
@@ -1,6 +1,6 @@
use super::super::super::{
build_admin_users_bad_request_response, build_admin_users_read_only_response,
normalize_admin_feature_settings, AdminUpdateUserApiKeyRequest,
normalize_admin_feature_settings, normalize_admin_user_ip_rules, AdminUpdateUserApiKeyRequest,
};
use super::super::helpers::{
attach_audit_response, build_admin_user_api_key_detail_payload,
@@ -94,6 +94,19 @@ pub(crate) async fn build_admin_update_user_api_key_response(
.into_response());
}
};
let ip_rules = match payload.ip_rules {
Some(value) => match normalize_admin_user_ip_rules(value) {
Ok(value) => Some(value),
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response());
}
},
None => None,
};
let Some(updated) = state
.update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord {
@@ -102,6 +115,7 @@ pub(crate) async fn build_admin_update_user_api_key_response(
name,
rate_limit: payload.rate_limit,
concurrent_limit,
ip_rules,
})
.await?
else {
@@ -37,16 +37,20 @@ pub(in super::super) async fn build_admin_list_users_response(
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let paged_rows = state
.list_export_users_page(&aether_data::repository::users::UserExportListQuery {
skip,
limit,
role: role.clone(),
is_active,
search,
group_id,
})
.await?;
let query = aether_data::repository::users::UserExportListQuery {
skip,
limit,
role: role.clone(),
is_active,
search,
group_id,
};
let (paged_rows_result, total_result) = tokio::join!(
state.list_export_users_page(&query),
state.count_export_users(&query),
);
let paged_rows = paged_rows_result?;
let total = total_result?;
let user_ids = paged_rows
.iter()
.map(|row| row.id.clone())
@@ -116,7 +120,15 @@ pub(in super::super) async fn build_admin_list_users_response(
));
}
Ok(Json(payload).into_response())
let has_more = (skip as u64).saturating_add(payload.len() as u64) < total;
Ok(Json(json!({
"items": payload,
"total": total,
"skip": skip,
"limit": limit,
"has_more": has_more,
}))
.into_response())
}
pub(in super::super) async fn build_admin_get_user_response(
@@ -56,7 +56,8 @@ use self::shared::{
};
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,
normalize_admin_user_api_formats, normalize_admin_user_ip_rules,
normalize_admin_user_string_list,
};
pub(crate) use crate::handlers::shared::normalize_feature_settings as normalize_admin_feature_settings;
@@ -1,5 +1,6 @@
use super::ADMIN_USERS_DATA_UNAVAILABLE_DETAIL;
use crate::handlers::admin::shared::AdminTypedObjectPatch;
use crate::handlers::shared::{deserialize_optional_string_list_patch, normalize_ip_rules};
use axum::{
body::Body,
http,
@@ -19,6 +20,8 @@ pub(super) struct AdminCreateUserApiKeyRequest {
pub(super) allowed_api_formats: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_models: Option<Vec<String>>,
#[serde(default, alias = "allowed_ips")]
pub(super) ip_rules: Option<Vec<String>>,
#[serde(default)]
pub(super) rate_limit: Option<i32>,
#[serde(default)]
@@ -49,6 +52,12 @@ pub(super) struct AdminUpdateUserApiKeyRequest {
pub(super) concurrent_limit: Option<i32>,
#[serde(default)]
pub(super) feature_settings: Option<Option<Value>>,
#[serde(
default,
alias = "allowed_ips",
deserialize_with = "deserialize_optional_string_list_patch"
)]
pub(super) ip_rules: Option<Option<Vec<String>>>,
}
#[derive(Debug, serde::Deserialize)]
@@ -280,6 +289,12 @@ pub(crate) fn normalize_admin_user_api_formats(
Ok(Some(normalized))
}
pub(crate) fn normalize_admin_user_ip_rules(
value: Option<Vec<String>>,
) -> Result<Option<Vec<String>>, String> {
normalize_ip_rules(value)
}
pub(crate) fn normalize_admin_list_policy_mode(value: &str) -> Result<String, String> {
match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "unrestricted" | "specific" | "deny_all" => {
@@ -338,7 +353,8 @@ pub(super) fn format_optional_datetime_iso8601(
#[cfg(test)]
mod tests {
use super::normalize_admin_user_api_formats;
use super::{normalize_admin_user_api_formats, AdminUpdateUserApiKeyRequest};
use serde_json::json;
#[test]
fn admin_user_api_formats_accept_current_canonical_signatures() {
@@ -375,4 +391,31 @@ mod tests {
);
}
}
#[test]
fn admin_update_api_key_distinguishes_missing_null_and_present_ip_rules() {
let missing = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
"name": "unchanged-ip-rules",
}))
.expect("missing ip_rules should deserialize");
assert_eq!(missing.ip_rules, None);
let cleared = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
"ip_rules": null,
}))
.expect("null ip_rules should deserialize");
assert_eq!(cleared.ip_rules, Some(None));
let updated = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
"ip_rules": ["203.0.113.10", "10.0.0.0/24"],
}))
.expect("present ip_rules should deserialize");
assert_eq!(
updated.ip_rules,
Some(Some(vec![
"203.0.113.10".to_string(),
"10.0.0.0/24".to_string(),
])),
);
}
}