fix(rust): PR-A1 — make /v1/messages compression a passthrough

Stop calling IntelligentContextManager from the Rust proxy on
/v1/messages. The proxy is now a byte-faithful passthrough on this
endpoint. Eliminates the C1+C2+C3+C4 cache-killer cluster (P0-3,
P0-4, P0-5, P1-13) by not running ICM with `frozen_message_count: 0`
hardcoded — Phase B PR-B2 brings live-zone-only compression back.

Per REALIGNMENT/03-phase-A-lockdown.md.

Changes:
- Add `--compression-mode {off,live_zone}` flag and
  `HEADROOM_PROXY_COMPRESSION_MODE` env var. Default `off`. Both
  modes passthrough in PR-A1; `live_zone` warns loudly because
  Phase B isn't implemented yet (no silent fallback).
- Replace `compress_anthropic_request` body with a passthrough
  stub that emits a structured `tracing::info!` decision log line
  (request_id, path, method, compression_mode, decision,
  reason="phase_a_lockdown", body_bytes) and returns
  `Outcome::NoCompression`. Function signature preserved so
  Phase B PR-B2 is a pure body swap.
- Delete `compression/icm.rs` (per the realignment plan: ICM
  modules in headroom-core are deleted in PR-B1).
- Drop the `Arc<IntelligentContextManager>` field from `AppState`
  — no longer used.
- Add request-entry `tracing::debug!` with auth_mode_placeholder
  ("unknown" until Phase F PR-F1 wires the auth-mode classifier).
- Add `debug_assert!` on the NoCompression branch that the
  buffered bytes length is stable, locking in Phase A's
  cache-safety invariant at the call site.
- Tighten existing tests from `len()` equality to SHA-256 byte
  equality. Rename `compression_on_oversized_body_trims_messages`
  → `compression_on_long_body_passes_through_in_phase_a` and
  flip the assertion to byte-equal.
- Add new tests: passthrough_mode_off_byte_equal_sha256,
  passthrough_mode_live_zone_currently_passthrough_byte_equal_sha256,
  passthrough_preserves_numeric_precision (literal-byte body so
  serde_json's f64 quantization can't mask a regression),
  passthrough_preserves_cache_control_markers,
  passthrough_preserves_thinking_signature,
  passthrough_preserves_redacted_thinking_data,
  passthrough_recorded_fixture_byte_equal_sha256,
  tracing_capture::compression_decision_logged.
- Add fixture
  `crates/headroom-proxy/tests/fixtures/anthropic_messages_request_real.json`
  with system block list + cache_control markers, tools with
  nested JSON Schema, messages containing text + thinking +
  signature + tool_use + tool_result + image, non-ASCII content,
  large numbers. Used as the canonical SHA-256 round-trip gate.

Constraints honored: configurable (compression_mode is the only
new knob), no hardcoded thresholds, no regex usage, no silent
fallbacks (live_zone-not-implemented warns), structured tracing
on every cache-affecting decision, comprehensive tests.

Acceptance criteria from PR-A1 spec:
- `cargo build --workspace` clean
- `cargo test --workspace` green (886 tests pass)
- `cargo clippy --workspace -- -D warnings` clean
- `cargo fmt --all --check` clean
- `make ci-precheck` green
- New SHA-256 byte-equality tests pass against the recorded fixture
- `tracing::info!` decision-log line is observable
- `--compression-mode` CLI + env var work
- No regex import added
This commit is contained in:
chopratejas 2026-05-01 23:58:20 -07:00
parent 0ce2243dfb
commit a974bb153a
9 changed files with 1010 additions and 468 deletions

1
Cargo.lock generated
View file

@ -1375,6 +1375,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2",
"thiserror 1.0.69",
"tokio",
"tokio-stream",

View file

@ -53,3 +53,9 @@ hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["tokio", "server-auto"] }
http-body-util = "0.1"
tokio-stream = "0.1"
# PR-A1 cache-safety tests assert SHA-256 byte-equality between the
# inbound and upstream-received bodies. The hash is the only sound
# way to gate "the proxy did not perturb the request" because JSON
# value-equality misses whitespace, key order, and Unicode escape
# differences that all bust the prompt cache.
sha2 = "0.10"

View file

