crabmate 0.4.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
//! 将**上游 HTTP 响应体**等长文本截断为适合 **`log` 输出**的预览,避免把全文写入日志。
//!
//! 与仓库「密钥与日志脱敏」规则配合:**不得**在 `error!` / 返回给前端的 `Err` 中附带完整供应商响应体;
//! 排障时使用 `body_preview` + `body_len` 即可。
//!
//! 发往模型的 **`ChatRequest` JSON** 预览:由 `llm::api::stream_chat` 输出(长度上限见 [`CHAT_REQUEST_JSON_LOG_MAX_CHARS`])。
//! - 设置 **`RUST_LOG=crabmate=debug`**(或更宽 `debug`)时走 **`debug!`**,预览上限为 [`CHAT_REQUEST_JSON_LOG_MAX_CHARS`];
//! - 仅 **`--log` 文件**且默认 **info** 时:设环境变量 **`CM_LOG_CHAT_REQUEST_JSON=1`** 则走 **`info!`**,预览上限为 [`CHAT_REQUEST_JSON_LOG_INFO_CHARS`](短于 debug,避免 **`chat_turn`** 上下文下一行过长);否则不打印。
//!
//! 对话/助手消息预览用于 `log::debug!`:默认仅开启 `RUST_LOG=debug` 时输出,且始终截断。

use std::sync::LazyLock;

use regex::Regex;

use crate::cm_types::{Message, message_content_as_str};

/// 日志里展示的响应体预览最大字符数(Unicode 标量)。
pub const HTTP_BODY_PREVIEW_LOG_CHARS: usize = 256;

/// `stream_chat` 发往供应商前,DEBUG 日志中 **`ChatRequest` JSON** 的最大字符数(Unicode 标量)。
/// 仅用于排障;完整 tools 定义可能很长,超出部分见 `…(truncated)`。
pub const CHAT_REQUEST_JSON_LOG_MAX_CHARS: usize = 12_288;

/// 在未开 **`RUST_LOG=…debug`** 但开启 **`CM_LOG_CHAT_REQUEST_JSON`** 时,**INFO** 级别 `chat 请求体 JSON` 预览上限(避免嵌在 **`chat_turn`** 上下文下一行过长;需要更长预览请用 **`RUST_LOG=crabmate=debug`**)。
pub const CHAT_REQUEST_JSON_LOG_INFO_CHARS: usize = 768;

/// 从供应商 JSON 里取出 `error.message` 后,写入**用户可见** `Err` 的最大长度(不含 HTTP 状态前缀)。
/// 仅拼接解析出的文案,不附带整段 body(见模块顶部说明)。
pub const CHAT_API_USER_ERROR_MSG_CHARS: usize = 180;

/// 对话消息写入日志时的正文预览长度(HTTP/CLI 等仍截断处使用)。
/// 部分高噪声调试目标(如终端 UI 专用 log target)的会话输出全文,不使用本长度。
pub const MESSAGE_LOG_PREVIEW_CHARS: usize = 320;

/// 按 Unicode 标量截断;超出则后缀 `…(truncated)`。
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)
}

/// 从 OpenAI 兼容的 chat 错误 JSON 中取出简短 `message`,供 `stream_chat` 等返回给 TUI/前端。
/// 解析失败或非字符串字段时返回 `None`(调用方保留泛化提示语)。
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))
}

/// 从消息列表中取**最后一条** `user` 角色正文的截断预览,供调试日志使用。
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()
}

/// 单条助手(或其它角色)消息摘要:正文截断 + 若有 `tool_calls` 则附工具名(参数不全文记录)。
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}]"),
    }
}

/// 工具调用 `arguments` JSON 字符串的日志预览(防过长、防误打满屏;空白折叠为单行便于 grep)。
pub fn tool_arguments_preview_for_log(args: &str) -> String {
    single_line_preview(args, 240)
}

/// SSE `tool_call.arguments_preview` 与日志预览同源(单行 + 截断),便于前后端与 `execute_tools` 日志对齐。
pub fn tool_arguments_preview_for_sse(args: &str) -> String {
    tool_arguments_preview_for_log(args)
}

/// `tool_call.arguments` 在开启完整下发时的最大 Unicode 标量(经脱敏后再截断)。
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")
});

/// JSON 字符串值脱敏:键名(不区分大小写)匹配常见凭证字段时替换值为 `<redacted>`。
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")
});

/// 对工具 `arguments` 原始串做**启发式脱敏**(非保证),再折叠为单行并截断,供 SSE `tool_call.arguments` 使用。
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: &regex::Captures<'_>| {
            format!("{}<redacted>{}", &caps[1], &caps[4])
        })
        .to_string();
    s = RE_URL_QUERY_SECRET
        .replace_all(&s, |caps: &regex::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")
});

/// MCP stdio 启动命令日志预览:折叠单行、脱敏 `export VAR=…` / Bearer / sk- 样例,并截断。
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: &regex::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)
}

/// 对**整段 JSON 文本**做与 [`tool_arguments_redacted_for_sse`] 同源的启发式脱敏,供**落盘**回合重放/调试(非密码学级保证,仅降低误写入明显密钥形态)。
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: &regex::Captures<'_>| {
            format!("{}<redacted>{}", &caps[1], &caps[4])
        })
        .to_string();
    t = RE_URL_QUERY_SECRET
        .replace_all(&t, |caps: &regex::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"));
    }
}