use std::sync::LazyLock;
use regex::Regex;
use crate::cm_types::{Message, message_content_as_str};
pub const HTTP_BODY_PREVIEW_LOG_CHARS: usize = 256;
pub const CHAT_REQUEST_JSON_LOG_MAX_CHARS: usize = 12_288;
pub const CHAT_REQUEST_JSON_LOG_INFO_CHARS: usize = 768;
pub const CHAT_API_USER_ERROR_MSG_CHARS: usize = 180;
pub const MESSAGE_LOG_PREVIEW_CHARS: usize = 320;
pub fn preview_chars(s: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let mut iter = s.chars();
let prefix: String = iter.by_ref().take(max_chars).collect();
if iter.next().is_some() {
format!("{prefix}…(truncated)")
} else {
prefix
}
}
pub fn single_line_preview(s: &str, max_chars: usize) -> String {
let folded = s.split_whitespace().collect::<Vec<_>>().join(" ");
preview_chars(&folded, max_chars)
}
pub fn chat_api_error_message_for_user(body: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let msg = v
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.or_else(|| v.get("message").and_then(|m| m.as_str()))?;
let msg = msg.trim();
if msg.is_empty() {
return None;
}
Some(single_line_preview(msg, CHAT_API_USER_ERROR_MSG_CHARS))
}
pub fn last_user_message_preview_for_log(messages: &[Message]) -> String {
for m in messages.iter().rev() {
if m.role == "user" {
return match message_content_as_str(&m.content).map(str::trim) {
None | Some("") => "<empty>".to_string(),
Some(s) => preview_chars(s, MESSAGE_LOG_PREVIEW_CHARS),
};
}
}
"<no user>".to_string()
}
pub fn assistant_message_preview_for_log(msg: &Message) -> String {
let content_p = match message_content_as_str(&msg.content).map(str::trim) {
None | Some("") => None,
Some(s) => Some(preview_chars(s, MESSAGE_LOG_PREVIEW_CHARS)),
};
let tool_names = msg.tool_calls.as_ref().map(|tcs| {
tcs.iter()
.map(|tc| tc.function.name.as_str())
.collect::<Vec<_>>()
.join(",")
});
let tools_nonempty = tool_names.as_deref().filter(|t| !t.is_empty());
match (&content_p, tools_nonempty) {
(None, None) => "<empty>".to_string(),
(Some(c), None) => c.clone(),
(None, Some(t)) => format!("(no text) tools=[{t}]"),
(Some(c), Some(t)) => format!("{c} | tools=[{t}]"),
}
}
pub fn tool_arguments_preview_for_log(args: &str) -> String {
single_line_preview(args, 240)
}
pub fn tool_arguments_preview_for_sse(args: &str) -> String {
tool_arguments_preview_for_log(args)
}
pub const TOOL_CALL_ARGUMENTS_SSE_REDACTED_MAX_CHARS: usize = 4096;
static RE_SK_API_LIKE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\bsk-[a-zA-Z0-9]{12,}\b").expect("sk- token redact pattern"));
static RE_BEARER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]{8,}\b").expect("bearer redact pattern")
});
static RE_JSON_SECRET_STRING: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?i)("(api_key|apikey|token|access_token|refresh_token|password|secret|authorization|bearer)"\s*:\s*")((?:\\.|[^"\\])*)(")"#,
)
.expect("json secret string redact pattern")
});
static RE_URL_QUERY_SECRET: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"([?&])(?i)(key|token|access_token|api_key|apikey|secret|password)=([^&\s#]+)")
.expect("url query secret redact pattern")
});
pub fn tool_arguments_redacted_for_sse(args: &str) -> String {
if args.is_empty() {
return String::new();
}
let mut s = args.to_string();
s = RE_SK_API_LIKE.replace_all(&s, "sk-<redacted>").to_string();
s = RE_BEARER.replace_all(&s, "Bearer <redacted>").to_string();
s = RE_JSON_SECRET_STRING
.replace_all(&s, |caps: ®ex::Captures<'_>| {
format!("{}<redacted>{}", &caps[1], &caps[4])
})
.to_string();
s = RE_URL_QUERY_SECRET
.replace_all(&s, |caps: ®ex::Captures<'_>| {
format!("{}{}=<redacted>", &caps[1], &caps[2])
})
.to_string();
single_line_preview(&s, TOOL_CALL_ARGUMENTS_SSE_REDACTED_MAX_CHARS)
}
static RE_EXPORT_ASSIGN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\bexport\s+([A-Za-z_][A-Za-z0-9_]*)=([^\s;]+)")
.expect("export env redact pattern")
});
pub fn mcp_command_line_for_log(cmdline: &str) -> String {
if cmdline.trim().is_empty() {
return String::new();
}
let mut s = cmdline.to_string();
s = RE_EXPORT_ASSIGN
.replace_all(&s, |caps: ®ex::Captures<'_>| {
format!("export {}=<redacted>", &caps[1])
})
.to_string();
s = RE_SK_API_LIKE.replace_all(&s, "sk-<redacted>").to_string();
s = RE_BEARER.replace_all(&s, "Bearer <redacted>").to_string();
single_line_preview(&s, 320)
}
pub fn redact_secrets_in_json_str(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut t = s.to_string();
t = RE_SK_API_LIKE.replace_all(&t, "sk-<redacted>").to_string();
t = RE_BEARER.replace_all(&t, "Bearer <redacted>").to_string();
t = RE_JSON_SECRET_STRING
.replace_all(&t, |caps: ®ex::Captures<'_>| {
format!("{}<redacted>{}", &caps[1], &caps[4])
})
.to_string();
t = RE_URL_QUERY_SECRET
.replace_all(&t, |caps: ®ex::Captures<'_>| {
format!("{}{}=<redacted>", &caps[1], &caps[2])
})
.to_string();
t
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_truncates_with_marker() {
let s = "a".repeat(10);
assert_eq!(preview_chars(&s, 5), "aaaaa…(truncated)");
assert_eq!(preview_chars("hi", 10), "hi");
}
#[test]
fn single_line_collapses_newlines() {
assert_eq!(single_line_preview("a\nb\r\nc", 20), "a b c");
assert_eq!(single_line_preview(" x \t y ", 20), "x y");
}
#[test]
fn chat_api_error_message_parses_openai_shape() {
let body = r#"{"error":{"message":"Invalid model","type":"invalid_request_error"}}"#;
assert_eq!(
chat_api_error_message_for_user(body).as_deref(),
Some("Invalid model")
);
}
#[test]
fn chat_api_error_message_missing_returns_none() {
assert_eq!(chat_api_error_message_for_user("not json"), None);
assert_eq!(chat_api_error_message_for_user("{}"), None);
}
#[test]
fn last_user_preview_finds_last_user() {
use crate::cm_types::Message;
let msgs = vec![
Message::system_only("s"),
Message::user_only("first"),
Message::user_only("second"),
];
assert!(last_user_message_preview_for_log(&msgs).contains("second"));
}
#[test]
fn tool_arguments_redacted_masks_json_secret_and_sk() {
let raw = r#"{"api_key":"supersecret","path":"a"}"#;
let r = tool_arguments_redacted_for_sse(raw);
assert!(r.contains("<redacted>"));
assert!(!r.contains("supersecret"));
let sk = r#"{"k":"sk-1234567890abcdef"}"#;
let r2 = tool_arguments_redacted_for_sse(sk);
assert!(r2.contains("sk-<redacted>"));
}
#[test]
fn mcp_command_line_for_log_redacts_export() {
let cmd = "sh -c 'export API_KEY=secret; /usr/bin/mcp'";
let r = mcp_command_line_for_log(cmd);
assert!(r.contains("export API_KEY=<redacted>"));
assert!(!r.contains("secret"));
}
#[test]
fn redact_secrets_in_json_str_preserve_structure() {
let raw = r#"{"api_key":"x","bearer":"y","u":"https://a.com?token=sec"}"#;
let r = redact_secrets_in_json_str(raw);
assert!(!r.contains("sec"), "{r}");
assert!(r.contains("api_key") && r.contains("u"));
}
}