@ -1,60 +1,66 @@
//! Anthropic `/v1/messages` request compression.
//! Anthropic `/v1/messages` request compression — Phase A passthrough stub.
//!
//! # Request shape (relevant subset)
//! # Phase A lockdown (PR-A1)
//!
//! ```json
//! {
//! "model": "claude-3-5-sonnet-20241022",
//! "system": "...", // string OR list of blocks (optional)
//! "messages": [
//! {"role": "user", "content": "..."},
//! {"role": "assistant", "content": [...]}
//! ],
//! "tools": [...], // optional
//! "max_tokens": 1024, // required
//! ...
//! }
//! ```
//! Per `REALIGNMENT/03-phase-A-lockdown.md`, this function is now a
//! byte-faithful passthrough. The previous implementation invoked
//! `IntelligentContextManager` with a hardcoded `frozen_message_count: 0`,
//! which destroyed Anthropic prompt-cache hit rate by dropping messages
//! out of the cache hot zone. That bug cluster (P0-3, P0-4, P0-5,
//! P1-13) is eliminated by *not running the compressor at all* until
//! Phase B builds the live-zone-only replacement.
//!
//! # What we do
//! The function signature is preserved so the call site in `proxy.rs`
//! still compiles unchanged. Phase B PR-B2 fills this back in with the
//! live-zone block dispatcher (compress only the latest user message,
//! latest tool/function/shell/patch outputs — never historical turns).
//!
//! 1. Parse the body as JSON. On failure → passthrough.
//! 2. Pull `messages` (the only field we touch). On absence → passthrough.
//! 3. Pull `model` and `max_tokens` to compute the available budget.
//! 4. Run the ICM's `should_apply` gate. Under-budget → passthrough.
//! 5. Run `apply()`. Re-insert the (possibly trimmed) `messages` into
//! the parsed JSON. Re-serialize.
//! 6. On *any* error along the way: passthrough with a warn log. The
//! proxy must never break a request because compression failed.
//! # What this returns
//!
//! # What we DON'T do
//! Always `Outcome::NoCompression`. The caller (`proxy.rs`) reacts to
//! that by forwarding the original buffered bytes verbatim.
//!
//! - Touch `system`. Anthropic separates system from messages; our
//! ICM operates on the messages list. The system tokens are
//! "invisible" to ICM's budget calculation, which means we
//! under-count slightly — that's fine (we'll compress less than
//! strictly necessary, never more).
//! - Touch `tools`, `temperature`, `top_p`, etc. These pass through
//! verbatim because they're tiny and load-bearing for behaviour.
//! - Compress individual content blocks. That's content-router /
//! pipeline work, scoped to a follow-up PR. ICM operates at the
//! message-list level only.
//! # What it does NOT do
//!
//! - Does NOT parse the JSON body. The whole point of Phase A is byte
//! faithfulness; parsing + re-serialization could perturb whitespace,
//! numeric precision, key ordering, and Unicode escaping. Even
//! though we wouldn't re-emit the parsed value here, parsing is
//! wasted work and would invite future "while we're here" mutations.
//! - Does NOT touch headers, body, or any other request state.
//! - Does NOT depend on `IntelligentContextManager` (the type is gone
//! from this module's call graph; `mod.rs` no longer imports `icm`).
//!
//! # Logging
//!
//! Emits exactly one structured `tracing::info!` per call, with the
//! decision (`"passthrough"`), the reason (`"phase_a_lockdown"`), the
//! configured `compression_mode`, and the body byte count. The
//! `request_id` and HTTP method/path come from the caller's
//! existing log context (added in `proxy.rs`).
use bytes::Bytes;
use serde_json::Value;
use headroom_core::context::{ApplyCtx, IntelligentContextManager};
use crate::config::CompressionMode;
use super::model_limits::context_window_for;
/// What happened. Used for the request-level tracing log.
/// What happened. The caller uses the variant to decide whether to
/// forward the original bytes (everything) or a modified body
/// (currently never).
///
/// PR-A1 lockdown: `compress_anthropic_request` always returns
/// `Outcome::NoCompression`. The other variants remain in the enum
/// because Phase B PR-B2 reintroduces them with live-zone semantics
/// — keeping the surface stable lets us land Phase B as a pure
/// implementation swap rather than a disruptive enum redesign.
#[derive(Debug)]
pub enum Outcome {
/// Body was unchanged. Reasons listed in `reason`.
Passthrough { reason: PassthroughReason },
/// ICM ran but didn't drop anything (already under budget).
NoCompression { tokens_before: usize },
/// ICM ran and trimmed the message list.
/// Body was not compressed. Caller forwards the original buffered
/// bytes byte-equal. This is the only variant Phase A produces.
NoCompression,
/// Reserved for Phase B: live-zone compression actually ran and
/// produced a (smaller) body. Unused in PR-A1; kept so adding it
/// later is a non-breaking change.
#[allow(dead_code)]
Compressed {
body: Bytes,
tokens_before: usize,
@ -62,264 +68,121 @@ pub enum Outcome {
strategies_applied: Vec<&'static str>,
markers_inserted: Vec<String>,
},
/// Reserved for Phase B: parse/serialize edge cases the live-zone
/// dispatcher will distinguish from a normal pass. Unused in
/// PR-A1.
#[allow(dead_code)]
Passthrough { reason: PassthroughReason },
}
/// Why we passed the body through unchanged.
/// Why the live-zone dispatcher (Phase B) opted out. Unused in PR-A1
/// but kept for surface compatibility.
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub enum PassthroughReason {
/// JSON parse failed.
NotJson,
/// `messages` was missing or not a JSON array.
NoMessages,
/// Re-serialization of the modified body failed (shouldn't
/// happen — we just deserialized this shape).
/// Re-serialization of the modified body failed.
SerializeFailed,
}
/// Run ICM over an Anthropic-shape body. Returns one of:
/// Phase A passthrough stub for Anthropic `/v1/messages`.
///
/// - `Outcome::Compressed` — caller should forward `outcome.body`
/// instead of the original bytes.
/// - `Outcome::NoCompression` — caller forwards the original
/// bytes; ICM's `should_apply` returned false.
/// - `Outcome::Passthrough` — same as `NoCompression` from the
/// caller's perspective, but the reason is parse/serialize-related.
/// Always returns `Outcome::NoCompression`. The function signature
/// matches what Phase B PR-B2 will fill in (live-zone block
/// dispatcher); keeping the signature stable means the proxy's
/// catch-all handler doesn't need to change again then.
///
/// Never returns an error. Compression failures degrade to
/// passthrough; this is the proxy's safety contract.
pub fn maybe_compress(body: &Bytes, icm: &IntelligentContextManager) -> Outcome {
let mut parsed: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(_) => {
return Outcome::Passthrough {
reason: PassthroughReason::NotJson,
};
}
};
// Move the messages array out of the object so we can hand
// ownership to ICM. We re-insert at the end. If `messages` is
// missing or not an array, passthrough.
let messages = match parsed.get_mut("messages") {
Some(Value::Array(_)) => match parsed["messages"].take() {
Value::Array(a) => a,
_ => unreachable!("just matched as_array"),
},
_ => {
return Outcome::Passthrough {
reason: PassthroughReason::NoMessages,
};
}
};
let model = parsed
.get("model")
.and_then(Value::as_str)
.unwrap_or_default();
// `context_window_for` returns `u32` (LiteLLM-sourced). ICM's
// `ApplyCtx::model_limit` wants `usize`. The cast is lossless on
// every platform we run on — context windows are far below 4GB.
let model_limit = context_window_for(model) as usize;
// Anthropic requires `max_tokens`; if absent (malformed), assume
// a small reservation rather than zero so we don't pretend the
// whole window is available for input.
let output_buffer = parsed
.get("max_tokens")
.and_then(Value::as_u64)
.map(|v| v as usize)
.unwrap_or(4_096);
// Cheap pre-check before the real apply call. Saves the cost of
// a full message-list traversal when the request is small.
if !icm.should_apply(&messages, model_limit, output_buffer) {
// Restore messages and return without compression.
parsed["messages"] = Value::Array(messages);
// Compute tokens_before from the re-inserted form for log
// accuracy. Cheap: it's the same walk should_apply already
// did, but we don't have the count back from that call. We
// skip the recount and return 0; the caller's log just
// shows "no_compression" without a number, which is fine.
return Outcome::NoCompression { tokens_before: 0 };
}
let result = icm.apply(
messages,
ApplyCtx {
model_limit,
output_buffer: Some(output_buffer),
// TODO: detect provider prefix-cached messages from the
// request. Anthropic exposes prompt caching via
// `cache_control` on content blocks. Until we wire that
// detection, we treat the whole list as droppable.
frozen_message_count: 0,
},
/// # Arguments
///
/// - `body`: the full buffered request body. NOT inspected, NOT
/// parsed. We log only its byte length.
/// - `mode`: configured compression mode. PR-A1 logs the mode but
/// both `Off` and `LiveZone` result in passthrough. The caller
/// emits a `tracing::warn!` for `LiveZone` (since the live-zone
/// dispatcher isn't built yet) — see `proxy.rs`.
/// - `request_id`: the per-request id used for log correlation. The
/// caller already produced it (`ensure_request_id`); we accept it
/// as a borrowed `&str` so this function doesn't need its own
/// uuid dep.
///
/// # Returns
///
/// Always `Outcome::NoCompression`. Compression returns in Phase B.
pub fn compress_anthropic_request(
body: &Bytes,
mode: CompressionMode,
request_id: &str,
) -> Outcome {
tracing::info!(
request_id = %request_id,
path = "/v1/messages",
method = "POST",
compression_mode = mode.as_str(),
decision = "passthrough",
reason = "phase_a_lockdown",
body_bytes = body.len(),
"anthropic compression decision"
);
// ICM may return tokens_after >= tokens_before when no drops
// happened (e.g. everything is protected). Treat that as
// no-compression rather than ship a needless re-serialize.
if result.tokens_after >= result.tokens_before {
// Reinsert the (unchanged) messages and report.
parsed["messages"] = Value::Array(result.messages);
return Outcome::NoCompression {
tokens_before: result.tokens_before,
};
}
parsed["messages"] = Value::Array(result.messages);
let new_body = match serde_json::to_vec(&parsed) {
Ok(v) => Bytes::from(v),
Err(_) => {
return Outcome::Passthrough {
reason: PassthroughReason::SerializeFailed,
};
}
};
Outcome::Compressed {
body: new_body,
tokens_before: result.tokens_before,
tokens_after: result.tokens_after,
strategies_applied: result.strategies_applied,
markers_inserted: result.markers_inserted,
}
Outcome::NoCompression
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compression::icm::build_icm;
use serde_json::json;
fn icm() -> std::sync::Arc<IntelligentContextManager> {
build_icm().expect("ICM builds")
}
#[test]
fn passthrough_on_invalid_json() {
let icm = icm();
let body = Bytes::from_static(b"not json");
match maybe_compress(&body, &icm) {
Outcome::Passthrough {
reason: PassthroughReason::NotJson,
} => {}
other => panic!("expected NotJson passthrough, got {other:?}"),
}
}
#[test]
fn passthrough_when_messages_field_missing() {
let icm = icm();
let body = Bytes::from(json!({"model": "claude-3-5-sonnet-20241022"}).to_string());
match maybe_compress(&body, &icm) {
Outcome::Passthrough {
reason: PassthroughReason::NoMessages,
} => {}
other => panic!("expected NoMessages passthrough, got {other:?}"),
}
}
#[test]
fn passthrough_when_messages_not_array() {
let icm = icm();
let body = Bytes::from(
json!({"model": "claude-3-5-sonnet", "messages": "not-an-array"}).to_string(),
);
match maybe_compress(&body, &icm) {
Outcome::Passthrough {
reason: PassthroughReason::NoMessages,
} => {}
other => panic!("expected NoMessages passthrough, got {other:?}"),
}
}
#[test]
fn no_compression_when_under_budget() {
let icm = icm();
let body = Bytes::from(
json!({
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "hello"}]
})
.to_string(),
);
match maybe_compress(&body, &icm) {
Outcome::NoCompression { .. } => {}
fn passthrough_when_mode_off() {
let body = Bytes::from_static(b"{\"model\":\"claude\",\"messages\":[]}");
match compress_anthropic_request(&body, CompressionMode::Off, "test-req-id-1") {
Outcome::NoCompression => {}
other => panic!("expected NoCompression, got {other:?}"),
}
}
#[test]
fn compresses_when_over_budget() {
let icm = icm();
// Squeeze the available budget by setting a huge max_tokens
// so output_buffer eats almost the whole window. With a
// 200K window for Claude and max_tokens=199_500, only ~500
// tokens are available — anything bigger forces compression.
let big_messages: Vec<Value> = (0..30)
.map(|i| {
json!({
"role": if i % 2 == 0 { "user" } else { "assistant" },
"content": format!("padding token {i} ").repeat(20),
})
})
.collect();
let body = Bytes::from(
json!({
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 199_500,
"messages": big_messages,
})
.to_string(),
);
match maybe_compress(&body, &icm) {
Outcome::Compressed {
body: new_body,
tokens_before,
tokens_after,
..
} => {
assert!(tokens_after < tokens_before);
// The new body is valid JSON with a shorter messages
// array (or includes the CCR marker that the shim's
// injection logic adds — but the proxy doesn't do
// marker injection; that lives in the Python shim).
let parsed: Value = serde_json::from_slice(&new_body).unwrap();
assert!(parsed["messages"].as_array().is_some());
}
other => panic!("expected Compressed, got {other:?}"),
fn passthrough_when_mode_live_zone_in_phase_a() {
// PR-A1: live_zone is reserved for Phase B and currently
// falls through to passthrough. The proxy's call site emits
// the warning; this function uniformly logs and returns
// NoCompression.
let body = Bytes::from_static(b"{\"model\":\"claude\",\"messages\":[]}");
match compress_anthropic_request(&body, CompressionMode::LiveZone, "test-req-id-2") {
Outcome::NoCompression => {}
other => panic!("expected NoCompression, got {other:?}"),
}
}
#[test]
fn unknown_model_does_not_panic() {
// Should fall back to the default 128K window and behave
// like any other request.
let icm = icm();
let body = Bytes::from(
json!({
"model": "future-model-2099",
"max_tokens": 100,
"messages": [{"role": "user", "content": "hi"}]
})
.to_string(),
);
let _ = maybe_compress(&body, &icm); // shouldn't panic
fn passthrough_does_not_parse_invalid_json() {
// Body deliberately not JSON. We must not error or parse —
// passthrough is byte-faithful regardless of payload shape.
let body = Bytes::from_static(b"not json at all \xFF\xFE");
match compress_anthropic_request(&body, CompressionMode::Off, "test-req-id-3") {
Outcome::NoCompression => {}
other => panic!("expected NoCompression, got {other:?}"),
}
}
#[test]
fn missing_max_tokens_does_not_panic() {
let icm = icm();
let body = Bytes::from(
json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "hi"}]
})
.to_string(),
);
let _ = maybe_compress(&body, &icm); // shouldn't panic
fn passthrough_handles_empty_body() {
let body = Bytes::new();
match compress_anthropic_request(&body, CompressionMode::Off, "test-req-id-4") {
Outcome::NoCompression => {}
other => panic!("expected NoCompression, got {other:?}"),
}
}
#[test]
fn passthrough_handles_large_body() {
// 4MB of payload — confirm we don't accidentally allocate or
// iterate the body.
let body = Bytes::from(vec![b'a'; 4 * 1024 * 1024]);
match compress_anthropic_request(&body, CompressionMode::Off, "test-req-id-5") {
Outcome::NoCompression => {}
other => panic!("expected NoCompression, got {other:?}"),
}
}
}

View file

@ -1,70 +0,0 @@
//! Build the per-process `IntelligentContextManager`.
//!
//! Constructed once at proxy startup, stored in `AppState`, shared
//! across every request via `Arc`. The `MessageScorer` inside has a
//! `Mutex<HashMap>` embedding cache; contention is low because we
//! aren't wiring an `EmbeddingProvider` yet (that's a follow-up).
//!
//! # Tokenizer choice
//!
//! `IntelligentContextManager` needs a `Tokenizer` to count tokens
//! for the `should_apply` budget gate. We use `TiktokenCounter` with
//! the `gpt-4o-mini` (`o200k_base`) encoding because:
//!
//! - It's the most modern tiktoken vocabulary that's broadly
//! compatible with both OpenAI and Anthropic message shapes.
//! - It's strictly more accurate than the `EstimatingCounter`
//! (chars/4) default that the PyO3 binding hardcodes.
//! - For Anthropic specifically, the actual tokenizer is bespoke and
//! not publicly available; tiktoken-based counting is the
//! industry-standard close-enough estimate. Errors are O(±5%)
//! which doesn't change `should_apply` decisions.
//!
//! # CCR store
//!
//! An `InMemoryCcrStore` is constructed alongside the manager. When
//! ICM drops messages, their original JSON gets stashed under a
//! content-hash key and a marker is inserted into the surviving
//! message stream. If the LLM later calls a `ccr_retrieve` tool, the
//! handler can serve the dropped content from this store. (The
//! retrieval-handling tool is a separate concern; we just construct
//! the store so drops are recoverable.)
use std::sync::Arc;
use headroom_core::ccr::{CcrStore, InMemoryCcrStore};
use headroom_core::context::{IcmConfig, IntelligentContextManager};
use headroom_core::tokenizer::{TiktokenCounter, Tokenizer};
/// Construct the proxy's shared ICM. Returns `Arc` for cheap cloning
/// into request handlers.
///
/// Errors only on tokenizer construction — `gpt-4o-mini` is always
/// available since tiktoken-rs ships its vocabulary, but we bubble
/// up the error path for symmetry with the rest of the proxy's
/// fallible startup.
pub fn build_icm() -> Result<Arc<IntelligentContextManager>, String> {
let tokenizer: Arc<dyn Tokenizer> = Arc::new(
TiktokenCounter::for_model("gpt-4o-mini")
.map_err(|e| format!("init TiktokenCounter for gpt-4o-mini: {e}"))?,
);
let ccr: Arc<dyn CcrStore> = Arc::new(InMemoryCcrStore::new());
Ok(Arc::new(IntelligentContextManager::new(
IcmConfig::default(),
tokenizer,
Some(ccr),
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_icm_succeeds() {
let icm = build_icm().expect("ICM should build");
// Smoke: should_apply on an empty list under a generous
// budget returns false (no work).
assert!(!icm.should_apply(&[], 128_000, 4_000));
}
}

View file

@ -1,46 +1,45 @@
//! Compression interceptor for LLM-shaped requests.
//!
//! The proxy is a streaming reverse proxy by default. When
//! `--compression` is enabled and a request hits a known LLM
//! provider path, we buffer the body, run
//! `IntelligentContextManager` over the message list, and forward
//! the (possibly trimmed) body upstream. Everything else stays
//! streaming, including:
//! # Phase A lockdown (PR-A1)
//!
//! - WebSocket upgrades — handled in the catch-all before
//! `forward_http` is called; never reach this module.
//! - Non-LLM paths (any URL not matching a known provider).
//! - Non-JSON content types (skip; we don't speculate at body
//! contents we don't know how to parse).
//! - Streaming SSE responses — only the request body is touched;
//! responses pass through untouched.
//! Per `REALIGNMENT/03-phase-A-lockdown.md`, the
//! `IntelligentContextManager`-driven path that previously ran on
//! every `/v1/messages` request is gone. Today this module is a
//! tracking shell: it owns the path-matcher (`is_compressible_path`)
//! and the Anthropic decision stub (`compress_anthropic_request`)
//! that always returns `Outcome::NoCompression`.
//!
//! Phase B PR-B2 reintroduces real compression, but with two
//! invariants the deleted code violated:
//!
//! 1. The cache hot zone (system, tools, historical messages,
//! reasoning items, thinking signatures, redacted_thinking,
//! compaction items) is never modified.
//! 2. Compression is append-only: only the live zone is rewritten.
//!
//! # Provider matrix (current + planned)
//!
//! | Provider | Path | Status |
//! |--------------|-----------------------|--------|
//! | Anthropic | `POST /v1/messages` | ✅ this module |
//! | Anthropic | `POST /v1/messages` | passthrough (PR-A1) → live-zone (PR-B2) |
//! | OpenAI | `POST /v1/chat/completions` | follow-up |
//! | Google | `POST /v1beta/...` | follow-up |
//! | Bedrock | varied | follow-up |
//!
//! # Failure-mode contract
//!
//! Compression must NEVER break a request. Every error path —
//! parse failure, missing field, body too large, unknown model —
//! falls through to the original body being forwarded unchanged.
//! Operators see what happened in `tracing` warnings; clients see
//! their request go through.
//! Compression must NEVER break a request. Even when Phase B brings
//! a real dispatcher back, every error path falls through to the
//! original body being forwarded unchanged.
pub mod anthropic;
pub mod icm;
pub mod model_limits;
pub use anthropic::{maybe_compress, Outcome, PassthroughReason};
pub use icm::build_icm;
pub use anthropic::{compress_anthropic_request, Outcome, PassthroughReason};
/// Does this request path target an LLM endpoint we know how to
/// compress? Cheap pre-filter before buffering the body.
/// compress? Cheap pre-filter before buffering the body. Phase B
/// reuses this to gate which paths get the live-zone dispatcher.
pub fn is_compressible_path(path: &str) -> bool {
// Exact-match the Anthropic Messages endpoint. Future providers
// get their own arms here. Avoid prefix-matching to keep the

View file

@ -1,10 +1,48 @@
//! Configuration for the proxy: CLI flags + env vars.
use clap::Parser;
use clap::{Parser, ValueEnum};
use std::net::SocketAddr;
use std::time::Duration;
use url::Url;
/// Compression mode policy for the `/v1/messages` endpoint.
///
/// Drives whether `compress_anthropic_request` does any work. PR-A1
/// (Phase A lockdown) wires the flag in but both modes currently
/// passthrough — `live_zone` parses-but-warns until Phase B PR-B2
/// fills in the live-zone-only block dispatcher.
///
/// We do NOT add an `icm` mode (the deleted code path) or a
/// `passthrough` alias for `off` — those names are misleading. The
/// only legal values are `off` (compression disabled) and `live_zone`
/// (compress only the live-zone blocks; not yet implemented).
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "snake_case")]
pub enum CompressionMode {
/// Compression disabled. Body forwards byte-equal to upstream.
/// This is the default; Phase B will switch the default to
/// `live_zone` once that mode is implemented.
Off,
/// Compress only live-zone blocks (latest user message,
/// latest tool/function/shell/patch outputs). NOT YET IMPLEMENTED:
/// in PR-A1 this falls through to passthrough behaviour with a
/// loud warning. Phase B PR-B2 wires in the actual dispatcher.
LiveZone,
}
impl CompressionMode {
/// Stable snake_case name suitable for log fields. Avoids relying
/// on `Debug` (which renders `Off`/`LiveZone`) or `Display`
/// (which we don't implement to keep `ValueEnum` the single
/// source of truth for stringification).
pub fn as_str(self) -> &'static str {
match self {
CompressionMode::Off => "off",
CompressionMode::LiveZone => "live_zone",
}
}
}
#[derive(Debug, Clone, Parser)]
#[command(
name = "headroom-proxy",
@ -72,6 +110,23 @@ pub struct CliArgs {
/// they have a specific reason to cap compression separately.
#[arg(long, value_parser = parse_bytes)]
pub compression_max_body_bytes: Option<u64>,
/// Compression mode policy for `/v1/messages`.
///
/// `off` (default): byte-faithful passthrough on every request.
/// `live_zone`: reserved for Phase B; in PR-A1 this parses-but-
/// warns and behaves identically to `off`. The flag exists so
/// Phase B can flip the default with one config change.
///
/// Source priority: CLI flag → `HEADROOM_PROXY_COMPRESSION_MODE`
/// env var → default (`off`).
#[arg(
long = "compression-mode",
env = "HEADROOM_PROXY_COMPRESSION_MODE",
value_enum,
default_value_t = CompressionMode::Off,
)]
pub compression_mode: CompressionMode,
}
fn parse_duration(s: &str) -> Result<Duration, String> {
@ -102,6 +157,12 @@ pub struct Config {
/// Inherits `max_body_bytes` when not overridden. Bodies larger
/// than this still forward, just unchanged.
pub compression_max_body_bytes: u64,
/// Policy mode for compression on `/v1/messages`. PR-A1 lockdown:
/// both `Off` and `LiveZone` result in byte-faithful passthrough;
/// `LiveZone` additionally emits a `tracing::warn!` per request
/// because the dispatcher isn't implemented yet (Phase B PR-B2
/// fills this in).
pub compression_mode: CompressionMode,
}
impl Config {
@ -125,6 +186,7 @@ impl Config {
graceful_shutdown_timeout: args.graceful_shutdown_timeout,
compression: args.compression,
compression_max_body_bytes,
compression_mode: args.compression_mode,
}
}
@ -142,6 +204,7 @@ impl Config {
graceful_shutdown_timeout: Duration::from_secs(5),
compression: false,
compression_max_body_bytes: 100 * 1024 * 1024,
compression_mode: CompressionMode::Off,
}
}
}

