Codex Handbook
responses-api-proxy/src/dump.rs 378 lines
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<Self> {        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<ExchangeDump> {        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<R: Read>(        self,        status: u16,        headers: &HeaderMap,        response_body: R,    ) -> ResponseBodyDump<R> {        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<R> {    response_body: R,    response_path: PathBuf,    status: u16,    headers: Vec<HeaderDump>,    body: Vec<u8>,    dump_written: bool,}impl<R> ResponseBodyDump<R> {    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<R: Read> Read for ResponseBodyDump<R> {    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {        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<R> Drop for ResponseBodyDump<R> {    fn drop(&mut self) {        self.write_dump_if_needed();    }}#[derive(Serialize)]struct RequestDump {    method: String,    url: String,    headers: Vec<HeaderDump>,    body: Value,}#[derive(Serialize)]struct ResponseDump {    status: u16,    headers: Vec<HeaderDump>,    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::<serde_json::Value>(&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::<serde_json::Value>(&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::<Vec<_>>();        matches.sort();        assert_eq!(matches.len(), 1);        matches.pop().expect("single dump file")    }}