From 00a196869050246260d593cffec2cb4c8e855ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Kiene?= <46886660+fkiene@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:59:10 +0200 Subject: [PATCH] fix(intercept): decode zstd/gzip request bodies so Codex is compressed and tracked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex CLI 0.144+ sends its request bodies to chatgpt.com/backend-api/codex/responses with `Content-Encoding: zstd`. The interceptor handed the raw compressed bytes to the compression pipeline, which could not parse them as JSON and declined, so every Codex turn was forwarded verbatim with no pending state: no compression, no attribution, no ledger row. `llmtrim status` showed no Codex activity at all (#193) — the host was already intercepted (#124); the body was just unreadable. The interceptor now decodes zstd- and gzip-encoded POST bodies (256 MB decompressed cap) right after buffering, strips `content-encoding`, and runs the normal pipeline on the plain JSON. Any decode failure — unknown codec, multi-codec list, corrupt stream, over-cap — falls back to today's verbatim forward, so a decode problem can never break the call. The replay-on-error copy is captured after decoding, which keeps it consistent with the stripped header. Two attribution gaps kept the recorded turns from being labeled `codex`: - The fingerprint markers predate Codex 0.144, whose prompt opens with "You are Codex" and no longer contains "Codex CLI". Added the current marker; kept the legacy two. - `system_text` read only the first system/developer item of a Responses body. Codex now leads with a non-message `additional_tools` item, so the identity message behind it was never seen. Identity extraction now concatenates every system/developer *message*, skipping tool registrations — whose schemas vary turn-to-turn and would also destabilize the session hash. Verified end to end against a live Codex 0.144.1 on an isolated proxy port: before the fix a completed turn left zero ledger rows; after it the turn records agent=codex / provider=openai / model=gpt-5.6-sol with real token counts. Fixes #193. Signed-off-by: François Kiene <46886660+fkiene@users.noreply.github.com> --- CHANGELOG.md | 16 +++++ Cargo.lock | 30 ++++++++ crates/llmtrim-cli/Cargo.toml | 9 +++ crates/llmtrim-cli/src/serve.rs | 98 ++++++++++++++++++++++++- crates/llmtrim-core/src/attribution.rs | 99 ++++++++++++++++++++++++-- 5 files changed, 246 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8a86d9..6b676a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,22 @@ All notable changes to this project are documented here. The format follows Claude numbers stay so the segment isn't blank. Kimi/Grok share the cache schema; their usage endpoints are not wired yet. +### Fixed + +- **Codex traffic is compressed and tracked again.** Codex CLI 0.144+ sends its request bodies + with `Content-Encoding: zstd`, which the interceptor could not parse, so every Codex turn was + forwarded verbatim and never reached the ledger — `llmtrim status` showed no Codex activity at + all (#193). The interceptor now decodes zstd- and gzip-encoded request bodies (capped at + 256 MB) before compression and attribution, and forwards the body as plain JSON. A body it + cannot decode still passes through verbatim, so a decode problem can never break the call. +- **Codex sessions are labeled `codex` in the cost breakdown again.** Two attribution gaps hid + them: the fingerprint markers predated Codex 0.144's system prompt (which opens with "You are + Codex" and no longer says "Codex CLI"), and identity extraction read only the *first* + system/developer item of a Responses body — Codex now leads with a non-message + `additional_tools` item, so the identity message behind it was skipped. The fingerprint gained + the current marker and identity extraction concatenates every system/developer *message*, + skipping tool registrations (whose schemas would also destabilize the session hash). + ## [0.11.3] - 2026-07-17 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 41f1b17c..4aa51342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3132,6 +3132,7 @@ dependencies = [ "comfy-table", "crossterm", "dotenvy", + "flate2", "http-body-util", "hudsucker", "hyper-http-proxy", @@ -3160,6 +3161,7 @@ dependencies = [ "uuid", "wait-timeout", "winreg 0.56.0", + "zstd", ] [[package]] @@ -8392,6 +8394,34 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/crates/llmtrim-cli/Cargo.toml b/crates/llmtrim-cli/Cargo.toml index f6b5149b..f3746116 100644 --- a/crates/llmtrim-cli/Cargo.toml +++ b/crates/llmtrim-cli/Cargo.toml @@ -99,6 +99,13 @@ base64 = { workspace = true, optional = true } sha2 = { version = "0.10", optional = true } rand = { version = "0.8", optional = true } uuid = { version = "1", features = ["v4"], optional = true } +# Request-side body decompression (#193): Codex CLI 0.144+ sends its request bodies with +# `Content-Encoding: zstd`; without decoding them the interceptor can't parse (let alone +# compress or ledger) any Codex traffic. `zstd` (libzstd bindings, BSD-3-Clause) over the +# pure-Rust ruzstd: it is the reference decoder Codex itself encodes against, and this tree +# already links C crypto (aws-lc), so a C dep costs nothing new. flate2 covers gzip. +zstd = { version = "0.13", optional = true } +flate2 = { version = "1", optional = true } # Not used directly — caps the transitive `time` (via hudsucker → rcgen) below 0.3.48, # whose new blanket impl collides with rcgen 0.14's `impl> From` # (E0119), breaking un-`--locked` `cargo install`. Drop when rcgen ships a fix. @@ -153,6 +160,8 @@ intercept = [ "dep:sha2", "dep:rand", "dep:uuid", + "dep:zstd", + "dep:flate2", ] mcp = ["dep:rmcp", "dep:schemars", "dep:tokio"] diff --git a/crates/llmtrim-cli/src/serve.rs b/crates/llmtrim-cli/src/serve.rs index 235b1958..6469170f 100644 --- a/crates/llmtrim-cli/src/serve.rs +++ b/crates/llmtrim-cli/src/serve.rs @@ -267,6 +267,41 @@ mod imp { .unwrap_or(false) } + /// Decode a request body sent with `Content-Encoding: zstd` or `gzip` (#193: Codex CLI + /// 0.144+ zstd-compresses its `/backend-api/codex/responses` bodies; without decoding, + /// `compress_blocking` can't parse the JSON, so the turn is forwarded verbatim and never + /// reaches the ledger). Returns the decoded bytes, or `None` when the header is + /// absent/`identity` (body already plain) or the encoding is unsupported (multi-codec + /// lists), corrupt, or over the size cap — the caller forwards verbatim in every `None` + /// case, so a decode problem can never break the call. After a successful decode the + /// caller must strip `content-encoding` so the forwarded body goes out as plain JSON. + fn decode_request_body(headers: &header::HeaderMap, bytes: &[u8]) -> Option> { + use std::io::Read; + // Cap the decompressed size so a corrupt or malicious stream can't OOM the proxy. + const MAX_DECODED: u64 = 256 * 1024 * 1024; + let encoding = headers + .get(header::CONTENT_ENCODING)? + .to_str() + .ok()? + .trim() + .to_ascii_lowercase(); + let mut out = Vec::new(); + match encoding.as_str() { + "zstd" => { + // Read one byte past the cap so an exactly-at-cap stream still succeeds while + // an over-cap one is detected (rather than silently truncated and forwarded). + let decoder = zstd::stream::read::Decoder::new(bytes).ok()?; + decoder.take(MAX_DECODED + 1).read_to_end(&mut out).ok()?; + } + "gzip" => { + let decoder = flate2::read::GzDecoder::new(bytes); + decoder.take(MAX_DECODED + 1).read_to_end(&mut out).ok()?; + } + _ => return None, + } + (out.len() as u64 <= MAX_DECODED).then_some(out) + } + /// True if `req` is a WebSocket upgrade attempt — either an HTTP/1.1 `Upgrade: websocket` /// handshake or an HTTP/2 Extended CONNECT (RFC 8441, the `:protocol` pseudo-header set to /// `websocket`). We refuse these on intercepted LLM hosts (see `handle_request_inner`): a @@ -1452,7 +1487,7 @@ mod imp { if is_body_signed(req.headers()) { return req.into(); } - let (parts, body) = req.into_parts(); + let (mut parts, body) = req.into_parts(); let cc_session_id = parts .headers .get("x-claude-code-session-id") @@ -1466,6 +1501,16 @@ mod imp { // Always-sub is gated inside `reroute_messages`; this covers the compress+forward // path so a blocked session cannot keep compressing/overflowing after a sub latch. let mut bytes = bytes; + // Codex CLI 0.144+ zstd-compresses its request bodies (#193). Decode here so the + // whole pipeline below (compression, attribution, ledger, replay capture) sees + // parseable JSON, and strip `content-encoding` so the forwarded — possibly + // llmtrim-rewritten — body is correctly sent as identity. On any decode failure + // the header stays and the still-encoded body is forwarded verbatim, exactly as + // before this fix: never break the call. + if let Some(decoded) = decode_request_body(&parts.headers, &bytes) { + bytes = Bytes::from(decoded); + parts.headers.remove(header::CONTENT_ENCODING); + } if provider == ProviderKind::Anthropic && let Some(mut anthropic) = std::str::from_utf8(&bytes) .ok() @@ -1674,7 +1719,6 @@ mod imp { session_id, }); } - let mut parts = parts; // Length changed; drop it so hyper recomputes (and never streams a stale value). parts.headers.remove(header::CONTENT_LENGTH); // When we'll tee + measure this response, force identity encoding so the body is @@ -5207,6 +5251,56 @@ mod imp { assert!(is_compressible_path("/backend-api/codex/responses")); } + #[test] + fn decode_request_body_zstd_roundtrips() { + // Codex CLI 0.144+ sends `Content-Encoding: zstd` request bodies (#193). + let json = br#"{"model":"gpt-5.6-sol","input":[{"role":"user","content":"hi"}]}"#; + let compressed = zstd::stream::encode_all(&json[..], 0).unwrap(); + let mut h = header::HeaderMap::new(); + h.insert(header::CONTENT_ENCODING, "zstd".parse().unwrap()); + assert_eq!( + decode_request_body(&h, &compressed).as_deref(), + Some(&json[..]) + ); + } + + #[test] + fn decode_request_body_gzip_roundtrips() { + use std::io::Write; + let json = br#"{"model":"gpt-5.6-sol","input":[]}"#; + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(json).unwrap(); + let compressed = enc.finish().unwrap(); + let mut h = header::HeaderMap::new(); + h.insert(header::CONTENT_ENCODING, "GZip".parse().unwrap()); // case-insensitive + assert_eq!( + decode_request_body(&h, &compressed).as_deref(), + Some(&json[..]) + ); + } + + #[test] + fn decode_request_body_garbage_zstd_declines() { + // Corrupt stream → None; the caller forwards the request verbatim (never break + // the call). + let mut h = header::HeaderMap::new(); + h.insert(header::CONTENT_ENCODING, "zstd".parse().unwrap()); + assert_eq!(decode_request_body(&h, b"not a zstd stream"), None); + } + + #[test] + fn decode_request_body_identity_or_absent_leaves_body_alone() { + let h = header::HeaderMap::new(); + assert_eq!(decode_request_body(&h, b"{\"plain\":true}"), None); + let mut h = header::HeaderMap::new(); + h.insert(header::CONTENT_ENCODING, "identity".parse().unwrap()); + assert_eq!(decode_request_body(&h, b"{\"plain\":true}"), None); + // Multi-codec lists are unsupported by design — treated as passthrough. + let mut h = header::HeaderMap::new(); + h.insert(header::CONTENT_ENCODING, "gzip, zstd".parse().unwrap()); + assert_eq!(decode_request_body(&h, b"whatever"), None); + } + #[test] fn user_extra_hosts_flow_into_intercept_domains() { // A user-configured host must reach the CA sidecar set so it is actually intercepted. diff --git a/crates/llmtrim-core/src/attribution.rs b/crates/llmtrim-core/src/attribution.rs index fc09e111..2f58d164 100644 --- a/crates/llmtrim-core/src/attribution.rs +++ b/crates/llmtrim-core/src/attribution.rs @@ -602,20 +602,33 @@ fn system_text(body: &Value, kind: ProviderKind) -> Option { if let Some(v) = field { return Some(flatten_text(v)); } - // OpenAI chat / Responses: first system/developer message. + // OpenAI chat / Responses: concatenate every system/developer *message*. Modern + // Responses bodies (Codex 0.144+, #193) carry no `instructions` and interleave + // non-message developer items (e.g. `additional_tools` registrations) before the + // identity message, so taking only the first system/developer item would flatten tool + // schemas instead of the identity. Only items whose `type` is "message" (or absent — + // chat-completions messages carry no `type`) count: tool registrations aren't identity + // text and can vary turn-to-turn, which would also destabilize `stable_hash(system)` + // and thus the session id; the developer *message* preamble is static per session. let items = body .get("messages") .or_else(|| body.get("input")) .and_then(Value::as_array)?; - items + let text = items .iter() - .find(|m| { + .filter(|m| { matches!( m.get("role").and_then(Value::as_str), Some("system") | Some("developer") + ) && matches!( + m.get("type").and_then(Value::as_str), + None | Some("message") ) }) .map(|m| flatten_text(m.get("content").unwrap_or(m))) + .collect::>() + .join(""); + (!text.is_empty()).then_some(text) } /// Collect every string in a value (string, array of blocks, or object with `text`). @@ -675,7 +688,14 @@ static AGENTS: &[AgentFingerprint] = &[ }, AgentFingerprint { label: "codex", - markers: &["running as a coding agent in the Codex CLI", "Codex CLI"], + // Codex 0.144+ instructions open with "You are Codex, an agent based on GPT-5..." + // and no longer contain either legacy phrase; keep both for older CLI versions. + // Not bare "Codex": too broad — it would fire on any prompt merely mentioning it. + markers: &[ + "running as a coding agent in the Codex CLI", + "Codex CLI", + "You are Codex", + ], }, AgentFingerprint { label: "cursor", @@ -963,6 +983,77 @@ mod tests { } } + #[test] + fn identity_from_codex_responses_instructions() { + // Live Codex 0.144.1: instructions open with "You are Codex, ..." and carry neither + // legacy marker (#193). Responses-shaped body, OpenAI provider. + let body = json!({ + "model": "gpt-5.6-sol", + "instructions": "You are Codex, an agent based on GPT-5. You and the user share one workspace...", + "input": [{"role": "user", "content": "hi"}] + }); + assert_eq!( + extract_identity(&body, ProviderKind::OpenAi) + .agent + .as_deref(), + Some("codex") + ); + } + + #[test] + fn identity_from_codex_real_input_shape() { + // The shape actually seen on the wire from Codex 0.144.1 (#193): no `instructions`; + // `input` opens with a non-message `additional_tools` developer item, and the + // identity lives in the developer *message* after it. Taking only the first + // system/developer item would flatten the tool schemas and miss the marker. + let body = json!({ + "model": "gpt-5.6-sol", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{ + "type": "custom", + "name": "exec", + "description": "Run JavaScript code to orchestrate/compose tool calls" + }] + }, + { + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": "You are Codex, an agent based on GPT-5. You and the user share one workspace..." + }] + }, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]} + ] + }); + assert_eq!( + extract_identity(&body, ProviderKind::OpenAi) + .agent + .as_deref(), + Some("codex") + ); + } + + #[test] + fn claude_code_via_sub_reroute_does_not_fingerprint_as_codex() { + // The sub reroute flattens Claude Code's system prompt into `instructions`; the + // claude-code entry precedes codex in AGENTS, so it must win — pin that ordering. + let body = json!({ + "model": "gpt-5.6-sol", + "instructions": "You are Claude Code, Anthropic's CLI. Route via Codex backend.", + "input": [{"role": "user", "content": "hi"}] + }); + assert_eq!( + extract_identity(&body, ProviderKind::OpenAi) + .agent + .as_deref(), + Some("claude-code") + ); + } + #[test] fn qwen_legacy_phrase_does_not_steal_from_gemini_fork() { // Qwen Code is a Gemini CLI fork: its prompt can carry both its own brand marker and