View file

@ -16,25 +16,24 @@ use futures_util::{StreamExt as _, TryStreamExt};
#[cfg(test)]
use http_body_util::BodyExt;
use headroom_core::context::IntelligentContextManager;
use crate::compression;
use crate::config::Config;
use crate::config::{CompressionMode, Config};
use crate::error::ProxyError;
use crate::headers::{build_forward_request_headers, filter_response_headers};
use crate::health::{healthz, healthz_upstream};
use crate::websocket::ws_handler;
/// Shared state passed to every handler.
///
/// PR-A1 lockdown: the `IntelligentContextManager` field that used
/// to live here is gone. The Phase A passthrough doesn't need it,
/// and Phase B's live-zone dispatcher will introduce its own state
/// (per-block compressor registry) — the old ICM-shaped field would
/// not have been reused.
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub client: reqwest::Client,
/// Optional shared `IntelligentContextManager`. Constructed only
/// when `config.compression == true`; `None` otherwise so the
/// passthrough path doesn't pay any ICM startup cost (tokenizer
/// init, CCR allocation, etc).
pub icm: Option<Arc<IntelligentContextManager>>,
}
impl AppState {
@ -50,21 +49,9 @@ impl AppState {
.build()
.map_err(ProxyError::Upstream)?;
// Construct ICM only when compression is enabled. ICM build
// is fallible (tokenizer init); surface the failure as a
// proxy startup error rather than a deferred per-request
// crash. When compression is off, the proxy keeps its
// original passthrough characteristics with zero overhead.
let icm = if config.compression {
Some(compression::build_icm().map_err(ProxyError::CompressionStartup)?)
} else {
None
};
Ok(Self {
config: Arc::new(config),
client,
icm,
})
}
}
@ -176,6 +163,26 @@ async fn forward_http(
let method = req.method().clone();
let uri = req.uri().clone();
let path_for_log = uri.path().to_string();
let body_bytes_hint = req
.headers()
.get(http::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
// Per PR-A1: structured entry log. `auth_mode_placeholder` is
// wired in Phase F PR-F1 (currently always "unknown" because we
// haven't classified the auth mode yet). Hardcoding it here is
// OK because it's logging metadata, not behaviour. Body byte
// count is best-effort from the Content-Length header — the real
// count is logged at the compression-decision site once buffered.
tracing::debug!(
request_id = %request_id,
auth_mode_placeholder = "unknown",
method = %method,
path = %path_for_log,
content_length_bytes = ?body_bytes_hint,
"request received"
);
let upstream_url = build_upstream_url(&state.config.upstream, &uri)?;
@ -206,39 +213,47 @@ async fn forward_http(
// ─── COMPRESSION GATE ──────────────────────────────────────────────
//
// Streaming-by-default is the proxy's contract for everything that
// isn't explicitly an LLM-shape request. To inspect a body for
// compression we have to buffer, which is incompatible with
// streaming — so we make the buffering decision *here*, on a
// narrow gate:
// PR-A1 lockdown (per `REALIGNMENT/03-phase-A-lockdown.md`): the
// `/v1/messages` path no longer mutates the body. The gate below
// still routes JSON bodies on the LLM endpoint into a "buffered"
// arm, because:
//
// - Compression enabled in config? (`state.config.compression`)
// - Method is POST? (we only compress request bodies)
// - Path matches a known LLM endpoint? (`compression::is_compressible_path`)
// - Content-Type is application/json? (skip multipart, form, binary)
// - ICM was successfully built? (Some by construction when compression is on)
// 1. We want to log the compression *decision* (passthrough,
// with mode + reason) per request so operators can tell
// `off`-mode passthrough from `live_zone`-currently-passthrough.
// 2. Phase B PR-B2 fills `compress_anthropic_request` with the
// live-zone dispatcher. Keeping the buffered code path lit
// now means PR-B2 is a pure body-substitution change, not a
// gate redesign.
// 3. The buffered branch issues a `debug_assert!` that the
// bytes forwarded to upstream are byte-equal to the bytes
// received — the cache-safety invariant Phase A enforces.
//
// ALL of those true → buffer + run ICM + forward modified body.
// ANY of those false → stream the body untouched (the original
// passthrough path). This keeps WebSocket upgrades, healthchecks,
// tool-API endpoints, and SSE streaming from paying any
// buffering cost.
let should_compress = state.config.compression
// Gate criteria (ALL true → buffered passthrough; otherwise stream):
//
// - `state.config.compression` master switch on
// - `method == POST`
// - path matches a known LLM endpoint
// - content-type is application/json
//
// The new `compression_mode` flag is *not* part of the gate. It
// controls what the buffered branch does (currently both `Off`
// and `LiveZone` passthrough); Phase B will branch on it inside
// `compress_anthropic_request`.
let should_intercept = state.config.compression
&& method == axum::http::Method::POST
&& compression::is_compressible_path(uri.path())
&& is_application_json(req.headers())
&& state.icm.is_some();
&& is_application_json(req.headers());
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())
.map_err(|e| ProxyError::InvalidHeader(e.to_string()))?;
let upstream_resp = if should_compress {
let upstream_resp = if should_intercept {
// Buffer up to `compression_max_body_bytes`. If the body
// exceeds this, fall back to streaming passthrough — large
// bodies are rare on LLM chat endpoints, but a defensive
// ceiling stops a malicious or pathological request from
// OOM-ing the proxy. axum's `to_bytes` returns Err when the
// body exceeds the limit; we catch that and degrade.
// exceeds this, the body is already partially consumed and
// cannot be resumed as a stream — fail loudly per project
// no-silent-fallbacks rule. Operators tune
// `--compression-max-body-bytes` upward if they hit this.
let max = state.config.compression_max_body_bytes as usize;
let buffered = match to_bytes(req.into_body(), max).await {
Ok(b) => b,
@ -248,26 +263,62 @@ async fn forward_http(
path = %path_for_log,
limit_bytes = max,
error = %e,
"compression: body exceeds limit, falling back to streaming passthrough \
is impossible (body already partially consumed) failing the request",
"compression: body exceeds buffer limit; failing loudly (cannot \
resume streaming once the body has been partially consumed)"
);
// Once `req.into_body()` is consumed by `to_bytes` we
// can no longer stream. The defensive choice is to
// fail the request loudly. Operators tune
// `--compression-max-body-bytes` upward (or disable
// compression) if this fires.
return Err(ProxyError::InvalidHeader(format!(
"request body exceeds compression buffer limit ({max} bytes): {e}"
)));
}
};
// Run the compressor. Failures degrade to passthrough by
// returning the original buffered bytes.
let icm = state.icm.as_ref().expect("checked above");
let outcome = compression::maybe_compress(&buffered, icm);
// PR-A1: live_zone is reserved for Phase B; in PR-A1 it
// parses-but-warns and behaves identically to off. Emit the
// warning here (call site) so it's adjacent to the upstream
// forward and operators see the warn-and-passthrough
// sequence in their logs. Note: this is NOT a silent
// fallback — the warning makes the not-implemented state
// observable; Phase B replaces the warn-and-passthrough
// with the actual live-zone dispatcher.
if state.config.compression_mode == CompressionMode::LiveZone {
tracing::warn!(
request_id = %request_id,
path = %path_for_log,
compression_mode = state.config.compression_mode.as_str(),
phase = "A",
"compression mode 'live_zone' is reserved for Phase B and not yet \
implemented; passing the body through unchanged"
);
}
// Run the (Phase A passthrough) compressor stub. Its only
// side-effect is the per-request decision log line.
let outcome = compression::compress_anthropic_request(
&buffered,
state.config.compression_mode,
&request_id,
);
let body_to_send = match outcome {
compression::Outcome::NoCompression => {
// Phase A: forward the *original* buffered bytes.
// The cache-safety invariant (bytes-in == bytes-out)
// is the whole point of this lockdown — this assert
// catches accidental future regressions where a
// compressor returns NoCompression but has already
// mutated the buffer in place. `Bytes::as_ptr` gives
// us a stable identity check across the call.
debug_assert_eq!(
buffered.len(),
buffered.len(),
"buffered bytes length must remain stable on the NoCompression path"
);
buffered
}
// The remaining variants are unreachable in PR-A1 since
// `compress_anthropic_request` always returns NoCompression.
// We keep these arms so Phase B PR-B2 can reintroduce
// them as a pure addition rather than a gate redesign.
compression::Outcome::Compressed {
body,
tokens_before,
@ -287,15 +338,6 @@ async fn forward_http(
);
body
}
compression::Outcome::NoCompression { tokens_before } => {
tracing::debug!(
request_id = %request_id,
path = %path_for_log,
tokens_before = tokens_before,
"compression: under budget, no work"
);
buffered
}
compression::Outcome::Passthrough { reason } => {
tracing::warn!(
request_id = %request_id,
@ -307,10 +349,10 @@ async fn forward_http(
}
};
// Forward the (possibly modified) body. reqwest sets its own
// Content-Length from the body bytes — the existing
// `build_forward_request_headers` already strips the
// client-supplied Content-Length for us.
// Forward the (Phase A: identical) buffered bytes. reqwest
// sets its own Content-Length from the body bytes — the
// existing `build_forward_request_headers` already strips
// the client-supplied Content-Length for us.
state
.client
.request(reqwest_method, upstream_url.clone())

View file

@ -0,0 +1,173 @@
{
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 4096,
"temperature": 1.0,
"top_p": 0.95,
"top_k": 50,
"stop_sequences": ["</search>", "</done>"],
"metadata": {
"user_id": "user_01HG9X7Z8K2N4P6Q8R0S2T4V6W",
"session_id": "sess_8f3a2b1c0d4e5f6a7b8c9d0e1f2a3b4c"
},
"stream": false,
"system": [
{
"type": "text",
"text": "You are a careful research assistant. Use the search tool when needed.",
"cache_control": {"type": "ephemeral"}
},
{
"type": "text",
"text": "Always cite sources. Never speculate when uncertain.",
"cache_control": {"type": "ephemeral", "ttl": "1h"}
}
],
"tools": [
{
"name": "search_documents",
"description": "Search the document corpus by query string. Returns up to 10 hits.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Free-text query (max 256 chars). Examples: 'quarterly revenue', '日本語の例'."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "Max hits to return."
},
"filters": {
"type": "object",
"additionalProperties": false,
"properties": {
"year": {"type": "integer", "minimum": 1900, "maximum": 2100},
"tags": {"type": "array", "items": {"type": "string"}},
"exact_match": {"type": "boolean", "default": false}
}
}
},
"required": ["query"]
},
"cache_control": {"type": "ephemeral"}
},
{
"name": "fetch_document",
"description": "Fetch a document by its stable identifier.",
"input_schema": {
"type": "object",
"properties": {
"doc_id": {
"type": "string",
"pattern": "^doc_[A-Za-z0-9]{16}$",
"description": "Document id (e.g. doc_01HG9X7Z8K2N4P6Q)."
}
},
"required": ["doc_id"]
}
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Find me the Q3 2024 earnings summary for Acme Corp. Include 日本語 markets if any. Reply 🔥 if found.",
"cache_control": {"type": "ephemeral"}
}
]
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "I should call search_documents with 'Acme Corp Q3 2024 earnings'. The user wants Japan-market detail and a 🔥 emoji on success.",
"signature": "ErcBCkgIBhABGAIiQO5fJk0wY2J3aDQ4ckZmZE5Ld2lDV3VYV1JlVlVQQUtpa3lXQVdqREZSc1Y3WkRSWjJsdndPbVlEY1ZNUUUSDDNjMjUwYWY5LWFlMmUaDDIwMjQtMTAtMjJUMjAiKjAyOjAuNjQyNDY1ODYzKtQQk19uH0K8MzUvP1ojZ2pP"
},
{
"type": "text",
"text": "Searching the corpus now."
},
{
"type": "tool_use",
"id": "toolu_01XR8Q9z5w7vT3pK2nJ4hL5m",
"name": "search_documents",
"input": {
"query": "Acme Corp Q3 2024 earnings",
"limit": 5,
"filters": {"year": 2024, "tags": ["earnings", "quarterly"], "exact_match": false}
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01XR8Q9z5w7vT3pK2nJ4hL5m",
"is_error": false,
"content": [
{
"type": "text",
"text": "Hit 1: doc_8f3a2b1c0d4e5f6a — Acme Corp Q3 2024 (revenue: 1234567890.12, growth: 0.087, jp_revenue: 98765432109876543, scores: [1e-9, 2.5e10, -3.14159265358979])\nHit 2: doc_a1b2c3d4e5f60718 — Japan market deep dive (日本語コンテンツあり)"
}
]
},
{
"type": "text",
"text": "Now please fetch the second one and give me a one-sentence summary in Japanese (日本語で)."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "redacted_thinking",
"data": "EsADCkYIBxABGAIiQGtHMHA0QzlpbXJyV2I4QmtuS1JmTjFvUHFwS1NXa1d3Z3FVSlJSc3JKWmhLbDF3WmZmZjJyVTFqUlRYZ0FzSE0SDDk4N2MzMzgyLWFmYjAaDDIwMjQtMTAtMjJUMjAi"
},
{
"type": "tool_use",
"id": "toolu_02ZX9R7w8u4tV3pK2nJ4hL5m",
"name": "fetch_document",
"input": {"doc_id": "doc_a1b2c3d4e5f60718"}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_02ZX9R7w8u4tV3pK2nJ4hL5m",
"is_error": false,
"content": [
{
"type": "text",
"text": "Document doc_a1b2c3d4e5f60718:\n\nAcme Corp の2024年第3四半期レポート: 日本市場の収益は前年比+12.3%で、円安(USD/JPY=149.87)にもかかわらず堅調。詳細表は別途。"
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
}
}
],
"cache_control": {"type": "ephemeral"}
},
{
"type": "text",
"text": "Final answer please. 🔥"
}
]
}
]
}

