use std::fs; use std::io; use std::io::Read; use std::path::PathBuf; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use std::time::SystemTime; use std::time::UNIX_EPOCH; use reqwest::header::HeaderMap; use serde::Serialize; use serde_json::Value; use tiny_http::Header; use tiny_http::Method; const AUTHORIZATION_HEADER_NAME: &str = "authorization"; const REDACTED_HEADER_VALUE: &str = "[REDACTED]"; pub(crate) struct ExchangeDumper { dump_dir: PathBuf, next_sequence: AtomicU64, } impl ExchangeDumper { pub(crate) fn new(dump_dir: PathBuf) -> io::Result { fs::create_dir_all(&dump_dir)?; Ok(Self { dump_dir, next_sequence: AtomicU64::new(1), }) } pub(crate) fn dump_request( &self, method: &Method, url: &str, headers: &[Header], body: &[u8], ) -> io::Result { let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); let timestamp_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |duration| duration.as_millis()); let prefix = format!("{sequence:06}-{timestamp_ms}"); let request_path = self.dump_dir.join(format!("{prefix}-request.json")); let response_path = self.dump_dir.join(format!("{prefix}-response.json")); let request_dump = RequestDump { method: method.as_str().to_string(), url: url.to_string(), headers: headers.iter().map(HeaderDump::from).collect(), body: dump_body(body), }; write_json_dump(&request_path, &request_dump)?; Ok(ExchangeDump { response_path }) } } pub(crate) struct ExchangeDump { response_path: PathBuf, } impl ExchangeDump { pub(crate) fn tee_response_body( self, status: u16, headers: &HeaderMap, response_body: R, ) -> ResponseBodyDump { ResponseBodyDump { response_body, response_path: self.response_path, status, headers: headers.iter().map(HeaderDump::from).collect(), body: Vec::new(), dump_written: false, } } } pub(crate) struct ResponseBodyDump { response_body: R, response_path: PathBuf, status: u16, headers: Vec, body: Vec, dump_written: bool, } impl ResponseBodyDump { fn write_dump_if_needed(&mut self) { if self.dump_written { return; } self.dump_written = true; let response_dump = ResponseDump { status: self.status, headers: std::mem::take(&mut self.headers), body: dump_body(&self.body), }; if let Err(err) = write_json_dump(&self.response_path, &response_dump) { eprintln!( "responses-api-proxy failed to write {}: {err}", self.response_path.display() ); } } } impl Read for ResponseBodyDump { fn read(&mut self, buf: &mut [u8]) -> io::Result { let bytes_read = self.response_body.read(buf)?; if bytes_read == 0 { self.write_dump_if_needed(); return Ok(0); } self.body.extend_from_slice(&buf[..bytes_read]); Ok(bytes_read) } } impl Drop for ResponseBodyDump { fn drop(&mut self) { self.write_dump_if_needed(); } } #[derive(Serialize)] struct RequestDump { method: String, url: String, headers: Vec, body: Value, } #[derive(Serialize)] struct ResponseDump { status: u16, headers: Vec, body: Value, } #[derive(Debug, Serialize)] struct HeaderDump { name: String, value: String, } impl From<&Header> for HeaderDump { fn from(header: &Header) -> Self { let name = header.field.as_str().to_string(); let value = if should_redact_header(&name) { REDACTED_HEADER_VALUE.to_string() } else { header.value.as_str().to_string() }; Self { name, value } } } impl From<(&reqwest::header::HeaderName, &reqwest::header::HeaderValue)> for HeaderDump { fn from(header: (&reqwest::header::HeaderName, &reqwest::header::HeaderValue)) -> Self { let name = header.0.as_str(); let value = if should_redact_header(name) { REDACTED_HEADER_VALUE.to_string() } else { String::from_utf8_lossy(header.1.as_bytes()).into_owned() }; Self { name: name.to_string(), value, } } } fn should_redact_header(name: &str) -> bool { name.eq_ignore_ascii_case(AUTHORIZATION_HEADER_NAME) || name.to_ascii_lowercase().contains("cookie") } fn dump_body(body: &[u8]) -> Value { serde_json::from_slice(body) .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(body).into_owned())) } fn write_json_dump(path: &PathBuf, dump: &impl Serialize) -> io::Result<()> { let mut bytes = serde_json::to_vec_pretty(dump) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; bytes.push(b'\n'); fs::write(path, bytes) } #[cfg(test)] mod tests { use std::fs; use std::io::Cursor; use std::io::Read; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use pretty_assertions::assert_eq; use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; use serde_json::json; use tiny_http::Header; use tiny_http::Method; use super::ExchangeDumper; static NEXT_TEST_DIR: AtomicU64 = AtomicU64::new(0); #[test] fn dump_request_writes_redacted_headers_and_json_body() { let dump_dir = test_dump_dir(); let dumper = ExchangeDumper::new(dump_dir.clone()).expect("create dumper"); let headers = vec![ Header::from_bytes(&b"Authorization"[..], &b"Bearer secret"[..]) .expect("authorization header"), Header::from_bytes(&b"Cookie"[..], &b"user-session=secret"[..]).expect("cookie header"), Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) .expect("content-type header"), Header::from_bytes(&b"x-codex-window-id"[..], &b"thread-1:0"[..]) .expect("window id header"), Header::from_bytes(&b"x-codex-parent-thread-id"[..], &b"parent-thread-1"[..]) .expect("parent thread id header"), Header::from_bytes(&b"x-openai-subagent"[..], &b"collab_spawn"[..]) .expect("subagent header"), ]; let exchange_dump = dumper .dump_request( &Method::Post, "/v1/responses", &headers, br#"{"model":"gpt-5.4"}"#, ) .expect("dump request"); let request_dump = fs::read_to_string(dump_file_with_suffix(&dump_dir, "-request.json")) .expect("read request dump"); assert_eq!( serde_json::from_str::(&request_dump).expect("parse request dump"), json!({ "method": "POST", "url": "/v1/responses", "headers": [ { "name": "Authorization", "value": "[REDACTED]" }, { "name": "Cookie", "value": "[REDACTED]" }, { "name": "Content-Type", "value": "application/json" }, { "name": "x-codex-window-id", "value": "thread-1:0" }, { "name": "x-codex-parent-thread-id", "value": "parent-thread-1" }, { "name": "x-openai-subagent", "value": "collab_spawn" } ], "body": { "model": "gpt-5.4" } }) ); assert!( exchange_dump .response_path .file_name() .expect("response dump file name") .to_string_lossy() .ends_with("-response.json") ); fs::remove_dir_all(dump_dir).expect("remove test dump dir"); } #[test] fn response_body_dump_streams_body_and_writes_response_file() { let dump_dir = test_dump_dir(); let dumper = ExchangeDumper::new(dump_dir.clone()).expect("create dumper"); let exchange_dump = dumper .dump_request(&Method::Post, "/v1/responses", &[], b"{}") .expect("dump request"); let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream")); headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer secret")); headers.insert( "set-cookie", HeaderValue::from_static("user-session=secret"), ); let mut response_body = String::new(); exchange_dump .tee_response_body( /*status*/ 200, &headers, Cursor::new(b"data: hello\n\n".to_vec()), ) .read_to_string(&mut response_body) .expect("read response body"); let response_dump = fs::read_to_string(dump_file_with_suffix(&dump_dir, "-response.json")) .expect("read response dump"); assert_eq!(response_body, "data: hello\n\n"); assert_eq!( serde_json::from_str::(&response_dump).expect("parse response dump"), json!({ "status": 200, "headers": [ { "name": "content-type", "value": "text/event-stream" }, { "name": "authorization", "value": "[REDACTED]" }, { "name": "set-cookie", "value": "[REDACTED]" } ], "body": "data: hello\n\n" }) ); fs::remove_dir_all(dump_dir).expect("remove test dump dir"); } fn test_dump_dir() -> std::path::PathBuf { let test_id = NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed); let dump_dir = std::env::temp_dir().join(format!( "codex-responses-api-proxy-dump-test-{}-{test_id}", std::process::id() )); fs::create_dir_all(&dump_dir).expect("create test dump dir"); dump_dir } fn dump_file_with_suffix(dump_dir: &std::path::Path, suffix: &str) -> std::path::PathBuf { let mut matches = fs::read_dir(dump_dir) .expect("read dump dir") .map(|entry| entry.expect("read dump entry").path()) .filter(|path| path.to_string_lossy().ends_with(suffix)) .collect::>(); matches.sort(); assert_eq!(matches.len(), 1); matches.pop().expect("single dump file") } }