Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions crates/llmtrim-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Into<String>> From<T>`
# (E0119), breaking un-`--locked` `cargo install`. Drop when rcgen ships a fix.
Expand Down Expand Up @@ -153,6 +160,8 @@ intercept = [
"dep:sha2",
"dep:rand",
"dep:uuid",
"dep:zstd",
"dep:flate2",
]
mcp = ["dep:rmcp", "dep:schemars", "dep:tokio"]

Expand Down
98 changes: 96 additions & 2 deletions crates/llmtrim-cli/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
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
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
99 changes: 95 additions & 4 deletions crates/llmtrim-core/src/attribution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,20 +602,33 @@ fn system_text(body: &Value, kind: ProviderKind) -> Option<String> {
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::<Vec<_>>()
.join("");
(!text.is_empty()).then_some(text)
}

/// Collect every string in a value (string, array of blocks, or object with `text`).
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Loading