1use std::io::Write;
10
11use super::client::{TransportLogEvent, TransportLogger};
12
13const SENSITIVE_HEADERS: &[&str] = &[
15 "authorization",
16 "proxy-authorization",
17 "cookie",
18 "set-cookie",
19 "x-api-key",
20];
21
22const REDACTED: &str = "<redacted>";
23
24#[derive(Clone, Debug, Default)]
45pub struct StderrTransportLogger {
46 extra_redacted: Vec<String>,
49}
50
51impl StderrTransportLogger {
52 #[must_use]
55 pub fn new() -> Self {
56 Self::default()
57 }
58
59 #[must_use]
69 pub fn with_redacted_headers(
70 mut self,
71 names: impl IntoIterator<Item = impl Into<String>>,
72 ) -> Self {
73 self.extra_redacted
74 .extend(names.into_iter().filter_map(|name| {
75 let name = name.into().trim().to_ascii_lowercase();
76 (!name.is_empty()).then_some(name)
77 }));
78 self
79 }
80
81 fn is_sensitive(&self, name: &str) -> bool {
82 SENSITIVE_HEADERS
83 .iter()
84 .any(|candidate| name.eq_ignore_ascii_case(candidate))
85 || self
86 .extra_redacted
87 .iter()
88 .any(|candidate| name.eq_ignore_ascii_case(candidate))
89 }
90
91 fn format_event(&self, event: &TransportLogEvent) -> String {
96 match event.message {
97 "http request" => {
98 let method = field(event, "method").unwrap_or("?");
99 let url = field(event, "url").unwrap_or("?");
100 let mut out = format!("> {method} {url}\n");
101 self.append_headers(&mut out, ">", event);
102 append_body(&mut out, ">", event);
103 out
104 }
105 "http response" => {
106 let status = field(event, "status").unwrap_or("?");
110 let suffix = match (field(event, "method"), field(event, "url")) {
111 (Some(method), Some(url)) => format!(" {method} {url}"),
112 (Some(value), None) | (None, Some(value)) => format!(" {value}"),
113 (None, None) => String::new(),
114 };
115 let mut out = format!("< {status}{suffix}\n");
116 self.append_headers(&mut out, "<", event);
117 append_body(&mut out, "<", event);
118 out
119 }
120 "retrying request" => {
121 let attempt = field(event, "attempt").unwrap_or("?");
122 let backoff = field(event, "backoff").unwrap_or("?");
123 format!("* retrying (attempt {attempt}, backoff {backoff})\n")
124 }
125 other => {
126 let mut out = format!("* {other}");
127 for (key, value) in &event.fields {
128 out.push_str(&format!(" {key}={value}"));
129 }
130 out.push('\n');
131 out
132 }
133 }
134 }
135
136 fn append_headers(&self, out: &mut String, prefix: &str, event: &TransportLogEvent) {
137 if let Some(headers) = &event.headers {
138 for (name, value) in headers {
139 let shown = if self.is_sensitive(name) {
140 REDACTED
141 } else {
142 value
143 };
144 out.push_str(&format!("{prefix} {name}: {shown}\n"));
145 }
146 }
147 }
148}
149
150impl TransportLogger for StderrTransportLogger {
151 fn debug(&self, event: &TransportLogEvent) {
152 let rendered = self.format_event(event);
153 if rendered.is_empty() {
154 return;
155 }
156 let mut stderr = std::io::stderr().lock();
162 stderr.write_all(rendered.as_bytes()).ok();
163 }
164}
165
166fn field<'event>(event: &'event TransportLogEvent, key: &str) -> Option<&'event str> {
167 event.fields.get(key).map(String::as_str)
168}
169
170fn append_body(out: &mut String, prefix: &str, event: &TransportLogEvent) {
171 if let Some(body) = &event.body {
172 if body.is_empty() {
173 return;
174 }
175 out.push_str(&format!("{prefix}\n"));
176 for line in String::from_utf8_lossy(body).lines() {
177 out.push_str(&format!("{prefix} {line}\n"));
178 }
179 } else if let Some(size) = field(event, "body_bytes")
180 && size != "0"
181 {
182 out.push_str(&format!("{prefix} [body: {size} bytes not captured]\n"));
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use std::collections::BTreeMap;
189
190 use super::{REDACTED, StderrTransportLogger};
191 use crate::transport::client::TransportLogEvent;
192
193 fn fields(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
194 pairs
195 .iter()
196 .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
197 .collect()
198 }
199
200 #[test]
201 fn request_event_redacts_sensitive_headers_and_prints_body() {
202 let event = TransportLogEvent {
203 message: "http request",
204 fields: fields(&[("method", "POST"), ("url", "https://api.example.com/repos")]),
205 headers: Some(vec![
206 ("authorization".to_owned(), "Bearer super-secret".to_owned()),
207 ("content-type".to_owned(), "application/json".to_owned()),
208 ]),
209 body: Some(br#"{"name":"foo"}"#.to_vec()),
210 };
211 let rendered = StderrTransportLogger::new().format_event(&event);
212 assert!(rendered.contains("> POST https://api.example.com/repos"));
213 assert!(rendered.contains(&format!("> authorization: {REDACTED}")));
214 assert!(!rendered.contains("super-secret"));
215 assert!(rendered.contains("> content-type: application/json"));
216 assert!(rendered.contains(r#"> {"name":"foo"}"#));
217 }
218
219 #[test]
220 fn response_event_with_size_only_reports_byte_count() {
221 let event = TransportLogEvent {
222 message: "http response",
223 fields: fields(&[
224 ("status", "200"),
225 ("method", "GET"),
226 ("url", "https://api.example.com/blob"),
227 ("body_bytes", "2048"),
228 ]),
229 headers: Some(vec![("set-cookie".to_owned(), "session=abc123".to_owned())]),
230 body: None,
231 };
232 let rendered = StderrTransportLogger::new().format_event(&event);
233 assert!(rendered.contains("< 200 GET https://api.example.com/blob"));
234 assert!(rendered.contains(&format!("< set-cookie: {REDACTED}")));
235 assert!(!rendered.contains("abc123"));
236 assert!(rendered.contains("< [body: 2048 bytes not captured]"));
237 }
238
239 #[test]
240 fn status_only_response_has_no_trailing_space() {
241 let event = TransportLogEvent {
244 message: "http response",
245 fields: fields(&[("status", "204")]),
246 headers: Some(vec![("content-length".to_owned(), "0".to_owned())]),
247 body: None,
248 };
249 let rendered = StderrTransportLogger::new().format_event(&event);
250 assert!(
251 rendered.starts_with("< 204\n"),
252 "status-only response should be `< 204` with no trailing space, got: {rendered:?}"
253 );
254 }
255
256 #[test]
257 fn extra_redacted_headers_are_redacted_case_insensitively() {
258 let event = TransportLogEvent {
259 message: "http request",
260 fields: fields(&[("method", "GET"), ("url", "https://api.example.com/m")]),
261 headers: Some(vec![
262 ("x-litellm-api-key".to_owned(), "sk-leak-me".to_owned()),
263 ("content-type".to_owned(), "application/json".to_owned()),
264 ]),
265 body: None,
266 };
267 let logger = StderrTransportLogger::new().with_redacted_headers([
270 " X-LiteLLM-API-Key ",
271 " ",
272 "",
273 ]);
274 let rendered = logger.format_event(&event);
275 assert!(rendered.contains(&format!("> x-litellm-api-key: {REDACTED}")));
276 assert!(!rendered.contains("sk-leak-me"));
277 assert!(rendered.contains("> content-type: application/json"));
279
280 let plain = StderrTransportLogger::new().format_event(&event);
282 assert!(plain.contains("> x-litellm-api-key: sk-leak-me"));
283 }
284
285 #[test]
286 fn retry_event_renders_a_note_line() {
287 let event = TransportLogEvent {
288 message: "retrying request",
289 fields: fields(&[("attempt", "2"), ("backoff", "500ms")]),
290 headers: None,
291 body: None,
292 };
293 assert_eq!(
294 StderrTransportLogger::new().format_event(&event),
295 "* retrying (attempt 2, backoff 500ms)\n"
296 );
297 }
298}