mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Merge upstream/main into feat/one-click-update
This commit is contained in:
@@ -9,6 +9,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -548,16 +549,69 @@ fn admin_monitoring_trace_response_data(
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = admin_monitoring_trace_response_body(headers, body);
|
||||
Some(json!({
|
||||
"source": source,
|
||||
"status_code": status_code,
|
||||
"headers": headers.cloned().unwrap_or(Value::Null),
|
||||
"body": body.cloned().unwrap_or(Value::Null),
|
||||
"body": body.unwrap_or(Value::Null),
|
||||
"body_ref": body_ref,
|
||||
"body_state": body_state.map(|state| state.as_str()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn admin_monitoring_trace_response_body(
|
||||
headers: Option<&Value>,
|
||||
body: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let body = body?;
|
||||
admin_monitoring_decode_connect_json_error_body(headers, body).or_else(|| Some(body.clone()))
|
||||
}
|
||||
|
||||
fn admin_monitoring_decode_connect_json_error_body(
|
||||
headers: Option<&Value>,
|
||||
body: &Value,
|
||||
) -> Option<Value> {
|
||||
if !admin_monitoring_headers_indicate_connect_json(headers) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body_base64 = match body {
|
||||
Value::String(value) => Some(value.as_str()),
|
||||
Value::Object(object) => object
|
||||
.get("encoding")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("base64"))
|
||||
.then(|| object.get("data").and_then(Value::as_str))
|
||||
.flatten(),
|
||||
_ => None,
|
||||
}?
|
||||
.trim();
|
||||
if body_base64.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body_bytes = BASE64_STANDARD.decode(body_base64).ok()?;
|
||||
aether_ai_formats::api::extract_provider_private_stream_error_body(None, &body_bytes)
|
||||
}
|
||||
|
||||
fn admin_monitoring_headers_indicate_connect_json(headers: Option<&Value>) -> bool {
|
||||
headers
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| {
|
||||
object.iter().find_map(|(key, value)| {
|
||||
key.eq_ignore_ascii_case("content-type")
|
||||
.then(|| value.as_str())
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.contains("application/connect+json") || value.contains("+connect+json")
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_admin_monitoring_trace_response(
|
||||
extra_object: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::observability::stats::{aggregate_usage_stats, parse_bounded_u32, round_to};
|
||||
use aether_ai_formats::api::request_path_implies_stream_request;
|
||||
use aether_ai_formats::UPSTREAM_IS_STREAM_KEY;
|
||||
use aether_billing::{
|
||||
normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate,
|
||||
};
|
||||
@@ -1052,7 +1053,7 @@ fn admin_usage_upstream_is_stream(item: &StoredRequestUsageAudit) -> bool {
|
||||
item.request_metadata
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("upstream_is_stream"))
|
||||
.and_then(|metadata| metadata.get(UPSTREAM_IS_STREAM_KEY))
|
||||
.and_then(Value::as_bool)
|
||||
.or_else(|| admin_usage_headers_stream_flag(item.response_headers.as_ref()))
|
||||
.or_else(|| admin_usage_infer_upstream_stream_from_captured_bodies(item))
|
||||
@@ -1256,7 +1257,10 @@ pub fn admin_usage_record_json(
|
||||
.as_object_mut()
|
||||
.expect("admin usage record payload should be an object");
|
||||
object.insert("is_stream".to_string(), json!(item.is_stream));
|
||||
object.insert("upstream_is_stream".to_string(), json!(upstream_is_stream));
|
||||
object.insert(
|
||||
UPSTREAM_IS_STREAM_KEY.to_string(),
|
||||
json!(upstream_is_stream),
|
||||
);
|
||||
object.insert(
|
||||
"client_requested_stream".to_string(),
|
||||
json!(client_is_stream),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::architectures::normalize_architecture_id;
|
||||
use super::verify::admin_provider_ops_value_as_f64;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
@@ -14,7 +13,7 @@ pub fn parse_query_balance_payload(
|
||||
action_config: &Map<String, Value>,
|
||||
response_json: &Value,
|
||||
) -> Result<Value, String> {
|
||||
match normalize_architecture_id(architecture_id) {
|
||||
match architecture_id {
|
||||
"generic_api" | "new_api" | "anyrouter" | "done_hub" => {
|
||||
parse_new_api_balance_payload(action_config, response_json)
|
||||
}
|
||||
@@ -114,73 +113,6 @@ pub fn parse_sub2api_balance_payload(
|
||||
))
|
||||
}
|
||||
|
||||
pub fn parse_sub2api_api_key_usage_payload(
|
||||
action_config: &Map<String, Value>,
|
||||
response_json: &Value,
|
||||
) -> Result<Value, String> {
|
||||
let usage_data = sub2api_usage_response_object(response_json)?;
|
||||
let is_valid = bool_value_from_candidates(
|
||||
response_json,
|
||||
usage_data,
|
||||
&["is_active", "data.is_active", "isValid", "data.isValid"],
|
||||
)
|
||||
.unwrap_or(true);
|
||||
if !is_valid {
|
||||
return Err(response_json
|
||||
.get("invalidMessage")
|
||||
.or_else(|| response_json.get("message"))
|
||||
.or_else(|| usage_data.get("invalidMessage"))
|
||||
.or_else(|| usage_data.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("API Key 已禁用或无效")
|
||||
.to_string());
|
||||
}
|
||||
|
||||
let remaining = balance_value_from_candidates(
|
||||
response_json,
|
||||
usage_data,
|
||||
action_config,
|
||||
&["remaining_path", "available_path", "balance_path"],
|
||||
&[
|
||||
"remaining",
|
||||
"data.remaining",
|
||||
"quota.remaining",
|
||||
"data.quota.remaining",
|
||||
"balance",
|
||||
"data.balance",
|
||||
],
|
||||
)
|
||||
.ok_or_else(|| "响应格式无效".to_string())?;
|
||||
let currency = string_value_from_candidates(
|
||||
response_json,
|
||||
usage_data,
|
||||
&["unit", "data.unit", "quota.unit", "data.quota.unit"],
|
||||
)
|
||||
.or_else(|| {
|
||||
action_config
|
||||
.get("currency")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let mut extra = Map::new();
|
||||
extra.insert("is_active".to_string(), json!(is_valid));
|
||||
if let Some(quota) = usage_data.get("quota").filter(|value| value.is_object()) {
|
||||
extra.insert("quota".to_string(), quota.clone());
|
||||
}
|
||||
|
||||
Ok(build_balance_data(
|
||||
None,
|
||||
None,
|
||||
Some(remaining),
|
||||
¤cy,
|
||||
extra,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn attach_balance_checkin_outcome(
|
||||
action_payload: &mut Value,
|
||||
outcome: &ProviderOpsCheckinOutcome,
|
||||
@@ -239,316 +171,39 @@ fn parse_new_api_balance_payload(
|
||||
action_config: &Map<String, Value>,
|
||||
response_json: &Value,
|
||||
) -> Result<Value, String> {
|
||||
let user_data = balance_response_object(response_json)?;
|
||||
let quota_divisor = balance_divisor(action_config);
|
||||
let total_available_raw = balance_value_from_candidates(
|
||||
response_json,
|
||||
user_data,
|
||||
action_config,
|
||||
&[
|
||||
"balance_path",
|
||||
"available_path",
|
||||
"total_available_path",
|
||||
"quota_path",
|
||||
],
|
||||
&[
|
||||
"total_available",
|
||||
"data.total_available",
|
||||
"balance",
|
||||
"data.balance",
|
||||
"available",
|
||||
"data.available",
|
||||
"remaining",
|
||||
"data.remaining",
|
||||
"quota",
|
||||
"data.quota",
|
||||
"balance_infos.0.total_balance",
|
||||
"balance_infos[0].total_balance",
|
||||
"data.balance_infos.0.total_balance",
|
||||
"data.balance_infos[0].total_balance",
|
||||
"balance_total",
|
||||
"data.balance_total",
|
||||
"total_balance",
|
||||
"data.total_balance",
|
||||
],
|
||||
);
|
||||
let total_used_raw = balance_value_from_candidates(
|
||||
response_json,
|
||||
user_data,
|
||||
action_config,
|
||||
&[
|
||||
"used_path",
|
||||
"used_quota_path",
|
||||
"spent_path",
|
||||
"usage_path",
|
||||
"total_used_path",
|
||||
],
|
||||
&[
|
||||
"used_quota",
|
||||
"data.used_quota",
|
||||
"used",
|
||||
"data.used",
|
||||
"total_used",
|
||||
"data.total_used",
|
||||
"spent",
|
||||
"data.spent",
|
||||
"usage",
|
||||
"data.usage",
|
||||
],
|
||||
);
|
||||
let total_granted_raw = balance_value_from_candidates(
|
||||
response_json,
|
||||
user_data,
|
||||
action_config,
|
||||
&[
|
||||
"granted_path",
|
||||
"total_granted_path",
|
||||
"limit_path",
|
||||
"total_quota_path",
|
||||
],
|
||||
&[
|
||||
"total_granted",
|
||||
"data.total_granted",
|
||||
"total_quota",
|
||||
"data.total_quota",
|
||||
"granted",
|
||||
"data.granted",
|
||||
"limit",
|
||||
"data.limit",
|
||||
"balance_total",
|
||||
"data.balance_total",
|
||||
"total_balance",
|
||||
"data.total_balance",
|
||||
],
|
||||
);
|
||||
let total_available_raw = total_available_raw.or(match (total_granted_raw, total_used_raw) {
|
||||
(Some(granted), Some(used)) => Some((granted - used).max(0.0)),
|
||||
_ => None,
|
||||
});
|
||||
let total_used_raw = total_used_raw.or(match (total_granted_raw, total_available_raw) {
|
||||
(Some(granted), Some(available)) => Some((granted - available).max(0.0)),
|
||||
_ => None,
|
||||
});
|
||||
let total_granted_raw = total_granted_raw.or(match (total_available_raw, total_used_raw) {
|
||||
(Some(available), Some(used)) => Some(available + used),
|
||||
_ => None,
|
||||
});
|
||||
let mut extra = Map::new();
|
||||
if let Some(plan_name) = new_api_plan_name(user_data) {
|
||||
extra.insert("plan_name".to_string(), json!(plan_name));
|
||||
}
|
||||
let user_data = if response_json.get("success").and_then(Value::as_bool) == Some(true)
|
||||
&& response_json.get("data").is_some_and(Value::is_object)
|
||||
{
|
||||
response_json.get("data")
|
||||
} else if response_json.get("success").and_then(Value::as_bool) == Some(false) {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("业务状态码表示失败")
|
||||
.to_string());
|
||||
} else {
|
||||
Some(response_json)
|
||||
};
|
||||
let Some(user_data) = user_data.and_then(Value::as_object) else {
|
||||
return Err("响应格式无效".to_string());
|
||||
};
|
||||
let quota_divisor = quota_divisor(action_config);
|
||||
let total_available =
|
||||
admin_provider_ops_value_as_f64(user_data.get("quota")).map(|value| value / quota_divisor);
|
||||
let total_used = admin_provider_ops_value_as_f64(user_data.get("used_quota"))
|
||||
.map(|value| value / quota_divisor);
|
||||
Ok(build_balance_data(
|
||||
total_granted_raw.map(|value| value / quota_divisor),
|
||||
total_used_raw.map(|value| value / quota_divisor),
|
||||
total_available_raw.map(|value| value / quota_divisor),
|
||||
None,
|
||||
total_used,
|
||||
total_available,
|
||||
action_config
|
||||
.get("currency")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("USD"),
|
||||
extra,
|
||||
Map::new(),
|
||||
))
|
||||
}
|
||||
|
||||
fn new_api_plan_name(user_data: &Value) -> Option<String> {
|
||||
for key in ["group", "plan_name", "planName", "plan", "package"] {
|
||||
let value = user_data.get(key).and_then(Value::as_str)?.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn balance_response_object(response_json: &Value) -> Result<&Value, String> {
|
||||
if let Some(success) = response_json.get("success").and_then(Value::as_bool) {
|
||||
if !success {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("业务状态码表示失败")
|
||||
.to_string());
|
||||
}
|
||||
if let Some(data) = response_json.get("data").filter(|value| value.is_object()) {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(code) = response_json.get("code").and_then(Value::as_i64) {
|
||||
if code != 0 {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("查询余额失败")
|
||||
.to_string());
|
||||
}
|
||||
if let Some(data) = response_json.get("data").filter(|value| value.is_object()) {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
response_json
|
||||
.as_object()
|
||||
.map(|_| response_json)
|
||||
.ok_or_else(|| "响应格式无效".to_string())
|
||||
}
|
||||
|
||||
fn balance_value_from_candidates(
|
||||
full_response: &Value,
|
||||
response_data: &Value,
|
||||
action_config: &Map<String, Value>,
|
||||
config_keys: &[&str],
|
||||
candidate_paths: &[&str],
|
||||
) -> Option<f64> {
|
||||
for key in config_keys {
|
||||
if let Some(path) = action_config.get(*key).and_then(Value::as_str) {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = balance_value_at_path(full_response, path)
|
||||
.or_else(|| balance_value_at_path(response_data, path))
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for path in candidate_paths {
|
||||
if let Some(value) = balance_value_at_path(full_response, path)
|
||||
.or_else(|| balance_value_at_path(response_data, path))
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn balance_value_at_path(value: &Value, path: &str) -> Option<f64> {
|
||||
let mut current = value;
|
||||
for segment in path
|
||||
.split('.')
|
||||
.map(str::trim)
|
||||
.filter(|segment| !segment.is_empty())
|
||||
{
|
||||
current = value_at_path_segment(current, segment)?;
|
||||
}
|
||||
admin_provider_ops_value_as_f64(Some(current))
|
||||
}
|
||||
|
||||
fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
let mut current = value;
|
||||
for segment in path
|
||||
.split('.')
|
||||
.map(str::trim)
|
||||
.filter(|segment| !segment.is_empty())
|
||||
{
|
||||
current = value_at_path_segment(current, segment)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn value_at_path_segment<'a>(mut current: &'a Value, segment: &str) -> Option<&'a Value> {
|
||||
if !segment.contains('[') {
|
||||
return if current.is_array() {
|
||||
segment
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.and_then(|index| current.get(index))
|
||||
} else {
|
||||
current.get(segment)
|
||||
};
|
||||
}
|
||||
|
||||
let mut rest = segment;
|
||||
if let Some(open) = rest.find('[') {
|
||||
let head = rest[..open].trim();
|
||||
if !head.is_empty() {
|
||||
current = current.get(head)?;
|
||||
}
|
||||
rest = &rest[open..];
|
||||
}
|
||||
|
||||
while let Some(stripped) = rest.strip_prefix('[') {
|
||||
let close = stripped.find(']')?;
|
||||
let index = stripped[..close].trim().parse::<usize>().ok()?;
|
||||
current = current.get(index)?;
|
||||
rest = stripped[close + 1..].trim();
|
||||
if rest.is_empty() {
|
||||
return Some(current);
|
||||
}
|
||||
}
|
||||
|
||||
current.get(rest)
|
||||
}
|
||||
|
||||
fn bool_value_from_candidates(
|
||||
full_response: &Value,
|
||||
response_data: &Value,
|
||||
candidate_paths: &[&str],
|
||||
) -> Option<bool> {
|
||||
for path in candidate_paths {
|
||||
if let Some(value) = value_at_path(full_response, path)
|
||||
.or_else(|| value_at_path(response_data, path))
|
||||
.and_then(Value::as_bool)
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn string_value_from_candidates(
|
||||
full_response: &Value,
|
||||
response_data: &Value,
|
||||
candidate_paths: &[&str],
|
||||
) -> Option<String> {
|
||||
for path in candidate_paths {
|
||||
if let Some(value) = value_at_path(full_response, path)
|
||||
.or_else(|| value_at_path(response_data, path))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn sub2api_usage_response_object(response_json: &Value) -> Result<&Value, String> {
|
||||
if let Some(success) = response_json.get("success").and_then(Value::as_bool) {
|
||||
if !success {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("查询失败")
|
||||
.to_string());
|
||||
}
|
||||
if let Some(data) = response_json.get("data").filter(|value| value.is_object()) {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(code) = response_json.get("code").and_then(Value::as_i64) {
|
||||
if code != 0 {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("查询失败")
|
||||
.to_string());
|
||||
}
|
||||
if let Some(data) = response_json.get("data").filter(|value| value.is_object()) {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
response_json
|
||||
.as_object()
|
||||
.map(|_| response_json)
|
||||
.ok_or_else(|| "响应格式无效".to_string())
|
||||
}
|
||||
|
||||
fn parse_cubence_balance_payload(
|
||||
action_config: &Map<String, Value>,
|
||||
response_json: &Value,
|
||||
@@ -759,12 +414,6 @@ fn quota_divisor(action_config: &Map<String, Value>) -> f64 {
|
||||
.unwrap_or(500000.0)
|
||||
}
|
||||
|
||||
fn balance_divisor(action_config: &Map<String, Value>) -> f64 {
|
||||
admin_provider_ops_value_as_f64(action_config.get("balance_divisor"))
|
||||
.filter(|value| *value > 0.0)
|
||||
.unwrap_or_else(|| quota_divisor(action_config))
|
||||
}
|
||||
|
||||
fn parse_rfc3339_unix_secs(value: Option<&Value>) -> Option<i64> {
|
||||
let raw = value?.as_str()?.trim();
|
||||
if raw.is_empty() {
|
||||
@@ -817,8 +466,7 @@ fn parse_subscription(value: &Value) -> Option<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
attach_balance_checkin_outcome, parse_query_balance_payload,
|
||||
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
|
||||
attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_balance_payload,
|
||||
ProviderOpsCheckinOutcome,
|
||||
};
|
||||
use serde_json::json;
|
||||
@@ -842,109 +490,6 @@ mod tests {
|
||||
assert_eq!(payload["total_used"], json!(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_api_alias_parser_supports_balance_field_fallbacks() {
|
||||
let payload = parse_query_balance_payload(
|
||||
"oneapi",
|
||||
&json!({ "quota_divisor": 1, "currency": "CNY" })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"balance": 12.5,
|
||||
"used": 2.25
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["currency"], json!("CNY"));
|
||||
assert_eq!(payload["total_available"], json!(12.5));
|
||||
assert_eq!(payload["total_used"], json!(2.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_api_parser_supports_total_available_and_total_used_fields() {
|
||||
let payload = parse_query_balance_payload(
|
||||
"new_api",
|
||||
&json!({ "quota_divisor": 1 })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"data": {
|
||||
"total_available": 9.75,
|
||||
"total_used": 1.25
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["total_available"], json!(9.75));
|
||||
assert_eq!(payload["total_used"], json!(1.25));
|
||||
assert_eq!(payload["total_granted"], json!(11.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_api_parser_matches_cc_switch_usage_script_shape() {
|
||||
let payload = parse_query_balance_payload(
|
||||
"new_api",
|
||||
&json!({ "quota_divisor": 500000, "currency": "USD" })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"group": "默认套餐",
|
||||
"quota": 2_500_000,
|
||||
"used_quota": 500_000
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["total_available"], json!(5.0));
|
||||
assert_eq!(payload["total_used"], json!(1.0));
|
||||
assert_eq!(payload["total_granted"], json!(6.0));
|
||||
assert_eq!(payload["currency"], json!("USD"));
|
||||
assert_eq!(payload["extra"]["plan_name"], json!("默认套餐"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_api_parser_supports_deepseek_balance_shape() {
|
||||
let payload = parse_query_balance_payload(
|
||||
"generic_api",
|
||||
&json!({
|
||||
"quota_divisor": 1,
|
||||
"currency": "CNY",
|
||||
"balance_path": "balance_infos[0].total_balance"
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"is_available": true,
|
||||
"balance_infos": [
|
||||
{
|
||||
"currency": "CNY",
|
||||
"total_balance": "128.50",
|
||||
"granted_balance": "8.50",
|
||||
"topped_up_balance": "120.00"
|
||||
}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["currency"], json!("CNY"));
|
||||
assert_eq!(payload["total_available"], json!(128.5));
|
||||
assert_eq!(payload["total_used"], json!(null));
|
||||
assert_eq!(payload["total_granted"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_hub_single_request_parser_reads_wrapped_quota() {
|
||||
let payload = parse_query_balance_payload(
|
||||
@@ -995,28 +540,6 @@ mod tests {
|
||||
assert_eq!(payload["extra"]["active_subscriptions"], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub2api_api_key_usage_parser_matches_cc_switch_shape() {
|
||||
let payload = parse_sub2api_api_key_usage_payload(
|
||||
&json!({ "currency": "USD" })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"is_active": true,
|
||||
"quota": {
|
||||
"remaining": "12.5",
|
||||
"unit": "USD"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["total_available"], json!(12.5));
|
||||
assert_eq!(payload["currency"], json!("USD"));
|
||||
assert_eq!(payload["extra"]["is_active"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubence_parser_reads_wrapped_dashboard_overview() {
|
||||
let payload = parse_query_balance_payload(
|
||||
|
||||
@@ -119,19 +119,12 @@ pub fn get_architecture(architecture_id: &str) -> Option<ProviderOpsArchitecture
|
||||
}
|
||||
|
||||
pub fn normalize_architecture_id(architecture_id: &str) -> &'static str {
|
||||
let compact = architecture_id
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
match compact.as_str() {
|
||||
match architecture_id.trim() {
|
||||
"" => "generic_api",
|
||||
"genericapi" => "generic_api",
|
||||
"newapi" | "oneapi" => "new_api",
|
||||
"generic_api" => "generic_api",
|
||||
"new_api" => "new_api",
|
||||
"cubence" => "cubence",
|
||||
"donehub" => "done_hub",
|
||||
"done_hub" => "done_hub",
|
||||
"yescode" => "yescode",
|
||||
"nekocode" => "nekocode",
|
||||
"anyrouter" => "anyrouter",
|
||||
@@ -143,7 +136,7 @@ pub fn normalize_architecture_id(architecture_id: &str) -> &'static str {
|
||||
pub fn admin_provider_ops_is_supported_auth_type(auth_type: &str) -> bool {
|
||||
matches!(
|
||||
auth_type,
|
||||
"api_key" | "refresh_token" | "session_login" | "oauth" | "cookie" | "none"
|
||||
"api_key" | "session_login" | "oauth" | "cookie" | "none"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -227,8 +220,6 @@ mod tests {
|
||||
assert_eq!(normalize_architecture_id(""), "generic_api");
|
||||
assert_eq!(normalize_architecture_id("done_hub"), "done_hub");
|
||||
assert_eq!(normalize_architecture_id("new_api"), "new_api");
|
||||
assert_eq!(normalize_architecture_id("newapi"), "new_api");
|
||||
assert_eq!(normalize_architecture_id("one-api"), "new_api");
|
||||
assert_eq!(normalize_architecture_id("unknown"), "generic_api");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "访问令牌",
|
||||
"description": "在 New API 个人安全设置中获取的访问令牌,与 Cookie 二选一",
|
||||
"title": "访问令牌 (API Key)",
|
||||
"description": "New API 的访问令牌,与 Cookie 二选一",
|
||||
"x-sensitive": true,
|
||||
"x-input-type": "password"
|
||||
},
|
||||
@@ -30,7 +30,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"title": "用户 ID",
|
||||
"description": "可选;使用 Cookie 时可自动解析"
|
||||
"description": "使用访问令牌时必填,使用 Cookie 时可选"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
@@ -64,6 +64,13 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
"type": "any_required",
|
||||
"fields": ["api_key", "cookie"],
|
||||
"message": "访问令牌和 Cookie 至少需要填写一个"
|
||||
},
|
||||
{
|
||||
"type": "conditional_required",
|
||||
"if": "api_key",
|
||||
"then": ["user_id"],
|
||||
"unless": "cookie",
|
||||
"message": "使用访问令牌时,用户 ID 不能为空"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -5,38 +5,6 @@ use super::{
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
let api_key_usage_schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "API Key",
|
||||
"description": "用于访问 Sub2API /v1/usage 的模型 API Key",
|
||||
"x-sensitive": true,
|
||||
"x-input-type": "password",
|
||||
"x-help": "请求 GET /v1/usage,并通过 Authorization: Bearer <API Key> 查询余量"
|
||||
}
|
||||
},
|
||||
"required": ["api_key"],
|
||||
"x-auth-method": "bearer",
|
||||
"x-auth-type": "api_key",
|
||||
"x-field-groups": [
|
||||
{ "fields": ["base_url"] },
|
||||
{ "fields": ["api_key"] }
|
||||
],
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["api_key"],
|
||||
"message": "请填写 API Key"
|
||||
}
|
||||
]
|
||||
});
|
||||
let session_login_schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -93,7 +61,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
},
|
||||
"required": ["refresh_token"],
|
||||
"x-auth-method": "bearer",
|
||||
"x-auth-type": "refresh_token",
|
||||
"x-auth-type": "api_key",
|
||||
"x-field-groups": [
|
||||
{ "fields": ["base_url"] },
|
||||
{ "fields": ["refresh_token"] }
|
||||
@@ -112,25 +80,20 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
display_name: "Sub2API",
|
||||
description: "Sub2API 风格中转站的预设配置",
|
||||
hidden: false,
|
||||
credentials_schema: api_key_usage_schema.clone(),
|
||||
credentials_schema: session_login_schema.clone(),
|
||||
verify_endpoint: "/api/v1/auth/me?timezone=Asia/Shanghai",
|
||||
verify_mode: ProviderOpsVerifyMode::Sub2ApiExchange,
|
||||
balance_mode: ProviderOpsBalanceMode::Sub2ApiDualRequest,
|
||||
checkin_mode: ProviderOpsCheckinMode::None,
|
||||
query_balance_cookie_auth_errors: false,
|
||||
supported_auth_types: vec![
|
||||
ProviderOpsAuthSpec {
|
||||
auth_type: "api_key",
|
||||
display_name: "API Key 用量接口",
|
||||
credentials_schema: api_key_usage_schema,
|
||||
},
|
||||
ProviderOpsAuthSpec {
|
||||
auth_type: "session_login",
|
||||
display_name: "账号密码",
|
||||
credentials_schema: session_login_schema,
|
||||
},
|
||||
ProviderOpsAuthSpec {
|
||||
auth_type: "refresh_token",
|
||||
auth_type: "api_key",
|
||||
display_name: "Refresh Token",
|
||||
credentials_schema: refresh_token_schema,
|
||||
},
|
||||
@@ -138,7 +101,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
supported_actions: vec![ProviderOpsActionSpec {
|
||||
action_type: "query_balance",
|
||||
display_name: "查询余额",
|
||||
description: "查询 Sub2API API Key 用量或账户余额和订阅信息",
|
||||
description: "查询 Sub2API 账户余额和订阅信息",
|
||||
config_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -151,7 +114,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
"required": []
|
||||
}),
|
||||
}],
|
||||
default_connector: Some("api_key"),
|
||||
default_connector: Some("session_login"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +123,6 @@ pub(super) fn default_action_config(action_type: &str) -> Option<Map<String, Val
|
||||
"query_balance" => Some(json_object(json!({
|
||||
"endpoint": "/api/v1/auth/me?timezone=Asia/Shanghai",
|
||||
"subscription_endpoint": "/api/v1/subscriptions/summary",
|
||||
"api_key_usage_endpoint": "/v1/usage",
|
||||
"method": "GET",
|
||||
"currency": "USD"
|
||||
}))),
|
||||
|
||||
@@ -4,8 +4,7 @@ pub mod config;
|
||||
pub mod verify;
|
||||
|
||||
pub use self::actions::{
|
||||
attach_balance_checkin_outcome, parse_query_balance_payload,
|
||||
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
|
||||
attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_balance_payload,
|
||||
parse_yescode_combined_balance_payload, ProviderOpsCheckinOutcome,
|
||||
};
|
||||
pub use self::architectures::{
|
||||
|
||||
@@ -379,9 +379,18 @@ pub fn admin_provider_ops_verify_headers(
|
||||
}
|
||||
"new_api" => {
|
||||
for (name, value) in [
|
||||
("User-Agent", "cc-switch/1.0"),
|
||||
("Content-Type", "application/json"),
|
||||
(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36",
|
||||
),
|
||||
("Accept", "application/json"),
|
||||
("Accept-Language", "zh-CN"),
|
||||
("sec-ch-ua", "\"Not=A?Brand\";v=\"24\", \"Chromium\";v=\"140\""),
|
||||
("sec-ch-ua-mobile", "?0"),
|
||||
("sec-ch-ua-platform", "\"macOS\""),
|
||||
("Sec-Fetch-Site", "cross-site"),
|
||||
("Sec-Fetch-Mode", "cors"),
|
||||
("Sec-Fetch-Dest", "empty"),
|
||||
] {
|
||||
insert_header(&mut headers, name, value)?;
|
||||
}
|
||||
@@ -788,22 +797,13 @@ pub fn admin_provider_ops_sub2api_verify_payload(
|
||||
}
|
||||
}
|
||||
|
||||
let username_or_email = admin_provider_ops_sub2api_non_empty_string(user_data, "username")
|
||||
.or_else(|| admin_provider_ops_sub2api_non_empty_string(user_data, "email"));
|
||||
admin_provider_ops_verify_success(
|
||||
admin_provider_ops_verify_user_payload(
|
||||
user_data
|
||||
.get("username")
|
||||
.or_else(|| user_data.get("email"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
user_data
|
||||
.get("username")
|
||||
.or_else(|| user_data.get("email"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
user_data
|
||||
.get("email")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
username_or_email.clone(),
|
||||
username_or_email,
|
||||
admin_provider_ops_sub2api_non_empty_string(user_data, "email"),
|
||||
Some(balance + points),
|
||||
Some(extra),
|
||||
),
|
||||
@@ -811,6 +811,17 @@ pub fn admin_provider_ops_sub2api_verify_payload(
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_provider_ops_sub2api_non_empty_string(
|
||||
map: &Map<String, Value>,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
map.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
@@ -904,6 +915,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub2api_verify_payload_falls_back_to_email_when_username_is_null() {
|
||||
let payload = admin_provider_ops_sub2api_verify_payload(
|
||||
StatusCode::OK,
|
||||
&json!({
|
||||
"code": 0,
|
||||
"data": {
|
||||
"username": null,
|
||||
"email": "user@example.com",
|
||||
"balance": 2.0,
|
||||
"points": 0.0
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(payload["success"], json!(true));
|
||||
assert_eq!(payload["data"]["username"], json!("user@example.com"));
|
||||
assert_eq!(payload["data"]["display_name"], json!("user@example.com"));
|
||||
assert_eq!(payload["data"]["email"], json!("user@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anyrouter_verify_payload_uses_cookie_auth_messages_and_usage_fields() {
|
||||
let payload = admin_provider_ops_anyrouter_verify_payload(
|
||||
|
||||
@@ -922,6 +922,317 @@ pub fn parse_kiro_usage_response(
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_user_status_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let user_status = value
|
||||
.get("userStatus")
|
||||
.or_else(|| value.get("user_status"))?;
|
||||
let plan_status = user_status
|
||||
.get("planStatus")
|
||||
.or_else(|| user_status.get("plan_status"))?;
|
||||
let plan_info = plan_status
|
||||
.get("planInfo")
|
||||
.or_else(|| plan_status.get("plan_info"));
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||
|
||||
if let Some(plan_name) = plan_info
|
||||
.and_then(|value| {
|
||||
coerce_json_string(value.get("planName").or_else(|| value.get("plan_name")))
|
||||
})
|
||||
.or_else(|| {
|
||||
coerce_json_string(
|
||||
plan_status
|
||||
.get("planName")
|
||||
.or_else(|| plan_status.get("plan_name")),
|
||||
)
|
||||
})
|
||||
{
|
||||
result.insert("plan_name".to_string(), json!(plan_name));
|
||||
}
|
||||
if let Some(email) = coerce_json_string(user_status.get("email")) {
|
||||
result.insert("email".to_string(), json!(email));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("dailyQuotaRemainingPercent")
|
||||
.or_else(|| plan_status.get("daily_quota_remaining_percent"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
result.insert("daily_remaining_percent".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("weeklyQuotaRemainingPercent")
|
||||
.or_else(|| plan_status.get("weekly_quota_remaining_percent"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
result.insert("weekly_remaining_percent".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("dailyQuotaResetAtUnix")
|
||||
.or_else(|| plan_status.get("daily_quota_reset_at_unix"))
|
||||
.and_then(coerce_json_u64)
|
||||
{
|
||||
result.insert("daily_reset_at".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("weeklyQuotaResetAtUnix")
|
||||
.or_else(|| plan_status.get("weekly_quota_reset_at_unix"))
|
||||
.and_then(coerce_json_u64)
|
||||
{
|
||||
result.insert("weekly_reset_at".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("overageBalanceMicros")
|
||||
.or_else(|| plan_status.get("overage_balance_micros"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
result.insert("overage_balance".to_string(), json!(value / 1_000_000.0));
|
||||
}
|
||||
|
||||
let legacy_credit =
|
||||
|value: Option<&serde_json::Value>| value.and_then(coerce_json_f64).map(|n| n / 100.0);
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("availablePromptCredits")
|
||||
.or_else(|| plan_status.get("available_prompt_credits")),
|
||||
) {
|
||||
result.insert("prompt_remaining".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("usedPromptCredits")
|
||||
.or_else(|| plan_status.get("used_prompt_credits")),
|
||||
) {
|
||||
result.insert("prompt_used".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(plan_info.and_then(|plan_info| {
|
||||
plan_info
|
||||
.get("monthlyPromptCredits")
|
||||
.or_else(|| plan_info.get("monthly_prompt_credits"))
|
||||
})) {
|
||||
result.insert("prompt_limit".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("availableFlexCredits")
|
||||
.or_else(|| plan_status.get("available_flex_credits")),
|
||||
) {
|
||||
result.insert("flex_remaining".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("usedFlexCredits")
|
||||
.or_else(|| plan_status.get("used_flex_credits")),
|
||||
) {
|
||||
result.insert("flex_used".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(plan_info.and_then(|plan_info| {
|
||||
plan_info
|
||||
.get("monthlyFlexCreditPurchaseAmount")
|
||||
.or_else(|| plan_info.get("monthly_flex_credit_purchase_amount"))
|
||||
})) {
|
||||
result.insert("flex_limit".to_string(), json!(value));
|
||||
}
|
||||
|
||||
let mut status_sources = vec![value, user_status, plan_status];
|
||||
if let Some(plan_info) = plan_info {
|
||||
status_sources.push(plan_info);
|
||||
}
|
||||
for (target, aliases) in [
|
||||
(
|
||||
"banned",
|
||||
&[
|
||||
"banned",
|
||||
"isBanned",
|
||||
"is_banned",
|
||||
"accountBanned",
|
||||
"account_banned",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"quarantined",
|
||||
&[
|
||||
"quarantined",
|
||||
"isQuarantined",
|
||||
"is_quarantined",
|
||||
"accountQuarantined",
|
||||
"account_quarantined",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"is_forbidden",
|
||||
&[
|
||||
"isForbidden",
|
||||
"is_forbidden",
|
||||
"forbidden",
|
||||
"accountForbidden",
|
||||
"account_forbidden",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
if let Some(found) = status_sources.iter().find_map(|source| {
|
||||
aliases
|
||||
.iter()
|
||||
.find_map(|alias| source.get(*alias).and_then(coerce_json_bool))
|
||||
}) {
|
||||
result.insert(target.to_string(), json!(found));
|
||||
}
|
||||
}
|
||||
for (target, aliases) in [
|
||||
(
|
||||
"ban_reason",
|
||||
&[
|
||||
"banReason",
|
||||
"ban_reason",
|
||||
"blockedReason",
|
||||
"blocked_reason",
|
||||
"reason",
|
||||
"message",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"quarantine_reason",
|
||||
&["quarantineReason", "quarantine_reason", "reason", "message"][..],
|
||||
),
|
||||
(
|
||||
"forbidden_reason",
|
||||
&["forbiddenReason", "forbidden_reason", "reason", "message"][..],
|
||||
),
|
||||
] {
|
||||
if let Some(found) = status_sources.iter().find_map(|source| {
|
||||
aliases
|
||||
.iter()
|
||||
.find_map(|alias| coerce_json_string(source.get(*alias)))
|
||||
}) {
|
||||
result.insert(target.to_string(), json!(found));
|
||||
}
|
||||
}
|
||||
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_model_configs_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let configs = value
|
||||
.get("clientModelConfigs")
|
||||
.or_else(|| value.get("client_model_configs"))
|
||||
.and_then(serde_json::Value::as_array)?;
|
||||
let mut models = Vec::new();
|
||||
for config in configs {
|
||||
let Some(model_uid) = coerce_json_string(
|
||||
config
|
||||
.get("modelUid")
|
||||
.or_else(|| config.get("model_uid"))
|
||||
.or_else(|| config.get("id"))
|
||||
.or_else(|| config.get("name")),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let mut model = serde_json::Map::new();
|
||||
model.insert("model_uid".to_string(), json!(model_uid));
|
||||
if let Some(label) = coerce_json_string(
|
||||
config
|
||||
.get("label")
|
||||
.or_else(|| config.get("displayName"))
|
||||
.or_else(|| config.get("display_name")),
|
||||
) {
|
||||
model.insert("label".to_string(), json!(label));
|
||||
}
|
||||
if let Some(provider) = coerce_json_string(config.get("provider")) {
|
||||
model.insert("provider".to_string(), json!(provider));
|
||||
}
|
||||
if let Some(value) = config
|
||||
.get("supportsImages")
|
||||
.or_else(|| config.get("supports_images"))
|
||||
.and_then(coerce_json_bool)
|
||||
{
|
||||
model.insert("supports_images".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = config
|
||||
.get("creditMultiplier")
|
||||
.or_else(|| config.get("credit_multiplier"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
model.insert("credit_multiplier".to_string(), json!(value));
|
||||
}
|
||||
models.push(serde_json::Value::Object(model));
|
||||
}
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||
result.insert(
|
||||
"allowed_models_count".to_string(),
|
||||
json!(models.len() as u64),
|
||||
);
|
||||
result.insert("models".to_string(), serde_json::Value::Array(models));
|
||||
if let Some(default_model_uid) = value
|
||||
.get("defaultOverrideModelConfig")
|
||||
.or_else(|| value.get("default_override_model_config"))
|
||||
.and_then(|default_config| {
|
||||
coerce_json_string(
|
||||
default_config
|
||||
.get("modelUid")
|
||||
.or_else(|| default_config.get("model_uid")),
|
||||
)
|
||||
})
|
||||
{
|
||||
result.insert("default_model_uid".to_string(), json!(default_model_uid));
|
||||
}
|
||||
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_rate_limit_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let root = value.as_object()?;
|
||||
if root.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let has_capacity = value
|
||||
.get("hasCapacity")
|
||||
.or_else(|| value.get("has_capacity"))
|
||||
.and_then(coerce_json_bool)
|
||||
.unwrap_or(true);
|
||||
let messages_remaining = value
|
||||
.get("messagesRemaining")
|
||||
.or_else(|| value.get("messages_remaining"))
|
||||
.and_then(coerce_json_f64);
|
||||
let max_messages = value
|
||||
.get("maxMessages")
|
||||
.or_else(|| value.get("max_messages"))
|
||||
.and_then(coerce_json_f64);
|
||||
let retry_after_ms = value
|
||||
.get("retryAfterMs")
|
||||
.or_else(|| value.get("retry_after_ms"))
|
||||
.and_then(coerce_json_u64);
|
||||
|
||||
let limited = !has_capacity || messages_remaining.is_some_and(|value| value <= 0.0);
|
||||
let mut rate_limit = serde_json::Map::new();
|
||||
rate_limit.insert("limited".to_string(), json!(limited));
|
||||
rate_limit.insert("has_capacity".to_string(), json!(has_capacity));
|
||||
if let Some(value) = messages_remaining {
|
||||
rate_limit.insert("messages_remaining".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = max_messages {
|
||||
rate_limit.insert("max_messages".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = retry_after_ms {
|
||||
rate_limit.insert("retry_after_ms".to_string(), json!(value));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"updated_at": updated_at_unix_secs,
|
||||
"rate_limit": rate_limit,
|
||||
}))
|
||||
}
|
||||
|
||||
fn chatgpt_web_quota_feature_name(value: &serde_json::Value) -> Option<String> {
|
||||
coerce_json_string(
|
||||
value
|
||||
@@ -1141,8 +1452,10 @@ mod tests {
|
||||
use super::{
|
||||
codex_build_invalid_state, codex_runtime_invalid_reason,
|
||||
parse_chatgpt_web_conversation_init_response, parse_codex_backend_me_response,
|
||||
parse_codex_wham_usage_response, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
|
||||
OAUTH_REFRESH_FAILED_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
|
||||
parse_codex_wham_usage_response, parse_windsurf_model_configs_response,
|
||||
parse_windsurf_rate_limit_response, parse_windsurf_user_status_response,
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
||||
OAUTH_REQUEST_FAILED_PREFIX,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
@@ -1504,6 +1817,118 @@ mod tests {
|
||||
assert!(parsed.get("secondary_used_percent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_user_status_response() {
|
||||
let parsed = parse_windsurf_user_status_response(
|
||||
&json!({
|
||||
"userStatus": {
|
||||
"email": "windsurf@example.com",
|
||||
"isQuarantined": true,
|
||||
"quarantineReason": "quota review",
|
||||
"planStatus": {
|
||||
"dailyQuotaRemainingPercent": 45.5,
|
||||
"weeklyQuotaRemainingPercent": 80,
|
||||
"dailyQuotaResetAtUnix": "1775553285",
|
||||
"weeklyQuotaResetAtUnix": 1776158085u64,
|
||||
"availablePromptCredits": 900,
|
||||
"usedPromptCredits": 100,
|
||||
"availableFlexCredits": 250,
|
||||
"usedFlexCredits": 50,
|
||||
"overageBalanceMicros": 1250000,
|
||||
"planInfo": {
|
||||
"planName": "Pro",
|
||||
"monthlyPromptCredits": 1000,
|
||||
"monthlyFlexCreditPurchaseAmount": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
1_770_000_000,
|
||||
)
|
||||
.expect("windsurf user status should parse");
|
||||
|
||||
assert_eq!(parsed.get("plan_name"), Some(&json!("Pro")));
|
||||
assert_eq!(parsed.get("daily_remaining_percent"), Some(&json!(45.5)));
|
||||
assert_eq!(parsed.get("weekly_remaining_percent"), Some(&json!(80.0)));
|
||||
assert_eq!(parsed.get("daily_reset_at"), Some(&json!(1_775_553_285u64)));
|
||||
assert_eq!(
|
||||
parsed.get("weekly_reset_at"),
|
||||
Some(&json!(1_776_158_085u64))
|
||||
);
|
||||
assert_eq!(parsed.get("prompt_remaining"), Some(&json!(9.0)));
|
||||
assert_eq!(parsed.get("prompt_used"), Some(&json!(1.0)));
|
||||
assert_eq!(parsed.get("prompt_limit"), Some(&json!(10.0)));
|
||||
assert_eq!(parsed.get("flex_remaining"), Some(&json!(2.5)));
|
||||
assert_eq!(parsed.get("flex_used"), Some(&json!(0.5)));
|
||||
assert_eq!(parsed.get("flex_limit"), Some(&json!(3.0)));
|
||||
assert_eq!(parsed.get("overage_balance"), Some(&json!(1.25)));
|
||||
assert_eq!(parsed.get("email"), Some(&json!("windsurf@example.com")));
|
||||
assert_eq!(parsed.get("quarantined"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
parsed.get("quarantine_reason"),
|
||||
Some(&json!("quota review"))
|
||||
);
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_000u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_model_configs_response() {
|
||||
let parsed = parse_windsurf_model_configs_response(
|
||||
&json!({
|
||||
"clientModelConfigs": [
|
||||
{
|
||||
"modelUid": "claude-sonnet-4-5",
|
||||
"label": "Claude Sonnet 4.5",
|
||||
"provider": "anthropic",
|
||||
"supportsImages": true,
|
||||
"creditMultiplier": 2
|
||||
},
|
||||
{
|
||||
"modelUid": "gpt-5-mini",
|
||||
"label": "GPT-5 mini"
|
||||
}
|
||||
],
|
||||
"defaultOverrideModelConfig": {
|
||||
"modelUid": "claude-sonnet-4-5"
|
||||
}
|
||||
}),
|
||||
1_770_000_100,
|
||||
)
|
||||
.expect("windsurf model configs should parse");
|
||||
|
||||
assert_eq!(parsed.get("allowed_models_count"), Some(&json!(2u64)));
|
||||
assert_eq!(
|
||||
parsed.get("default_model_uid"),
|
||||
Some(&json!("claude-sonnet-4-5"))
|
||||
);
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_100u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_rate_limit_response() {
|
||||
let parsed = parse_windsurf_rate_limit_response(
|
||||
&json!({
|
||||
"hasCapacity": false,
|
||||
"messagesRemaining": 0,
|
||||
"maxMessages": 25,
|
||||
"retryAfterMs": 45000
|
||||
}),
|
||||
1_770_000_200,
|
||||
)
|
||||
.expect("windsurf rate limit should parse");
|
||||
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_200u64)));
|
||||
assert_eq!(parsed.pointer("/rate_limit/limited"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
parsed.pointer("/rate_limit/messages_remaining"),
|
||||
Some(&json!(0.0))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.pointer("/rate_limit/retry_after_ms"),
|
||||
Some(&json!(45000u64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chatgpt_web_image_quota_from_conversation_init() {
|
||||
let parsed = parse_chatgpt_web_conversation_init_response(
|
||||
|
||||
@@ -37,9 +37,27 @@ pub fn provider_oauth_pkce_s256(verifier: &str) -> String {
|
||||
|
||||
pub fn parse_provider_oauth_callback_params(callback_url: &str) -> BTreeMap<String, String> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let Ok(url) = Url::parse(callback_url.trim()) else {
|
||||
let raw_callback_url = callback_url.trim();
|
||||
let parsed_url = Url::parse(raw_callback_url).or_else(|_| {
|
||||
Url::parse(&format!(
|
||||
"https://aether.local/{}",
|
||||
raw_callback_url.trim_start_matches('/')
|
||||
))
|
||||
});
|
||||
let Ok(url) = parsed_url else {
|
||||
return merged;
|
||||
};
|
||||
if url.query().is_none()
|
||||
&& url.fragment().is_none()
|
||||
&& raw_callback_url.contains('=')
|
||||
&& !raw_callback_url.contains("://")
|
||||
{
|
||||
for (key, value) in
|
||||
form_urlencoded::parse(raw_callback_url.trim_start_matches('?').as_bytes())
|
||||
{
|
||||
merged.insert(key.into_owned(), value.into_owned());
|
||||
}
|
||||
}
|
||||
for (key, value) in form_urlencoded::parse(url.query().unwrap_or_default().as_bytes()) {
|
||||
merged.insert(key.into_owned(), value.into_owned());
|
||||
}
|
||||
@@ -377,6 +395,28 @@ mod tests {
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("nonce-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_oauth_callback_params_reads_relative_show_auth_token_url() {
|
||||
let params = parse_provider_oauth_callback_params(
|
||||
"show-auth-token?token=firebase-id-token&state=session-1&provider=google",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
params.get("token").map(String::as_str),
|
||||
Some("firebase-id-token")
|
||||
);
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("session-1"));
|
||||
assert_eq!(params.get("provider").map(String::as_str), Some("google"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_oauth_callback_params_reads_raw_query_string() {
|
||||
let params = parse_provider_oauth_callback_params("token=raw-token&state=session-raw");
|
||||
|
||||
assert_eq!(params.get("token").map(String::as_str), Some("raw-token"));
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("session-raw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_enrichment_extracts_identity_from_openai_claims() {
|
||||
let access_token = sample_unsigned_jwt(json!({
|
||||
|
||||
@@ -40,6 +40,7 @@ const AUTO_REMOVABLE_ACCOUNT_STATE_CODES: &[&str] = &[
|
||||
"account_banned",
|
||||
"account_suspended",
|
||||
"account_disabled",
|
||||
"account_quarantined",
|
||||
"workspace_deactivated",
|
||||
"account_forbidden",
|
||||
];
|
||||
@@ -275,7 +276,7 @@ fn resolve_from_metadata(
|
||||
upstream_metadata: Option<&Value>,
|
||||
) -> Option<PoolAccountState> {
|
||||
for source in metadata_sources(provider_type, upstream_metadata) {
|
||||
if json_bool(source.get("is_banned")) {
|
||||
if json_bool(source.get("is_banned")) || json_bool(source.get("banned")) {
|
||||
let reason = extract_reason(
|
||||
source,
|
||||
&["ban_reason", "forbidden_reason", "reason", "message"],
|
||||
@@ -290,6 +291,18 @@ fn resolve_from_metadata(
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
if json_bool(source.get("is_quarantined")) || json_bool(source.get("quarantined")) {
|
||||
let reason = extract_reason(source, &["quarantine_reason", "reason", "message"])
|
||||
.unwrap_or_else(|| "账号处于隔离状态".to_string());
|
||||
return Some(PoolAccountState {
|
||||
blocked: true,
|
||||
code: Some("account_quarantined".to_string()),
|
||||
label: Some("账号隔离".to_string()),
|
||||
reason: Some(reason),
|
||||
source: Some("metadata".to_string()),
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
if json_bool(source.get("is_forbidden")) || json_bool(source.get("account_disabled")) {
|
||||
let reason = extract_reason(
|
||||
source,
|
||||
@@ -574,6 +587,35 @@ mod tests {
|
||||
assert!(!should_auto_remove_account_state(&state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_windsurf_banned_and_quarantined_metadata_aliases() {
|
||||
let banned = resolve_pool_account_state(
|
||||
Some("windsurf"),
|
||||
Some(&json!({
|
||||
"windsurf": {
|
||||
"banned": true,
|
||||
"reason": "forbidden"
|
||||
}
|
||||
})),
|
||||
None,
|
||||
);
|
||||
assert!(banned.blocked);
|
||||
assert_eq!(banned.code.as_deref(), Some("account_banned"));
|
||||
|
||||
let quarantined = resolve_pool_account_state(
|
||||
Some("windsurf"),
|
||||
Some(&json!({
|
||||
"windsurf": {
|
||||
"quarantined": true
|
||||
}
|
||||
})),
|
||||
None,
|
||||
);
|
||||
assert!(quarantined.blocked);
|
||||
assert_eq!(quarantined.code.as_deref(), Some("account_quarantined"));
|
||||
assert!(should_auto_remove_account_state(&quarantined));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_snapshot_ignores_refresh_failed_as_account_block() {
|
||||
let snapshot = resolve_account_status_snapshot(
|
||||
|
||||
@@ -176,6 +176,44 @@ fn chat_pii_redaction_default_rules() -> serde_json::Value {
|
||||
])
|
||||
}
|
||||
|
||||
fn notification_service_default_items() -> serde_json::Value {
|
||||
json!([
|
||||
{
|
||||
"key": "provider_quota_alert",
|
||||
"name": "号池额度不足",
|
||||
"enabled": true,
|
||||
"channel": "global",
|
||||
"title_template": "",
|
||||
"markdown_template": "",
|
||||
"text_template": "",
|
||||
"user_email_enabled": false,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"key": "provider_pool_abnormal",
|
||||
"name": "号池异常",
|
||||
"enabled": true,
|
||||
"channel": "global",
|
||||
"title_template": "号池异常:{provider_name}",
|
||||
"markdown_template": "号池 `{provider_name}` 出现异常,请检查服务状态。",
|
||||
"text_template": "号池 {provider_name} 出现异常,请检查服务状态。",
|
||||
"user_email_enabled": false,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"key": "user_balance_low",
|
||||
"name": "用户余额不足",
|
||||
"enabled": true,
|
||||
"channel": "email",
|
||||
"title_template": "余额不足提醒",
|
||||
"markdown_template": "你的账户余额已低于提醒阈值,请及时处理。",
|
||||
"text_template": "你的账户余额已低于提醒阈值,请及时处理。",
|
||||
"user_email_enabled": true,
|
||||
"system": true
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
fn normalize_chat_pii_redaction_placeholder_prefix(raw: &str) -> Option<String> {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() || value.len() > 32 {
|
||||
@@ -669,7 +707,14 @@ struct AdminApiFormatDefinition {
|
||||
|
||||
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
|
||||
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
|
||||
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &["smtp_password", "turnstile_secret_key"];
|
||||
const DEFAULT_BARK_API_BASE: &str = "https://api.day.app";
|
||||
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &[
|
||||
"smtp_password",
|
||||
"turnstile_secret_key",
|
||||
"module.server_chan_push.send_key",
|
||||
"module.important_notification.server_chan_send_key",
|
||||
"module.bark_push.device_key",
|
||||
];
|
||||
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
||||
AdminApiFormatDefinition {
|
||||
value: "openai:chat",
|
||||
@@ -1219,7 +1264,9 @@ pub fn build_admin_module_validation_result(
|
||||
oauth_providers: &[StoredOAuthProviderModuleConfig],
|
||||
ldap_config: Option<&StoredLdapModuleConfig>,
|
||||
gemini_files_has_capable_key: bool,
|
||||
smtp_configured: bool,
|
||||
important_notification_configured: bool,
|
||||
server_chan_push_configured: bool,
|
||||
bark_push_configured: bool,
|
||||
) -> (bool, Option<String>) {
|
||||
match module_name {
|
||||
"oauth" => {
|
||||
@@ -1290,11 +1337,25 @@ pub fn build_admin_module_validation_result(
|
||||
}
|
||||
(true, None)
|
||||
}
|
||||
"notification_email" => {
|
||||
if smtp_configured {
|
||||
"important_notification" | "notification_email" => {
|
||||
if important_notification_configured {
|
||||
(true, None)
|
||||
} else {
|
||||
(false, Some("请先完成邮件配置(SMTP)".to_string()))
|
||||
(false, Some("请先完成通知服务推送渠道配置".to_string()))
|
||||
}
|
||||
}
|
||||
"server_chan_push" => {
|
||||
if server_chan_push_configured {
|
||||
(true, None)
|
||||
} else {
|
||||
(false, Some("请先配置 Server 酱 SendKey".to_string()))
|
||||
}
|
||||
}
|
||||
"bark_push" => {
|
||||
if bark_push_configured {
|
||||
(true, None)
|
||||
} else {
|
||||
(false, Some("请先配置 Bark Device Key".to_string()))
|
||||
}
|
||||
}
|
||||
"gemini_files" => {
|
||||
@@ -1317,7 +1378,12 @@ pub fn build_admin_module_health(
|
||||
gemini_files_has_capable_key: bool,
|
||||
) -> &'static str {
|
||||
match module_name {
|
||||
"management_tokens" | "model_directives" | "proxy_nodes" => "healthy",
|
||||
"management_tokens"
|
||||
| "model_directives"
|
||||
| "proxy_nodes"
|
||||
| "important_notification"
|
||||
| "bark_push"
|
||||
| "server_chan_push" => "healthy",
|
||||
"gemini_files" => {
|
||||
if gemini_files_has_capable_key {
|
||||
"healthy"
|
||||
@@ -1494,6 +1560,14 @@ pub fn normalize_admin_system_config_key(requested_key: &str) -> String {
|
||||
let trimmed = requested_key.trim();
|
||||
if trimmed.eq_ignore_ascii_case(LEGACY_REQUEST_LOG_LEVEL_KEY) {
|
||||
REQUEST_RECORD_LEVEL_KEY.to_string()
|
||||
} else if trimmed.eq_ignore_ascii_case("module.notification_email.enabled") {
|
||||
"module.important_notification.enabled".to_string()
|
||||
} else if trimmed.eq_ignore_ascii_case("module.important_notification.server_chan_enabled") {
|
||||
"module.server_chan_push.enabled".to_string()
|
||||
} else if trimmed.eq_ignore_ascii_case("module.important_notification.server_chan_send_key") {
|
||||
"module.server_chan_push.send_key".to_string()
|
||||
} else if trimmed.eq_ignore_ascii_case("module.important_notification.server_chan_template") {
|
||||
"module.server_chan_push.template".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
@@ -1506,6 +1580,26 @@ pub fn admin_system_config_delete_keys(requested_key: &str) -> Vec<String> {
|
||||
REQUEST_RECORD_LEVEL_KEY.to_string(),
|
||||
LEGACY_REQUEST_LOG_LEVEL_KEY.to_string(),
|
||||
]
|
||||
} else if normalized == "module.important_notification.enabled" {
|
||||
vec![
|
||||
"module.important_notification.enabled".to_string(),
|
||||
"module.notification_email.enabled".to_string(),
|
||||
]
|
||||
} else if normalized == "module.server_chan_push.enabled" {
|
||||
vec![
|
||||
"module.server_chan_push.enabled".to_string(),
|
||||
"module.important_notification.server_chan_enabled".to_string(),
|
||||
]
|
||||
} else if normalized == "module.server_chan_push.send_key" {
|
||||
vec![
|
||||
"module.server_chan_push.send_key".to_string(),
|
||||
"module.important_notification.server_chan_send_key".to_string(),
|
||||
]
|
||||
} else if normalized == "module.server_chan_push.template" {
|
||||
vec![
|
||||
"module.server_chan_push.template".to_string(),
|
||||
"module.important_notification.server_chan_template".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![normalized]
|
||||
}
|
||||
@@ -1627,6 +1721,18 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
|
||||
"smtp_from_email" => Some(serde_json::Value::Null),
|
||||
"smtp_from_name" => Some(json!("Aether")),
|
||||
"enable_oauth_token_refresh" => Some(json!(true)),
|
||||
"module.important_notification.enabled" => Some(json!(false)),
|
||||
"module.important_notification.email_enabled" => Some(json!(false)),
|
||||
"module.important_notification.email_recipients" => Some(json!("")),
|
||||
"module.important_notification.default_channel" => Some(json!("all")),
|
||||
"module.important_notification.items" => Some(notification_service_default_items()),
|
||||
"module.server_chan_push.enabled" => Some(json!(false)),
|
||||
"module.server_chan_push.send_key" => Some(serde_json::Value::Null),
|
||||
"module.server_chan_push.template" => Some(json!("")),
|
||||
"module.bark_push.enabled" => Some(json!(false)),
|
||||
"module.bark_push.device_key" => Some(serde_json::Value::Null),
|
||||
"module.bark_push.server_url" => Some(json!(DEFAULT_BARK_API_BASE)),
|
||||
"module.bark_push.template" => Some(json!("")),
|
||||
"module.chat_pii_redaction.enabled" => Some(json!(false)),
|
||||
"module.chat_pii_redaction.rules" => Some(chat_pii_redaction_default_rules()),
|
||||
"module.chat_pii_redaction.cache_ttl_seconds" => Some(json!(300)),
|
||||
@@ -1777,6 +1883,183 @@ fn normalize_chat_pii_redaction_rule_features(
|
||||
Ok(Value::Object(features))
|
||||
}
|
||||
|
||||
fn normalize_string_list_config_value(value: serde_json::Value) -> Result<serde_json::Value, ()> {
|
||||
match value {
|
||||
Value::Null => Ok(json!("")),
|
||||
Value::String(raw) => Ok(json!(raw.trim())),
|
||||
Value::Array(items) => {
|
||||
let mut normalized = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let Some(raw) = item.as_str() else {
|
||||
return Err(());
|
||||
};
|
||||
let raw = raw.trim();
|
||||
if !raw.is_empty() {
|
||||
normalized.push(raw.to_string());
|
||||
}
|
||||
}
|
||||
Ok(json!(normalized))
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_nullable_string_config_value(
|
||||
value: serde_json::Value,
|
||||
) -> Result<serde_json::Value, ()> {
|
||||
match value {
|
||||
Value::Null => Ok(Value::Null),
|
||||
Value::String(raw) => {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
Ok(json!(raw))
|
||||
}
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_bark_server_url_config_value(
|
||||
value: serde_json::Value,
|
||||
) -> Result<serde_json::Value, ()> {
|
||||
match value {
|
||||
Value::Null => Ok(json!(DEFAULT_BARK_API_BASE)),
|
||||
Value::String(raw) => {
|
||||
let raw = raw.trim().trim_end_matches('/');
|
||||
if raw.is_empty() {
|
||||
return Ok(json!(DEFAULT_BARK_API_BASE));
|
||||
}
|
||||
if !raw.starts_with("https://") && !raw.starts_with("http://") {
|
||||
return Err(());
|
||||
}
|
||||
Ok(json!(raw))
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_notification_channel_value(value: serde_json::Value) -> Result<serde_json::Value, ()> {
|
||||
match value {
|
||||
Value::Null => Ok(json!("all")),
|
||||
Value::String(raw) => {
|
||||
let normalized = normalize_notification_channel(raw.trim(), false)?;
|
||||
Ok(json!(normalized))
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_notification_channel(raw: &str, allow_global: bool) -> Result<&'static str, ()> {
|
||||
match raw.to_ascii_lowercase().as_str() {
|
||||
"all" => Ok("all"),
|
||||
"email" => Ok("email"),
|
||||
"server_chan" | "serverchan" | "serve_chan" => Ok("server_chan"),
|
||||
"bark" => Ok("bark"),
|
||||
"global" | "" if allow_global => Ok("global"),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_notification_service_items_value(
|
||||
value: serde_json::Value,
|
||||
) -> Result<serde_json::Value, ()> {
|
||||
let Value::Array(items) = value else {
|
||||
return Err(());
|
||||
};
|
||||
let mut normalized_items = Vec::with_capacity(items.len());
|
||||
let mut keys = BTreeSet::new();
|
||||
for item in items {
|
||||
let Value::Object(raw_item) = item else {
|
||||
return Err(());
|
||||
};
|
||||
let key = normalize_notification_item_key(raw_item.get("key"))?;
|
||||
if !keys.insert(key.clone()) {
|
||||
return Err(());
|
||||
}
|
||||
let name = normalize_optional_bounded_string(raw_item.get("name"), 80)?
|
||||
.unwrap_or_else(|| key.clone());
|
||||
let enabled = raw_item
|
||||
.get("enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let channel = raw_item
|
||||
.get("channel")
|
||||
.and_then(Value::as_str)
|
||||
.map(|raw| normalize_notification_channel(raw.trim(), true))
|
||||
.transpose()?
|
||||
.unwrap_or("global");
|
||||
let title_template =
|
||||
normalize_optional_bounded_string(raw_item.get("title_template"), 256)?
|
||||
.unwrap_or_default();
|
||||
let markdown_template =
|
||||
normalize_optional_bounded_string(raw_item.get("markdown_template"), 8_000)?
|
||||
.unwrap_or_default();
|
||||
let text_template =
|
||||
normalize_optional_bounded_string(raw_item.get("text_template"), 8_000)?
|
||||
.unwrap_or_default();
|
||||
let user_email_enabled = raw_item
|
||||
.get("user_email_enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let system = raw_item
|
||||
.get("system")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
normalized_items.push(json!({
|
||||
"key": key,
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"channel": channel,
|
||||
"title_template": title_template,
|
||||
"markdown_template": markdown_template,
|
||||
"text_template": text_template,
|
||||
"user_email_enabled": user_email_enabled,
|
||||
"system": system,
|
||||
}));
|
||||
}
|
||||
Ok(Value::Array(normalized_items))
|
||||
}
|
||||
|
||||
fn normalize_notification_item_key(value: Option<&Value>) -> Result<String, ()> {
|
||||
let Some(raw) = value.and_then(Value::as_str).map(str::trim) else {
|
||||
return Err(());
|
||||
};
|
||||
if raw.is_empty() || raw.len() > 64 {
|
||||
return Err(());
|
||||
}
|
||||
if !raw
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':'))
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
Ok(raw.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_bounded_string(
|
||||
value: Option<&Value>,
|
||||
max_len: usize,
|
||||
) -> Result<Option<String>, ()> {
|
||||
match value {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(raw)) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.len() > max_len {
|
||||
return Err(());
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
Some(_) => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_admin_system_config_update(
|
||||
requested_key: &str,
|
||||
request_body: &[u8],
|
||||
@@ -1830,6 +2113,97 @@ pub fn parse_admin_system_config_update(
|
||||
}
|
||||
|
||||
match normalized_key.as_str() {
|
||||
"module.important_notification.enabled"
|
||||
| "module.important_notification.email_enabled"
|
||||
| "module.server_chan_push.enabled"
|
||||
| "module.bark_push.enabled" => match value.as_bool() {
|
||||
Some(enabled) => value = json!(enabled),
|
||||
None if value.is_null() => {
|
||||
value = admin_system_config_default_value(&normalized_key).unwrap_or(json!(false));
|
||||
}
|
||||
None => {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
}
|
||||
},
|
||||
"module.important_notification.email_recipients" => {
|
||||
value = normalize_string_list_config_value(value).map_err(|_| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
"module.important_notification.default_channel" => {
|
||||
value = normalize_notification_channel_value(value).map_err(|_| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
"module.important_notification.items" => {
|
||||
if value.is_null() {
|
||||
value = notification_service_default_items();
|
||||
} else {
|
||||
value = normalize_notification_service_items_value(value).map_err(|_| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
"module.server_chan_push.send_key" => {
|
||||
value = normalize_nullable_string_config_value(value).map_err(|_| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
"module.server_chan_push.template" => {
|
||||
value = match value {
|
||||
Value::Null => json!(""),
|
||||
Value::String(raw) => json!(raw),
|
||||
_ => {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
"module.bark_push.device_key" => {
|
||||
value = normalize_nullable_string_config_value(value).map_err(|_| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
"module.bark_push.server_url" => {
|
||||
value = normalize_bark_server_url_config_value(value).map_err(|_| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
"module.bark_push.template" => {
|
||||
value = match value {
|
||||
Value::Null => json!(""),
|
||||
Value::String(raw) => json!(raw),
|
||||
_ => {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
"module.chat_pii_redaction.enabled" => match value.as_bool() {
|
||||
Some(enabled) => value = json!(enabled),
|
||||
None if value.is_null() => {
|
||||
@@ -2918,9 +3292,97 @@ mod tests {
|
||||
assert!(is_sensitive_admin_system_config_key("SMTP_PASSWORD"));
|
||||
assert!(is_sensitive_admin_system_config_key("turnstile_secret_key"));
|
||||
assert!(is_sensitive_admin_system_config_key("TURNSTILE_SECRET_KEY"));
|
||||
assert!(is_sensitive_admin_system_config_key(
|
||||
"module.server_chan_push.send_key"
|
||||
));
|
||||
assert!(is_sensitive_admin_system_config_key(
|
||||
"module.important_notification.server_chan_send_key"
|
||||
));
|
||||
assert!(is_sensitive_admin_system_config_key(
|
||||
"module.bark_push.device_key"
|
||||
));
|
||||
assert!(!is_sensitive_admin_system_config_key("site_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_notification_email_config_key_normalizes_to_important_notification() {
|
||||
assert_eq!(
|
||||
normalize_admin_system_config_key("module.notification_email.enabled"),
|
||||
"module.important_notification.enabled"
|
||||
);
|
||||
assert_eq!(
|
||||
admin_system_config_delete_keys("module.important_notification.enabled"),
|
||||
vec![
|
||||
"module.important_notification.enabled".to_string(),
|
||||
"module.notification_email.enabled".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_server_chan_config_keys_normalize_to_push_module() {
|
||||
assert_eq!(
|
||||
normalize_admin_system_config_key("module.important_notification.server_chan_send_key"),
|
||||
"module.server_chan_push.send_key"
|
||||
);
|
||||
assert_eq!(
|
||||
admin_system_config_delete_keys("module.server_chan_push.send_key"),
|
||||
vec![
|
||||
"module.server_chan_push.send_key".to_string(),
|
||||
"module.important_notification.server_chan_send_key".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_service_items_are_normalized() {
|
||||
let update = parse_admin_system_config_update(
|
||||
"module.important_notification.items",
|
||||
r#"{
|
||||
"value": [
|
||||
{
|
||||
"key": "user_balance_low",
|
||||
"name": " 用户余额不足 ",
|
||||
"enabled": true,
|
||||
"channel": "serverchan",
|
||||
"title_template": " 余额提醒 ",
|
||||
"markdown_template": " {body} ",
|
||||
"text_template": null,
|
||||
"user_email_enabled": true,
|
||||
"system": true
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("items should parse");
|
||||
|
||||
assert_eq!(update.normalized_key, "module.important_notification.items");
|
||||
assert_eq!(update.value[0]["channel"], json!("server_chan"));
|
||||
assert_eq!(update.value[0]["name"], json!("用户余额不足"));
|
||||
assert_eq!(update.value[0]["text_template"], json!(""));
|
||||
assert_eq!(update.value[0]["user_email_enabled"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_push_config_values_are_normalized() {
|
||||
let update = parse_admin_system_config_update(
|
||||
"module.bark_push.server_url",
|
||||
r#"{ "value": " https://api.day.app/ " }"#.as_bytes(),
|
||||
)
|
||||
.expect("server url should parse");
|
||||
|
||||
assert_eq!(update.normalized_key, "module.bark_push.server_url");
|
||||
assert_eq!(update.value, json!("https://api.day.app"));
|
||||
|
||||
let err = parse_admin_system_config_update(
|
||||
"module.bark_push.server_url",
|
||||
r#"{ "value": "api.day.app" }"#.as_bytes(),
|
||||
)
|
||||
.expect_err("server url without scheme should fail");
|
||||
assert_eq!(err.0, http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_admin_system_config_detail_masks_turnstile_secret_key() {
|
||||
let payload = build_admin_system_config_detail_payload(
|
||||
|
||||
Reference in New Issue
Block a user