use std::collections::BTreeMap;
#[cfg(feature = "_client")]
use std::{fmt, sync::Arc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SanitizedResponseBody {
pub text: String,
pub truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ResponseMetadata {
pub status: u16,
pub request_ids: BTreeMap<String, String>,
pub sanitized_body: Option<SanitizedResponseBody>,
}
impl ResponseMetadata {
#[must_use]
pub fn new(status: u16) -> Self {
Self {
status,
request_ids: BTreeMap::new(),
sanitized_body: None,
}
}
#[must_use]
pub fn request_id(&self) -> Option<&str> {
const PREFERRED_HEADERS: &[&str] = &[
"x-request-id",
"request-id",
"anthropic-request-id",
"openai-request-id",
"x-goog-request-id",
"x-amzn-requestid",
"x-amz-request-id",
];
PREFERRED_HEADERS
.iter()
.find_map(|header| self.request_ids.get(*header).map(String::as_str))
.or_else(|| self.request_ids.values().next().map(String::as_str))
}
}
#[cfg(feature = "_client")]
type Sanitizer = dyn Fn(&str) -> String + Send + Sync + 'static;
#[derive(Clone)]
#[cfg(feature = "_client")]
pub struct ResponseBodyCapture {
max_bytes: usize,
sanitizer: Arc<Sanitizer>,
}
#[cfg(feature = "_client")]
impl ResponseBodyCapture {
pub const DEFAULT_MAX_BYTES: usize = 16 * 1024;
pub fn new<F>(sanitizer: F) -> Self
where
F: Fn(&str) -> String + Send + Sync + 'static,
{
Self {
max_bytes: Self::DEFAULT_MAX_BYTES,
sanitizer: Arc::new(sanitizer),
}
}
#[must_use]
pub fn max_bytes(mut self, max_bytes: usize) -> Self {
self.max_bytes = max_bytes;
self
}
pub(crate) fn capture(&self, raw_body: &str) -> SanitizedResponseBody {
let sanitized = (self.sanitizer)(raw_body);
let boundary = sanitized.floor_char_boundary(self.max_bytes.min(sanitized.len()));
let truncated = boundary < sanitized.len();
SanitizedResponseBody {
text: sanitized[..boundary].to_string(),
truncated,
}
}
}
#[cfg(feature = "_client")]
impl fmt::Debug for ResponseBodyCapture {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ResponseBodyCapture")
.field("max_bytes", &self.max_bytes)
.field("sanitizer", &"<caller-provided>")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "_client")]
#[test]
fn capture_sanitizes_before_retaining_and_truncates_on_utf8_boundary() {
let capture =
ResponseBodyCapture::new(|body| body.replace("secret", "[REDACTED]")).max_bytes(13);
let body = capture.capture("secret тест");
assert_eq!(body.text, "[REDACTED] т");
assert!(body.truncated);
assert!(!body.text.contains("secret"));
}
#[test]
fn preferred_request_id_is_deterministic() {
let mut metadata = ResponseMetadata::new(200);
metadata
.request_ids
.insert("request-id".to_string(), "fallback".to_string());
metadata
.request_ids
.insert("x-request-id".to_string(), "preferred".to_string());
assert_eq!(metadata.request_id(), Some("preferred"));
}
}