diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8a86d..6b676a6 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 41f1b17..4aa5134 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 f6b5149..f374611 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 235b195..6469170 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 fc09e11..2f58d16 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