llmposter 0.5.0

Drop-in mock server for OpenAI, Anthropic & Gemini APIs — library or standalone CLI. SSE streaming, tool calling, OAuth2, failure injection, streaming chaos, stateful scenarios, request capture, hot-reload, response templating, record/replay (VCR). Test LLM apps without burning tokens.
Documentation
//! Response-side redaction: scrub sensitive substrings from recorded
//! content and tool-call arguments before they hit the cassette.

use super::cassette::RecordedFixture;

pub(crate) const REDACTED: &str = "[REDACTED]";

fn redact_str(s: &str, redactions: &[regex::Regex]) -> String {
    let mut out = s.to_string();
    for re in redactions {
        // NoExpand: the replacement is a literal, not a $-expansion template.
        out = re.replace_all(&out, regex::NoExpand(REDACTED)).into_owned();
    }
    out
}

fn redact_value(v: &mut serde_json::Value, redactions: &[regex::Regex]) {
    match v {
        serde_json::Value::String(s) => *s = redact_str(s, redactions),
        serde_json::Value::Array(items) => {
            items.iter_mut().for_each(|i| redact_value(i, redactions))
        }
        serde_json::Value::Object(map) => {
            map.values_mut().for_each(|i| redact_value(i, redactions))
        }
        _ => {}
    }
}

/// Redact response-side text only. The match key is left alone on purpose —
/// masking it would break replay matching (documented in docs/recording.md).
pub(crate) fn apply_redactions(rec: &mut RecordedFixture, redactions: &[regex::Regex]) {
    if redactions.is_empty() {
        return;
    }
    if let Some(content) = rec.response.content.as_mut() {
        *content = redact_str(content, redactions);
    }
    if let Some(calls) = rec.response.tool_calls.as_mut() {
        for call in calls {
            redact_value(&mut call.arguments, redactions);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::record::{RecordedMatch, RecordedResponse, RecordedToolCall, RECORDED_PRIORITY};

    #[test]
    fn should_redact_content_and_tool_arguments() {
        let redactions = vec![regex::Regex::new(r"sk-[A-Za-z0-9]+").unwrap()];
        let mut rec = RecordedFixture {
            match_rule: RecordedMatch {
                user_message: "q".to_string(),
                model: "gpt-test".to_string(),
            },
            provider: "openai",
            priority: RECORDED_PRIORITY,
            response: RecordedResponse {
                content: Some("key is sk-abc123 ok".to_string()),
                tool_calls: Some(vec![RecordedToolCall {
                    name: "f".to_string(),
                    arguments: serde_json::json!({
                        "token": "sk-zzz9",
                        "nested": {"v": "sk-qqq1"},
                        "list": ["sk-arr1", 7],
                        "count": 42
                    }),
                }]),
                ..Default::default()
            },
        };
        apply_redactions(&mut rec, &redactions);
        assert_eq!(
            rec.response.content.as_deref(),
            Some("key is [REDACTED] ok")
        );
        let args = &rec.response.tool_calls.as_ref().unwrap()[0].arguments;
        assert_eq!(args["token"], "[REDACTED]");
        assert_eq!(args["nested"]["v"], "[REDACTED]");
        assert_eq!(args["list"][0], "[REDACTED]", "array strings are scrubbed");
        assert_eq!(args["list"][1], 7, "non-string array items untouched");
        assert_eq!(args["count"], 42, "non-string primitives untouched");
    }
}