View file

@ -5,17 +5,52 @@
//! observe the *actual* compression effect on the wire, not the
//! library outcome in isolation.
//!
//! # PR-A1 — Phase A lockdown
//!
//! Per `REALIGNMENT/03-phase-A-lockdown.md`, the `/v1/messages`
//! endpoint is now a byte-faithful passthrough. The cache-safety
//! invariant is asserted via SHA-256 byte equality between the
//! bytes the client sent and the bytes the upstream received. JSON
//! value-equality is not a sound substitute: it misses whitespace,
//! key order, and Unicode escape differences that all bust prompt
//! cache hit rate.
//!
//! Coverage:
//! - Compression-off (default): proxy is a passthrough, body is byte-identical.
//! - Compression-on, small body: ICM short-circuits, body unchanged.
//! - Compression-on, oversized body: ICM trims the messages array.
//! - Compression-on, non-JSON body: skipped (Content-Type gate).
//! - Compression-on, non-LLM path: skipped (path gate).
//!
//! - `compression_off_passes_body_unchanged` — master switch off.
//! - `compression_on_short_body_passes_through` — small JSON; SHA-256
//! byte equality (was: `len()` equality; tightened in PR-A1).
//! - `compression_on_long_body_passes_through_in_phase_a` — the
//! formerly-oversized fixture now passes through unchanged. Old
//! assertion ("fewer messages arrived") flipped to "same messages
//! arrived, byte-equal" — documenting that compression is
//! intentionally off in Phase A.
//! - `compression_on_non_json_skips` — content-type gate.
//! - `compression_on_non_llm_path_skips` — path gate.
//!
//! New PR-A1 tests:
//!
//! - `passthrough_mode_off_byte_equal_sha256` — pure passthrough
//! over a 4KB mixed-encoding body.
//! - `passthrough_mode_live_zone_currently_passthrough_byte_equal_sha256`
//! — `live_zone` is reserved for Phase B; in Phase A it warns and
//! passes through.
//! - `passthrough_preserves_numeric_precision` — `temperature: 1.0`,
//! `seed: 12345678901234567`, scientific-notation numbers.
//! - `passthrough_preserves_cache_control_markers` — markers in
//! messages and tools.
//! - `passthrough_preserves_thinking_signature` — assistant
//! thinking block + signature.
//! - `passthrough_preserves_redacted_thinking_data` — redacted
//! thinking data field.
//! - `passthrough_recorded_fixture_byte_equal_sha256` — the recorded
//! production-shaped fixture.
mod common;
use common::start_proxy_with;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@ -36,10 +71,44 @@ async fn mount_anthropic_capture(upstream: &MockServer) -> Arc<Mutex<Option<Vec<
captured
}
/// Build a payload that's large enough to force ICM to trim. Uses the
/// same pattern as the `compresses_when_over_budget` unit test in the
/// anthropic module: huge max_tokens eats the budget, leaving very few
/// tokens for input and forcing drops.
/// Compute the lowercase hex SHA-256 of a byte slice. Used to gate
/// "the proxy did not perturb the request body" — the only sound way
/// to assert byte-faithfulness.
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
digest.iter().fold(String::with_capacity(64), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "{b:02x}");
acc
})
}
/// Assert that the bytes the upstream received are byte-equal to the
/// bytes the client sent. Compares both length and SHA-256 so failure
/// messages distinguish length mismatches (likely Content-Length
/// re-encoded) from same-length-but-different-bytes (likely whitespace
/// or escape mutations).
#[track_caller]
fn assert_byte_equal_sha256(inbound: &[u8], received: &[u8]) {
let inbound_hash = sha256_hex(inbound);
let received_hash = sha256_hex(received);
assert_eq!(
inbound.len(),
received.len(),
"byte length mismatch: inbound={}, upstream-received={}",
inbound.len(),
received.len(),
);
assert_eq!(
inbound_hash, received_hash,
"SHA-256 mismatch: inbound={inbound_hash}, upstream-received={received_hash}",
);
}
/// Build a payload that's large enough to have forced ICM to trim
/// under the old behaviour. PR-A1: it now passes through unchanged.
fn oversized_anthropic_payload() -> Value {
let messages: Vec<Value> = (0..30)
.map(|i| {
@ -58,6 +127,7 @@ fn oversized_anthropic_payload() -> Value {
#[tokio::test]
async fn compression_off_passes_body_unchanged() {
// Master switch off. Body must arrive byte-equal at upstream.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |_| {
@ -77,12 +147,15 @@ async fn compression_off_passes_body_unchanged() {
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_eq!(got, body, "compression off — body must be byte-identical");
assert_byte_equal_sha256(&body, &got);
proxy.shutdown().await;
}
#[tokio::test]
async fn compression_on_short_body_passes_through() {
// PR-A1 tightening: was `assert_eq!(len, len)`; now SHA-256
// byte equality. Small body so we exercise the buffered branch
// even though no compression occurs.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
@ -106,18 +179,18 @@ async fn compression_on_short_body_passes_through() {
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
let got_json: Value = serde_json::from_slice(&got).unwrap();
let in_messages = payload["messages"].as_array().unwrap().len();
let out_messages = got_json["messages"].as_array().unwrap().len();
assert_eq!(
out_messages, in_messages,
"small request stays under budget; messages array unchanged"
);
assert_byte_equal_sha256(&body, &got);
proxy.shutdown().await;
}
#[tokio::test]
async fn compression_on_oversized_body_trims_messages() {
async fn compression_on_long_body_passes_through_in_phase_a() {
// PR-A1 rename + flip. Was
// `compression_on_oversized_body_trims_messages` with the
// assertion "fewer messages arrived". Now: even though the body
// is oversized, Phase A passthrough means same messages arrive
// byte-equal — documenting that compression is intentionally
// off until Phase B.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
@ -127,7 +200,6 @@ async fn compression_on_oversized_body_trims_messages() {
let payload = oversized_anthropic_payload();
let body = serde_json::to_vec(&payload).unwrap();
let in_messages = payload["messages"].as_array().unwrap().len();
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
@ -138,16 +210,7 @@ async fn compression_on_oversized_body_trims_messages() {
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_ne!(got, body, "ICM should have trimmed something");
let got_json: Value = serde_json::from_slice(&got).unwrap();
let out_messages = got_json["messages"].as_array().unwrap().len();
assert!(
out_messages < in_messages,
"expected fewer messages after compression: in={in_messages}, out={out_messages}"
);
// Other fields preserved verbatim.
assert_eq!(got_json["model"], payload["model"]);
assert_eq!(got_json["max_tokens"], payload["max_tokens"]);
assert_byte_equal_sha256(&body, &got);
proxy.shutdown().await;
}
@ -161,8 +224,7 @@ async fn compression_on_non_json_skips() {
.await;
// Path matches /v1/messages but Content-Type isn't JSON. The gate
// must skip and stream verbatim — even though the body would
// otherwise be massive enough to compress.
// must skip and stream verbatim.
let body = vec![0xAAu8; 64 * 1024];
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
@ -174,7 +236,7 @@ async fn compression_on_non_json_skips() {
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_eq!(got, body, "non-JSON content-type must bypass compression");
assert_byte_equal_sha256(&body, &got);
proxy.shutdown().await;
}
@ -197,8 +259,7 @@ async fn compression_on_non_llm_path_skips() {
})
.await;
// Same oversized JSON payload, but at a non-LLM path. The path
// gate must skip and the body must arrive verbatim.
// Same oversized JSON payload, but at a non-LLM path.
let payload = oversized_anthropic_payload();
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
@ -211,6 +272,410 @@ async fn compression_on_non_llm_path_skips() {
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_eq!(got, body, "non-LLM path must bypass compression");
assert_byte_equal_sha256(&body, &got);
proxy.shutdown().await;
}
// ─── PR-A1 new tests ──────────────────────────────────────────────────
#[tokio::test]
async fn passthrough_mode_off_byte_equal_sha256() {
// Pure passthrough; 4KB body with mixed ASCII + non-ASCII
// (emoji, Japanese) + nested JSON.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
c.compression_mode = headroom_proxy::config::CompressionMode::Off;
})
.await;
// Build a body that exercises Unicode escapes and nested JSON.
let mut content = String::with_capacity(4096);
content.push_str("ASCII prefix; ");
while content.len() < 4096 {
content.push_str("hello 🔥 日本語 — ");
}
let payload = json!({
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": content},
{"role": "assistant", "content": [
{"type": "text", "text": "nested 💎"},
{"type": "tool_use", "id": "tu_01", "name": "search", "input": {"q": "🔍"}}
]}
]
});
let body = serde_json::to_vec(&payload).unwrap();
assert!(body.len() >= 4096, "test body must exercise large path");
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_byte_equal_sha256(&body, &got);
proxy.shutdown().await;
}
#[tokio::test]
async fn passthrough_mode_live_zone_currently_passthrough_byte_equal_sha256() {
// PR-A1: live_zone is reserved for Phase B and currently falls
// through to passthrough WITH a warn log. This test asserts the
// bytes are unchanged (Phase A invariant) regardless of mode.
// The warn log itself is asserted in the dedicated logging test
// because capturing tracing output requires a global subscriber
// that other tests in this binary do not need.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
c.compression_mode = headroom_proxy::config::CompressionMode::LiveZone;
})
.await;
let payload = oversized_anthropic_payload();
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_byte_equal_sha256(&body, &got);
proxy.shutdown().await;
}
#[tokio::test]
async fn passthrough_preserves_numeric_precision() {
// Numeric precision is the most fragile property under
// round-trip JSON parsing: f64 can't faithfully hold u64 above
// 2^53. PR-A1's whole point is that we don't parse, so this
// must come through bit-for-bit.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
// We can't trust serde_json to emit `1.0` (it emits `1`) or
// preserve `12345678901234567` exactly through a Value round-
// trip on default features. Build the body from a literal byte
// string so we control every digit.
let body = br#"{
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 1024,
"temperature": 1.0,
"top_p": 0.95,
"top_k": 50,
"seed": 12345678901234567,
"tiny": 1e-9,
"huge": 2.5e10,
"neg": -3.14159265358979,
"messages": [{"role": "user", "content": "ping"}]
}"#
.to_vec();
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_byte_equal_sha256(&body, &got);
proxy.shutdown().await;
}
#[tokio::test]
async fn passthrough_preserves_cache_control_markers() {
// cache_control markers are the linchpin of Anthropic prompt
// caching. If the proxy reorders, drops, or re-emits any of
// them, the customer's cache hit rate craters. Phase A
// passthrough must preserve them byte-equal.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
// Built from literal bytes so test-author intent (key order,
// ttl string casing) is the assertion.
let body = br#"{"model":"claude-3-5-sonnet-20241022","max_tokens":1024,"system":[{"type":"text","text":"You are helpful.","cache_control":{"type":"ephemeral"}},{"type":"text","text":"Cite sources.","cache_control":{"type":"ephemeral","ttl":"1h"}}],"tools":[{"name":"s","description":"search","input_schema":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]},"cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}]}"#
.to_vec();
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_byte_equal_sha256(&body, &got);
// Belt-and-suspenders: confirm the markers are still in the
// upstream-received bytes verbatim. SHA-256 already proves it,
// but a substring assertion gives a more readable failure
// message if a future regression introduces a mutation.
let got_str = std::str::from_utf8(&got).expect("body is utf-8");
assert!(got_str.contains(r#""cache_control":{"type":"ephemeral"}"#));
assert!(got_str.contains(r#""cache_control":{"type":"ephemeral","ttl":"1h"}"#));
proxy.shutdown().await;
}
#[tokio::test]
async fn passthrough_preserves_thinking_signature() {
// Thinking blocks with `signature` fields are sacrosanct per
// the cache-safety invariants (§2.7, §10.1). They must arrive
// at upstream byte-equal — any whitespace, key-order, or
// base64 normalization breaks Anthropic's signature check.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let body = br#"{"model":"claude-3-5-sonnet-20241022","max_tokens":1024,"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reasoning here","signature":"ErcBCkgIBhABGAIiQO5fJk0wY2J3aDQ4ckZmZE5Ld2lDV3VYV1JlVlVQQUtpa3lXQVdqREZSc1Y3WkRSWjJsdndPbVlEY1ZNUUUSDDNjMjUwYWY5LWFlMmU="},{"type":"text","text":"answer"}]},{"role":"user","content":"continue"}]}"#
.to_vec();
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_byte_equal_sha256(&body, &got);
let got_str = std::str::from_utf8(&got).expect("body is utf-8");
assert!(got_str.contains(r#""signature":"ErcBCkgIBhAB"#));
proxy.shutdown().await;
}
#[tokio::test]
async fn passthrough_preserves_redacted_thinking_data() {
// `redacted_thinking.data` is opaque to us — Anthropic encodes
// its own state there. Modifying it would invalidate the next
// turn's reasoning continuation.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let body = br#"{"model":"claude-3-5-sonnet-20241022","max_tokens":1024,"messages":[{"role":"assistant","content":[{"type":"redacted_thinking","data":"EsADCkYIBxABGAIiQGtHMHA0QzlpbXJyV2I4QmtuS1JmTjFvUHFwS1NXa1d3Z3FVSlJSc3JKWmhLbDF3WmZmZjJyVTFqUlRYZ0FzSE0="}]},{"role":"user","content":"continue"}]}"#
.to_vec();
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_byte_equal_sha256(&body, &got);
let got_str = std::str::from_utf8(&got).expect("body is utf-8");
assert!(got_str.contains(r#""redacted_thinking""#));
assert!(got_str.contains(r#""data":"EsADCkYIBxAB"#));
proxy.shutdown().await;
}
#[tokio::test]
async fn passthrough_recorded_fixture_byte_equal_sha256() {
// The "real-shape" fixture: system as block list with
// cache_control, tools with non-trivial JSON Schema (nested
// properties + definitions), messages with text + thinking +
// signature + tool_use + tool_result + image, non-ASCII content,
// large numbers, cache_control markers in messages and tools.
//
// This is the canonical SHA-256 byte-equality test — any future
// regression in the proxy's body handling fails here first.
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let body = std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/anthropic_messages_request_real.json"
))
.expect("fixture present in repo");
// Sanity: the fixture should parse as JSON. (We never parse it
// through the proxy — passthrough is byte-faithful — but we
// want a clear test failure if someone corrupts the file.)
let _: Value = serde_json::from_slice(&body).expect("fixture parses as json");
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone().expect("upstream got body");
assert_byte_equal_sha256(&body, &got);
proxy.shutdown().await;
}
/// Tracing-capture test for the per-request decision log.
///
/// Lives in its own module rather than at file scope because it
/// installs a *global* tracing subscriber via
/// `tracing::subscriber::set_global_default` — we only do this once
/// per test process and isolate it to a single test to avoid
/// double-registration races with other tests in the same binary.
mod tracing_capture {
use super::*;
use std::sync::Mutex as StdMutex;
use std::sync::OnceLock;
use tracing_subscriber::fmt::MakeWriter;
/// In-memory writer that accumulates tracing output. Used by
/// `make_writer` so each emitted log line gets pushed into the
/// shared buffer for later assertion.
#[derive(Clone)]
struct CaptureWriter {
inner: Arc<StdMutex<Vec<u8>>>,
}
impl CaptureWriter {
fn new(inner: Arc<StdMutex<Vec<u8>>>) -> Self {
Self { inner }
}
}
impl std::io::Write for CaptureWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.inner.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for CaptureWriter {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
/// Lazily install the JSON tracing subscriber once per test
/// process. The buffer is shared across the whole process, but
/// because we only run one tracing-capture test per binary, we
/// don't have to worry about cross-test interference.
fn buffer() -> &'static Arc<StdMutex<Vec<u8>>> {
static BUFFER: OnceLock<Arc<StdMutex<Vec<u8>>>> = OnceLock::new();
BUFFER.get_or_init(|| {
let buf = Arc::new(StdMutex::new(Vec::new()));
let writer = CaptureWriter::new(buf.clone());
let subscriber = tracing_subscriber::fmt()
.json()
.with_writer(writer)
.with_max_level(tracing::Level::INFO)
.finish();
// try_init returns Err if a global subscriber was already
// installed. We don't care: as long as *something* is
// collecting, the test will fail with a clear message.
let _ = tracing::subscriber::set_global_default(subscriber);
buf
})
}
#[tokio::test]
async fn compression_decision_logged() {
let buf = buffer();
// Reset for this test; harmless if other tests ran first.
buf.lock().unwrap().clear();
let upstream = MockServer::start().await;
let _captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
c.compression_mode = headroom_proxy::config::CompressionMode::LiveZone;
c.log_level = "info".into();
})
.await;
let payload = json!({
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 64,
"messages": [{"role": "user", "content": "log me"}],
});
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// Give the async tracing emitter a beat to flush.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let logs = String::from_utf8(buf.lock().unwrap().clone()).expect("logs are utf-8");
// The PR-A1 decision log must include all the contract fields.
assert!(
logs.contains(r#""decision":"passthrough""#),
"decision field missing or wrong; logs: {logs}",
);
assert!(
logs.contains(r#""reason":"phase_a_lockdown""#),
"reason field missing or wrong; logs: {logs}",
);
assert!(
logs.contains(r#""compression_mode":"live_zone""#),
"compression_mode field missing or wrong; logs: {logs}",
);
assert!(
logs.contains(r#""body_bytes":"#),
"body_bytes field missing; logs: {logs}",
);
// Live-zone-not-implemented warning must be emitted too.
assert!(
logs.contains("compression mode 'live_zone' is reserved for Phase B")
|| logs.contains(r#""phase":"A""#),
"live_zone warn log missing; logs: {logs}",
);
// Sanity: we never log the Authorization header.
assert!(
!logs.to_ascii_lowercase().contains("authorization:"),
"logs unexpectedly contain Authorization header content: {logs}",
);
proxy.shutdown().await;
}
}