mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user