Merge remote-tracking branch 'origin/main'

This commit is contained in:
fawney19
2026-05-22 14:16:07 +08:00
23 changed files with 1550 additions and 53 deletions

View File

@@ -705,11 +705,13 @@ struct AdminApiFormatDefinition {
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
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 {
@@ -1207,6 +1209,7 @@ pub fn build_admin_module_validation_result(
gemini_files_has_capable_key: bool,
important_notification_configured: bool,
server_chan_push_configured: bool,
bark_push_configured: bool,
) -> (bool, Option<String>) {
match module_name {
"oauth" => {
@@ -1291,6 +1294,13 @@ pub fn build_admin_module_validation_result(
(false, Some("请先配置 Server 酱 SendKey".to_string()))
}
}
"bark_push" => {
if bark_push_configured {
(true, None)
} else {
(false, Some("请先配置 Bark Device Key".to_string()))
}
}
"gemini_files" => {
if gemini_files_has_capable_key {
(true, None)
@@ -1315,6 +1325,7 @@ pub fn build_admin_module_health(
| "model_directives"
| "proxy_nodes"
| "important_notification"
| "bark_push"
| "server_chan_push" => "healthy",
"gemini_files" => {
if gemini_files_has_capable_key {
@@ -1661,6 +1672,10 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
"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)),
@@ -1849,6 +1864,25 @@ fn normalize_nullable_string_config_value(
}
}
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")),
@@ -1865,6 +1899,7 @@ fn normalize_notification_channel(raw: &str, allow_global: bool) -> Result<&'sta
"all" => Ok("all"),
"email" => Ok("email"),
"server_chan" | "serverchan" | "serve_chan" => Ok("server_chan"),
"bark" => Ok("bark"),
"global" | "" if allow_global => Ok("global"),
_ => Err(()),
}
@@ -2023,7 +2058,8 @@ 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" => match value.as_bool() {
| "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));
@@ -2083,6 +2119,34 @@ pub fn parse_admin_system_config_update(
}
};
}
"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() => {
@@ -3145,6 +3209,9 @@ mod tests {
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"));
}
@@ -3208,6 +3275,25 @@ mod tests {
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(

View File

@@ -1,3 +1,5 @@
use std::borrow::Cow;
use aether_ai_formats::formats::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_responses_request,
@@ -8,6 +10,20 @@ use serde_json::{json, Value};
use crate::formats::shared::model_directives::apply_model_directive_overrides_from_request;
fn is_responses_shaped_body_on_chat_endpoint(body_json: &Value) -> bool {
body_json
.as_object()
.is_some_and(|object| !object.contains_key("messages") && object.contains_key("input"))
}
fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option<Cow<'_, Value>> {
if is_responses_shaped_body_on_chat_endpoint(body_json) {
return normalize_openai_responses_request_to_openai_chat_request(body_json)
.map(Cow::Owned);
}
Some(Cow::Borrowed(body_json))
}
pub fn build_local_openai_chat_request_body(
body_json: &Value,
mapped_model: &str,
@@ -27,7 +43,8 @@ pub fn build_local_openai_chat_request_body_with_model_directives(
upstream_is_stream: bool,
enable_model_directives: bool,
) -> Option<Value> {
let request_body_object = body_json.as_object()?;
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
let request_body_object = chat_body.as_object()?;
let mut provider_request_body = serde_json::Map::from_iter(
request_body_object
.iter()
@@ -94,24 +111,39 @@ pub fn build_cross_format_openai_chat_request_body_with_model_directives(
) -> Option<Value> {
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
let provider_request_body = match conversion_kind {
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
body_json,
mapped_model,
upstream_is_stream,
)?,
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
body_json,
mapped_model,
upstream_is_stream,
)?,
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
body_json,
RequestConversionKind::ToClaudeStandard => {
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
convert_openai_chat_request_to_claude_request(
chat_body.as_ref(),
mapped_model,
upstream_is_stream,
false,
)?
}
RequestConversionKind::ToGeminiStandard => {
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
convert_openai_chat_request_to_gemini_request(
chat_body.as_ref(),
mapped_model,
upstream_is_stream,
)?
}
RequestConversionKind::ToOpenAiResponses => {
if is_responses_shaped_body_on_chat_endpoint(body_json) {
build_local_openai_responses_request_body_with_model_directives(
body_json,
mapped_model,
upstream_is_stream,
enable_model_directives,
)?
} else {
convert_openai_chat_request_to_openai_responses_request(
body_json,
mapped_model,
upstream_is_stream,
false,
)?
}
}
_ => return None,
};
let mut provider_request_body = with_model_directive_overrides(
@@ -342,6 +374,111 @@ mod tests {
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
}
#[test]
fn local_openai_chat_request_body_accepts_responses_shape_from_chat_endpoint() {
let body_json = json!({
"model": "gpt-5",
"stream": true,
"input": [{"role": "user", "content": "hello"}],
"tools": [{
"type": "function",
"name": "Shell",
"parameters": {"type": "object"},
"strict": false
}],
"reasoning": {"effort": "high"}
});
let provider_request_body =
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
.expect("responses-shaped chat body should build as chat");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["messages"][0]["role"], "user");
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
assert_eq!(
provider_request_body["tools"][0]["function"]["name"],
"Shell"
);
assert_eq!(provider_request_body["reasoning_effort"], "high");
assert_eq!(provider_request_body["stream"], true);
assert_eq!(
provider_request_body["stream_options"]["include_usage"],
true
);
}
#[test]
fn cross_format_openai_chat_request_body_preserves_responses_shape_for_responses_target() {
let body_json = json!({
"model": "gpt-5",
"stream": true,
"input": [{"role": "user", "content": "hello"}],
"include": ["reasoning.encrypted_content"],
"stream_options": {"include_usage": true},
"tools": [{
"type": "function",
"name": "Shell",
"parameters": {"type": "object"},
"strict": false
}, {
"type": "function",
"parameters": {"type": "object"}
}]
});
let provider_request_body =
build_cross_format_openai_chat_request_body_with_model_directives(
&body_json,
"gpt-5-upstream",
"openai:responses",
false,
false,
)
.expect("responses-shaped chat body should build as responses");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["input"][0]["role"], "user");
assert_eq!(provider_request_body["input"][0]["content"], "hello");
assert_eq!(provider_request_body["tools"][0]["name"], "Shell");
assert_eq!(provider_request_body["tools"][0]["strict"], false);
assert_eq!(provider_request_body["tools"][1]["type"], "function");
assert_eq!(
provider_request_body["include"][0],
"reasoning.encrypted_content"
);
assert_eq!(
provider_request_body["stream_options"]["include_usage"],
true
);
assert_eq!(provider_request_body["stream"], false);
assert!(provider_request_body.get("messages").is_none());
}
#[test]
fn openai_chat_request_body_prefers_messages_when_messages_and_input_are_both_present() {
let body_json = json!({
"model": "gpt-5",
"messages": [{"role": "user", "content": "from messages"}],
"input": [{"role": "user", "content": "from input"}]
});
let provider_request_body =
build_cross_format_openai_chat_request_body_with_model_directives(
&body_json,
"gpt-5-upstream",
"openai:responses",
false,
false,
)
.expect("normal chat body should still use messages");
assert_eq!(
provider_request_body["input"][0]["content"][0]["text"],
"from messages"
);
}
#[test]
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
let body_json = json!({

View File

@@ -187,6 +187,7 @@ pub struct TerminalUsageSeed {
pub is_stream: bool,
pub status_code: u16,
pub terminal_error_message: Option<String>,
pub terminal_failure_category: Option<String>,
pub response_time_ms: Option<u64>,
pub first_byte_time_ms: Option<u64>,
pub request_headers: Option<Value>,
@@ -511,6 +512,7 @@ fn build_terminal_usage_event_from_seed_impl(
is_stream,
status_code,
terminal_error_message,
terminal_failure_category,
response_time_ms,
first_byte_time_ms,
request_headers,
@@ -579,7 +581,12 @@ fn build_terminal_usage_event_from_seed_impl(
is_stream: Some(is_stream),
status_code: Some(status_code),
error_message,
error_category: resolve_error_category(status_code, event_type),
error_category: resolve_error_category(
status_code,
event_type,
is_stream,
terminal_failure_category.as_deref(),
),
response_time_ms,
first_byte_time_ms,
request_headers,
@@ -868,6 +875,7 @@ pub fn build_sync_terminal_usage_seed(
is_stream: context_seed.is_stream,
status_code,
terminal_error_message: None,
terminal_failure_category: None,
response_time_ms,
first_byte_time_ms,
request_headers: context_seed.request_headers,
@@ -906,8 +914,8 @@ pub fn build_stream_terminal_usage_seed(
client_response_headers,
provider_response_full,
provider_response_body_state,
client_response,
client_response_body_state,
mut client_response,
mut client_response_body_state,
standardized_usage,
observed_stream_finish,
terminal_error_message,
@@ -933,6 +941,31 @@ pub fn build_stream_terminal_usage_seed(
.as_ref()
.and_then(extract_explicit_error_message_from_json)
});
let terminal_failure_category = if terminal_error_message.is_some() {
Some("stream_terminal_error".to_string())
} else if missing_observed_finish {
Some("stream_missing_terminal_event".to_string())
} else {
None
};
let terminal_error_message = terminal_error_message.or_else(|| {
missing_observed_finish
.then(|| "execution runtime stream ended before provider terminal event".to_string())
});
if client_response.is_none() {
if let (Some(message), Some(category)) = (
terminal_error_message.as_deref(),
terminal_failure_category.as_deref(),
) {
client_response = Some(build_stream_terminal_error_client_response(
category,
message,
status_code,
provider_response_full.as_ref(),
));
client_response_body_state = Some(UsageBodyCaptureState::Inline);
}
}
let terminal_state = infer_stream_terminal_state(
report_kind.as_str(),
status_code,
@@ -963,6 +996,7 @@ pub fn build_stream_terminal_usage_seed(
is_stream: context_seed.is_stream,
status_code,
terminal_error_message,
terminal_failure_category,
response_time_ms,
first_byte_time_ms,
request_headers: context_seed.request_headers,
@@ -2144,17 +2178,77 @@ fn is_sensitive_body_key(key: &str) -> bool {
|| normalized == "cookie"
}
fn resolve_error_category(status_code: u16, event_type: UsageEventType) -> Option<String> {
fn resolve_error_category(
status_code: u16,
event_type: UsageEventType,
is_stream: bool,
terminal_failure_category: Option<&str>,
) -> Option<String> {
match event_type {
UsageEventType::Cancelled => Some("cancelled".to_string()),
UsageEventType::Failed if status_code >= 500 => Some("server_error".to_string()),
UsageEventType::Failed if status_code >= 400 => Some("client_error".to_string()),
UsageEventType::Failed if status_code >= 300 => Some("redirect".to_string()),
UsageEventType::Failed if (200..300).contains(&status_code) => terminal_failure_category
.map(ToOwned::to_owned)
.or_else(|| is_stream.then(|| "stream_terminal_error".to_string()))
.or_else(|| Some("non_success_status".to_string())),
UsageEventType::Failed => Some("non_success_status".to_string()),
_ => None,
}
}
fn build_stream_terminal_error_client_response(
category: &str,
message: &str,
status_code: u16,
provider_response: Option<&Value>,
) -> Value {
let mut error = provider_response
.and_then(extract_error_object_from_json)
.unwrap_or_default();
error
.entry("type".to_string())
.or_insert_with(|| Value::String(category.to_string()));
error
.entry("message".to_string())
.or_insert_with(|| Value::String(message.to_string()));
error
.entry("upstream_status".to_string())
.or_insert_with(|| Value::from(status_code));
json!({ "error": Value::Object(error) })
}
fn extract_error_object_from_json(value: &Value) -> Option<Map<String, Value>> {
value
.get("error")
.and_then(value_to_error_object)
.or_else(|| {
value
.get("response")
.and_then(|response| response.get("error"))
.and_then(value_to_error_object)
})
.or_else(|| {
value
.get("chunks")
.and_then(Value::as_array)
.and_then(|chunks| chunks.iter().find_map(extract_error_object_from_json))
})
}
fn value_to_error_object(value: &Value) -> Option<Map<String, Value>> {
match value {
Value::Object(object) => Some(object.clone()),
Value::String(message) if !message.trim().is_empty() => Some(Map::from_iter([(
"message".to_string(),
Value::String(message.trim().to_string()),
)])),
_ => None,
}
}
fn resolve_error_message(
status_code: u16,
body_json: Option<&Value>,
@@ -3841,12 +3935,112 @@ mod tests {
assert_eq!(event.data.status_code, Some(200));
assert_eq!(
event.data.error_category.as_deref(),
Some("non_success_status")
Some("stream_missing_terminal_event")
);
assert_eq!(
event.data.error_message.as_deref(),
Some("execution runtime stream ended before provider terminal event")
);
assert_eq!(event.data.input_tokens, None);
assert_eq!(event.data.output_tokens, None);
}
#[test]
fn stream_terminal_usage_marks_http_200_response_failed_as_stream_terminal_error() {
let plan = ExecutionPlan {
request_id: "req-stream-response-failed-1".to_string(),
candidate_id: Some("cand-stream-response-failed-1".to_string()),
provider_name: Some("OpenAI".to_string()),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
method: "POST".to_string(),
url: "https://example.com/v1/responses".to_string(),
headers: BTreeMap::new(),
content_type: None,
content_encoding: None,
body: RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
stream: true,
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("gpt-5.5".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
};
let message = "This content was flagged for possible cybersecurity risk";
let provider_sse = format!(
concat!(
"event: response.failed\n",
"data: {{\"type\":\"response.failed\",\"response\":{{\"status\":\"failed\",\"error\":{{\"message\":\"{}\",\"code\":\"cyber_policy\"}}}}}}\n\n"
),
message
);
let payload = GatewayStreamReportRequest {
trace_id: "trace-stream-response-failed-1".to_string(),
report_kind: "openai_responses_stream_success".to_string(),
report_context: Some(json!({
"client_api_format": "openai:responses",
"provider_api_format": "openai:responses"
})),
status_code: 200,
headers: BTreeMap::from([(
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
provider_body_base64: Some(
base64::engine::general_purpose::STANDARD.encode(provider_sse),
),
provider_body_state: Some(UsageBodyCaptureState::Inline),
client_body_base64: None,
client_body_state: Some(UsageBodyCaptureState::None),
terminal_summary: Some(ExecutionStreamTerminalSummary {
response_id: Some("resp_failed".to_string()),
model: Some("gpt-5.5".to_string()),
observed_finish: true,
parser_error: Some(message.to_string()),
..ExecutionStreamTerminalSummary::default()
}),
telemetry: None,
};
let event =
build_stream_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
.expect("usage event should build");
assert_eq!(event.event_type, UsageEventType::Failed);
assert_eq!(event.data.status_code, Some(200));
assert_eq!(event.data.error_message.as_deref(), Some(message));
assert_eq!(
event.data.error_category.as_deref(),
Some("stream_terminal_error")
);
assert_eq!(
event
.data
.client_response_body
.as_ref()
.and_then(|body| body.get("error"))
.and_then(|error| error.get("code"))
.and_then(Value::as_str),
Some("cyber_policy")
);
assert_eq!(
event
.data
.client_response_body
.as_ref()
.and_then(|body| body.get("error"))
.and_then(|error| error.get("type"))
.and_then(Value::as_str),
Some("stream_terminal_error")
);
}
#[test]
fn completed_image_usage_estimates_request_tokens_when_provider_usage_is_missing() {
let plan = ExecutionPlan {
@@ -4957,6 +5151,7 @@ mod tests {
},
status_code: 200,
terminal_error_message: None,
terminal_failure_category: None,
response_time_ms: Some(123),
first_byte_time_ms: Some(45),
request_headers: Some(json!({