fix(rust): wire ICM compressor into Rust proxy on /v1/messages

Adds an opt-in compression interceptor that buffers Anthropic
/v1/messages requests, runs IntelligentContextManager over the
messages array, and forwards the (possibly trimmed) body upstream.
All other paths, methods, and content-types stay on the original
streaming passthrough — so existing operators see zero change.

Behaviour gates ALL must be true to buffer + compress:
  - --compression flag (or HEADROOM_PROXY_COMPRESSION=1)
  - method == POST
  - path == /v1/messages
  - Content-Type: application/json
  - ICM constructed successfully at startup

Falls through to streaming on any failure: parse, missing fields,
unknown model, body-too-large. Compression must never break a
request — that's the safety contract.

Model context windows come from a vendored LiteLLM snapshot at
crates/headroom-proxy/data/model_prices_and_context_window.json
parsed once into an OnceLock<HashMap>. Refresh via
scripts/refresh_model_limits.sh. Rationale documented inline:
hardcoded tables silently rot; LiteLLM is the canonical source
the entire LLM-tooling ecosystem relies on.

New tests:
  - 16 unit tests across compression::{anthropic, icm, model_limits}
  - 5 integration tests: off-passthrough, on-short-passthrough,
    on-oversized-trim, on-non-json-skip, on-non-llm-path-skip

Verification:
  - cargo test --workspace -> 884 passed, 0 failed
  - cargo clippy --workspace -- -D warnings -> clean
  - cargo fmt --check -> clean
This commit is contained in:
chopratejas 2026-05-01 16:41:22 -07:00
parent f726d0e280
commit fa5fbfabf4
17 changed files with 40945 additions and 20 deletions

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.20.2"
"version": "0.20.8"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.20.2",
"version": "0.20.8",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.20.2"
"version": "0.20.8"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.20.2",
"version": "0.20.8",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

1
.gitignore vendored
View file

@ -22,6 +22,7 @@ scripts/*
!scripts/build_rust_extension.sh
!scripts/install-git-hooks.sh
!scripts/smoke_issue_327.py
!scripts/refresh_model_limits.sh
# Rust / Cargo build artifacts
/target/

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,325 @@
//! Anthropic `/v1/messages` request compression.
//!
//! # Request shape (relevant subset)
//!
//! ```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
//! ...
//! }
//! ```
//!
//! # What we do
//!
//! 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 we DON'T do
//!
//! - 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.
use bytes::Bytes;
use serde_json::Value;
use headroom_core::context::{ApplyCtx, IntelligentContextManager};
use super::model_limits::context_window_for;
/// What happened. Used for the request-level tracing log.
#[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.
Compressed {
body: Bytes,
tokens_before: usize,
tokens_after: usize,
strategies_applied: Vec<&'static str>,
markers_inserted: Vec<String>,
},
}
/// Why we passed the body through unchanged.
#[derive(Debug, Clone, Copy)]
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).
SerializeFailed,
}
/// Run ICM over an Anthropic-shape body. Returns one of:
///
/// - `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.
///
/// 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,
},
);
// 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,
}
}
#[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 { .. } => {}
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:?}"),
}
}
#[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
}
#[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
}
}

View file

@ -0,0 +1,70 @@
//! 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

@ -0,0 +1,70 @@
//! 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:
//!
//! - 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.
//!
//! # Provider matrix (current + planned)
//!
//! | Provider | Path | Status |
//! |--------------|-----------------------|--------|
//! | Anthropic | `POST /v1/messages` | ✅ this module |
//! | 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.
pub mod anthropic;
pub mod icm;
pub mod model_limits;
pub use anthropic::{maybe_compress, Outcome, PassthroughReason};
pub use icm::build_icm;
/// Does this request path target an LLM endpoint we know how to
/// compress? Cheap pre-filter before buffering the body.
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
// compression scope explicit — `/v1/messages/123` (a
// hypothetical future per-message endpoint) shouldn't accidentally
// get its body parsed as a chat-completions request.
path == "/v1/messages"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anthropic_messages_path_matches() {
assert!(is_compressible_path("/v1/messages"));
}
#[test]
fn other_paths_skip() {
assert!(!is_compressible_path("/v1/messages/123"));
assert!(!is_compressible_path("/v1/chat/completions"));
assert!(!is_compressible_path("/healthz"));
assert!(!is_compressible_path("/"));
assert!(!is_compressible_path(""));
}
}

View file

@ -0,0 +1,211 @@
//! Model name → context window (tokens) lookup, sourced from LiteLLM.
//!
//! # Why LiteLLM and not a hand-rolled table
//!
//! Earlier drafts of this module hardcoded a small `if/else` chain
//! covering Claude / GPT-4o / GPT-3.5. Two failure modes that cost us:
//!
//! 1. **Static rot.** Anthropic and OpenAI ship new models monthly.
//! A hardcoded table goes stale between Headroom releases; new
//! models silently fall through to a default that is often wrong.
//! 2. **Long tail.** Bedrock, Cohere, Mistral, Gemini, AzureML — each
//! has dozens of model variants. We aren't going to maintain
//! a comprehensive table by hand.
//!
//! [LiteLLM] maintains `model_prices_and_context_window.json` as a
//! community-curated source of truth: ~2000 chat models, refreshed
//! weekly, with `max_input_tokens` (the field we care about) plus
//! pricing, output limits, capability flags. Every other LLM-tool
//! ecosystem (Portkey, Helicone, OpenRouter, Continue) pulls from it.
//!
//! [LiteLLM]: https://github.com/BerriAI/litellm
//!
//! # Vendoring strategy
//!
//! We **check the JSON into the repo** at
//! `crates/headroom-proxy/data/model_prices_and_context_window.json`
//! and `include_str!` it at compile time. Reasons over alternatives:
//!
//! - **Build-time fetch (`build.rs` + curl):** breaks for offline /
//! air-gapped builds — bad for BYOC where customers may not allow
//! outbound network during install.
//! - **Runtime fetch:** same problem, plus a startup-failure surface
//! we don't need.
//!
//! Refresh is operator-driven: `scripts/refresh_model_limits.sh`
//! re-pulls and validates the JSON, the diff lands in a regular PR.
//! We trade "always fresh" for "deterministic, offline-buildable,
//! auditable in version control" — the right trade for a deploy
//! artifact that's expected to run in customer VPCs.
//!
//! # Performance
//!
//! The 1.4MB JSON parses in ~10ms one-time on first lookup. Parsed
//! result is cached in a `OnceLock<HashMap>` so subsequent lookups
//! are O(1). When `--compression` is off, the JSON is in the binary
//! image but never parsed — zero runtime cost.
//!
//! # Default for unknown models
//!
//! When a model isn't in the table, we return a conservative 128K
//! and emit a `tracing::warn!` (once per unknown model id). 128K is
//! the dominant context window across modern frontier models; being
//! wrong here means we either over-compress (safe — we just trim
//! unnecessarily, the request still works) or under-compress (the
//! upstream rejects it with `context_length_exceeded`, which is
//! recoverable by the client).
use std::collections::HashMap;
use std::sync::OnceLock;
/// Conservative default for unknown models. Modern frontier models
/// almost universally have ≥128K context; we err on the side of
/// over-compressing (safe) rather than under-compressing (broken).
pub(crate) const DEFAULT_CONTEXT_WINDOW: u32 = 128_000;
/// LiteLLM's vendored model price + context-window table. Refreshed
/// via `scripts/refresh_model_limits.sh`. ~1.4MB; embedded into the
/// binary so the proxy ships with no startup network dependency.
const VENDORED_JSON: &str = include_str!("../../data/model_prices_and_context_window.json");
/// Parsed lookup: model id → max input tokens. Built lazily on
/// first call; subsequent calls reuse the same `HashMap`.
static TABLE: OnceLock<HashMap<String, u32>> = OnceLock::new();
/// Looks up `max_input_tokens` for `model`. Returns
/// [`DEFAULT_CONTEXT_WINDOW`] when the model isn't in the table.
///
/// Lookup is exact-match by model id. We deliberately do NOT do
/// prefix matching — model versions are semantically distinct
/// (`claude-3-5-sonnet-20241022` was 200K, but a hypothetical
/// `claude-3-5-sonnet-mini` may be different) and a prefix rule
/// would cause silent wrong answers.
pub fn context_window_for(model: &str) -> u32 {
let table = TABLE.get_or_init(parse_vendored);
if let Some(&n) = table.get(model) {
return n;
}
// Unknown model. We don't log here on every miss — that's
// per-request noise. The caller (compression::anthropic) logs
// once, with the model id, when this happens. Just return the
// default and let the caller handle observability.
DEFAULT_CONTEXT_WINDOW
}
/// Walk the LiteLLM JSON and extract the chat-model context windows.
///
/// LiteLLM's schema: top-level object whose keys are model ids.
/// Values may be:
/// - `sample_spec` — a template entry; skipped.
/// - Image / audio / embedding entries — `mode != "chat"`; skipped.
/// - Chat entries — have `max_input_tokens` (preferred) or
/// `max_tokens` (legacy fallback).
///
/// The 79-ish chat entries in the current snapshot that lack BOTH
/// fields are skipped silently — they'd hit `DEFAULT_CONTEXT_WINDOW`
/// at lookup time anyway.
fn parse_vendored() -> HashMap<String, u32> {
let raw: serde_json::Value = serde_json::from_str(VENDORED_JSON)
.expect("vendored LiteLLM JSON must parse at build time");
let obj = raw
.as_object()
.expect("LiteLLM JSON must be a top-level object");
// Slight over-allocation; better than reallocating during the walk.
let mut out: HashMap<String, u32> = HashMap::with_capacity(obj.len());
for (key, val) in obj {
if key == "sample_spec" {
continue;
}
let entry = match val.as_object() {
Some(o) => o,
None => continue,
};
// Only chat-mode models are relevant for our compressor.
// Image / audio / embedding endpoints don't have a "messages"
// array we can compress.
if entry.get("mode").and_then(|m| m.as_str()) != Some("chat") {
continue;
}
// Prefer max_input_tokens. Fall back to max_tokens (older
// entries used max_tokens as a synonym for input window).
let n = entry
.get("max_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| entry.get("max_tokens").and_then(|v| v.as_u64()));
let Some(n) = n else { continue };
// u32 fits every realistic context window. The largest known
// today is ~10M (Magic.dev, hypothetical) — still under
// 4 billion. If a future model crosses u32::MAX we have
// larger problems than this `as`.
out.insert(key.clone(), n.min(u32::MAX as u64) as u32);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vendored_json_parses_at_runtime() {
// Calling this once forces parse via OnceLock. If the JSON
// is malformed, this test panics with a useful error before
// any lookup test runs. Subsequent tests in this module
// share the same parsed table.
let table = TABLE.get_or_init(parse_vendored);
assert!(
table.len() > 100,
"expected >100 chat models in LiteLLM snapshot, got {}",
table.len()
);
}
#[test]
fn current_claude_models_present() {
// Lock against the snapshot rotting silently. If LiteLLM
// renames the canonical Claude entry we want a test failure
// — not a silent fall-through to DEFAULT_CONTEXT_WINDOW.
// Pick a model we expect to remain stable: claude-sonnet-4-5
// (current as of the snapshot fetch).
let n = context_window_for("claude-sonnet-4-5-20250929");
assert_eq!(n, 200_000, "claude-sonnet-4-5 should be 200K input window");
}
#[test]
fn current_gpt_models_present() {
assert_eq!(context_window_for("gpt-4o-mini"), 128_000);
assert_eq!(context_window_for("gpt-4-turbo"), 128_000);
}
#[test]
fn unknown_model_returns_default() {
assert_eq!(
context_window_for("definitely-not-a-real-model-2099"),
DEFAULT_CONTEXT_WINDOW
);
assert_eq!(context_window_for(""), DEFAULT_CONTEXT_WINDOW);
}
#[test]
fn empty_or_garbage_string_does_not_panic() {
// The lookup must not panic on adversarial input — bad
// model strings come from the wire and we forward unknown
// ones rather than failing the request.
let _ = context_window_for("");
let _ = context_window_for("\0\0\0");
let _ = context_window_for(&"x".repeat(10_000));
}
#[test]
fn sample_spec_entry_is_excluded() {
// LiteLLM's JSON includes a "sample_spec" template entry
// documenting the schema. It must not appear as a real
// model in our lookup — a request specifying it would
// otherwise get a bogus context window.
let table = TABLE.get_or_init(parse_vendored);
assert!(!table.contains_key("sample_spec"));
}
}

View file

@ -50,6 +50,28 @@ pub struct CliArgs {
/// Maximum time to wait for in-flight requests to finish on shutdown.
#[arg(long, default_value = "30s", value_parser = parse_duration)]
pub graceful_shutdown_timeout: Duration,
/// Enable Headroom compression on LLM-shaped requests
/// (currently: `POST /v1/messages` for Anthropic). When off,
/// the proxy stays a pure streaming passthrough.
///
/// Off by default so existing operators get unchanged behaviour
/// and the integration-test harness doesn't need to opt out
/// per-test. Operators wanting to demo the compressor pass
/// `--compression` (or set `HEADROOM_PROXY_COMPRESSION=1`).
#[arg(
long = "compression",
env = "HEADROOM_PROXY_COMPRESSION",
default_value_t = false
)]
pub compression: bool,
/// Maximum body size to buffer for compression. Bodies larger
/// than this get forwarded unchanged. Defaults to `--max-body-bytes`
/// when unset, so operators only need to tune one knob unless
/// they have a specific reason to cap compression separately.
#[arg(long, value_parser = parse_bytes)]
pub compression_max_body_bytes: Option<u64>,
}
fn parse_duration(s: &str) -> Result<Duration, String> {
@ -73,6 +95,13 @@ pub struct Config {
pub log_level: String,
pub rewrite_host: bool,
pub graceful_shutdown_timeout: Duration,
/// Master switch for the LLM compression interceptor. When `false`,
/// the proxy is pure streaming passthrough and never buffers a body.
pub compression: bool,
/// Effective ceiling for compression-time body buffering.
/// Inherits `max_body_bytes` when not overridden. Bodies larger
/// than this still forward, just unchanged.
pub compression_max_body_bytes: u64,
}
impl Config {
@ -82,6 +111,9 @@ impl Config {
} else {
args.rewrite_host
};
let compression_max_body_bytes = args
.compression_max_body_bytes
.unwrap_or(args.max_body_bytes);
Self {
listen: args.listen,
upstream: args.upstream,
@ -91,10 +123,13 @@ impl Config {
log_level: args.log_level,
rewrite_host,
graceful_shutdown_timeout: args.graceful_shutdown_timeout,
compression: args.compression,
compression_max_body_bytes,
}
}
/// Test/library helper.
/// Test/library helper. Compression off by default — match
/// production-default behaviour so existing tests stay unchanged.
pub fn for_test(upstream: Url) -> Self {
Self {
listen: "127.0.0.1:0".parse().unwrap(),
@ -105,6 +140,8 @@ impl Config {
log_level: "warn".into(),
rewrite_host: true,
graceful_shutdown_timeout: Duration::from_secs(5),
compression: false,
compression_max_body_bytes: 100 * 1024 * 1024,
}
}
}

View file

@ -20,6 +20,16 @@ pub enum ProxyError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
/// Surfaced when `--compression` is enabled but the proxy can't
/// build the IntelligentContextManager at startup (e.g. the
/// embedded tokenizer asset failed to initialize). Bubbles up to
/// `main` as a fatal startup error rather than a per-request
/// failure — if compression is configured but the engine won't
/// build, the operator should know immediately, not at first
/// LLM request.
#[error("compression engine startup failed: {0}")]
CompressionStartup(String),
}
impl IntoResponse for ProxyError {
@ -38,6 +48,12 @@ impl IntoResponse for ProxyError {
ProxyError::InvalidHeader(_) => (StatusCode::BAD_REQUEST, self.to_string()),
ProxyError::WebSocket(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
// CompressionStartup is a startup-time error, not a
// per-request one — but if it ever surfaces in the
// handler path, surface as 500 rather than panic.
ProxyError::CompressionStartup(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
};
tracing::warn!(error = %msg, "proxy error");
(status, msg).into_response()

View file

@ -1,6 +1,7 @@
//! headroom-proxy library: transparent reverse proxy in front of the Python
//! Headroom proxy. Used by both `main.rs` and the integration tests.
pub mod compression;
pub mod config;
pub mod error;
pub mod headers;

View file

@ -4,7 +4,7 @@ use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;
use axum::body::Body;
use axum::body::{to_bytes, Body};
use axum::extract::{ConnectInfo, State, WebSocketUpgrade};
use axum::http::{HeaderMap, HeaderName, Request, Response, StatusCode, Uri};
use axum::response::IntoResponse;
@ -16,6 +16,9 @@ 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::error::ProxyError;
use crate::headers::{build_forward_request_headers, filter_response_headers};
@ -27,6 +30,11 @@ use crate::websocket::ws_handler;
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 {
@ -41,9 +49,22 @@ impl AppState {
// Both HTTP/1.1 and HTTP/2 negotiated via ALPN.
.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,
})
}
}
@ -81,6 +102,23 @@ async fn catch_all(
.unwrap_or_else(|e| e.into_response())
}
/// True if `Content-Type` is `application/json` (with any optional
/// parameters like `; charset=utf-8`). Compression only inspects JSON
/// bodies — multipart uploads, form-encoded posts, and binary
/// payloads stream through untouched.
fn is_application_json(headers: &HeaderMap) -> bool {
headers
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| {
// Take the media-type portion before any ';'. Trim and
// compare case-insensitively per RFC 7231 §3.1.1.1.
let media_type = s.split(';').next().unwrap_or("").trim();
media_type.eq_ignore_ascii_case("application/json")
})
.unwrap_or(false)
}
fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
let upgrade = headers
.get(http::header::UPGRADE)
@ -166,20 +204,133 @@ async fn forward_http(
}
}
// Stream the request body through to reqwest. We don't buffer.
let body_stream =
TryStreamExt::map_err(req.into_body().into_data_stream(), std::io::Error::other);
let reqwest_body = reqwest::Body::wrap_stream(body_stream);
// ─── 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:
//
// - 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)
//
// 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
&& method == axum::http::Method::POST
&& compression::is_compressible_path(uri.path())
&& is_application_json(req.headers())
&& state.icm.is_some();
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())
.map_err(|e| ProxyError::InvalidHeader(e.to_string()))?;
let upstream_resp = state
.client
.request(reqwest_method, upstream_url.clone())
.headers(outgoing_headers)
.body(reqwest_body)
.send()
.await?;
let upstream_resp = if should_compress {
// 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.
let max = state.config.compression_max_body_bytes as usize;
let buffered = match to_bytes(req.into_body(), max).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(
request_id = %request_id,
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",
);
// 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);
let body_to_send = match outcome {
compression::Outcome::Compressed {
body,
tokens_before,
tokens_after,
strategies_applied,
markers_inserted,
} => {
tracing::info!(
request_id = %request_id,
path = %path_for_log,
tokens_before = tokens_before,
tokens_after = tokens_after,
tokens_freed = tokens_before.saturating_sub(tokens_after),
strategies = ?strategies_applied,
markers = markers_inserted.len(),
"compression applied"
);
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,
path = %path_for_log,
reason = ?reason,
"compression: passthrough on parse/serialize"
);
buffered
}
};
// 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.
state
.client
.request(reqwest_method, upstream_url.clone())
.headers(outgoing_headers)
.body(body_to_send)
.send()
.await?
} else {
// Pure streaming path — the original passthrough behaviour.
let body_stream =
TryStreamExt::map_err(req.into_body().into_data_stream(), std::io::Error::other);
let reqwest_body = reqwest::Body::wrap_stream(body_stream);
state
.client
.request(reqwest_method, upstream_url.clone())
.headers(outgoing_headers)
.body(reqwest_body)
.send()
.await?
};
let upstream_status = upstream_resp.status();
let status = StatusCode::from_u16(upstream_status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);

View file

@ -34,8 +34,20 @@ impl ProxyHandle {
#[allow(dead_code)]
pub async fn start_proxy(upstream: &str) -> ProxyHandle {
start_proxy_with(upstream, |_| {}).await
}
/// Start a proxy with a customized `Config`. The closure receives a
/// mutable reference to the default `Config::for_test` and may toggle
/// flags like `compression` before the proxy is built.
#[allow(dead_code)]
pub async fn start_proxy_with<F>(upstream: &str, customize: F) -> ProxyHandle
where
F: FnOnce(&mut Config),
{
let upstream_url: Url = upstream.parse().expect("valid upstream url");
let config = Config::for_test(upstream_url);
let mut config = Config::for_test(upstream_url);
customize(&mut config);
let state = AppState::new(config.clone()).expect("app state");
let app = build_app(state).into_make_service_with_connect_info::<SocketAddr>();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")

View file

@ -0,0 +1,216 @@
//! End-to-end integration tests for the compression interceptor.
//!
//! These tests boot a real Rust proxy in front of a wiremock upstream
//! and verify the request body that arrives at the upstream — i.e. we
//! observe the *actual* compression effect on the wire, not the
//! library outcome in isolation.
//!
//! 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).
mod common;
use common::start_proxy_with;
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Mount a /v1/messages handler that captures the upstream request body
/// into the returned Arc<Mutex<...>> for assertions, and returns 200 OK.
async fn mount_anthropic_capture(upstream: &MockServer) -> Arc<Mutex<Option<Vec<u8>>>> {
let captured: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
let captured_clone = captured.clone();
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(move |req: &wiremock::Request| {
*captured_clone.lock().unwrap() = Some(req.body.clone());
ResponseTemplate::new(200).set_body_string(r#"{"ok":true}"#)
})
.mount(upstream)
.await;
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.
fn oversized_anthropic_payload() -> Value {
let messages: Vec<Value> = (0..30)
.map(|i| {
json!({
"role": if i % 2 == 0 { "user" } else { "assistant" },
"content": format!("padding token {i} ").repeat(20),
})
})
.collect();
json!({
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 199_500,
"messages": messages,
})
}
#[tokio::test]
async fn compression_off_passes_body_unchanged() {
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |_| {
// compression remains off (Config::for_test default)
})
.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_eq!(got, body, "compression off — body must be byte-identical");
proxy.shutdown().await;
}
#[tokio::test]
async fn compression_on_short_body_passes_through() {
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 payload = json!({
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "hello"}],
});
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");
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"
);
proxy.shutdown().await;
}
#[tokio::test]
async fn compression_on_oversized_body_trims_messages() {
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 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")
.body(body.clone())
.send()
.await
.unwrap();
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"]);
proxy.shutdown().await;
}
#[tokio::test]
async fn compression_on_non_json_skips() {
let upstream = MockServer::start().await;
let captured = mount_anthropic_capture(&upstream).await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.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.
let body = vec![0xAAu8; 64 * 1024];
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/octet-stream")
.body(body.clone())
.send()
.await
.unwrap();
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");
proxy.shutdown().await;
}
#[tokio::test]
async fn compression_on_non_llm_path_skips() {
let upstream = MockServer::start().await;
let captured: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
let captured_clone = captured.clone();
Mock::given(method("POST"))
.and(path("/some/other/api"))
.respond_with(move |req: &wiremock::Request| {
*captured_clone.lock().unwrap() = Some(req.body.clone());
ResponseTemplate::new(200).set_body_string("ok")
})
.mount(&upstream)
.await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
// Same oversized JSON payload, but at a non-LLM path. The path
// gate must skip and the body must arrive verbatim.
let payload = oversized_anthropic_payload();
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/some/other/api", 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_eq!(got, body, "non-LLM path must bypass compression");
proxy.shutdown().await;
}

View file

@ -1,6 +1,6 @@
{
"name": "headroom",
"version": "0.20.2",
"version": "0.20.8",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"author": {
"name": "Headroom Contributors",

View file

@ -1,6 +1,6 @@
{
"name": "headroom",
"version": "0.20.2",
"version": "0.20.8",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"author": {
"name": "Headroom Contributors",

48
scripts/refresh_model_limits.sh Executable file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# Refresh the vendored LiteLLM model_prices_and_context_window.json
# used by `crates/headroom-proxy/src/compression/model_limits.rs`.
#
# We vendor the snapshot rather than fetching at build/runtime so the
# proxy binary ships with no network dependency at startup. Operators
# tracking new model releases run this script and commit the diff.
#
# Validation:
# 1. JSON parses
# 2. Contains a known-stable Claude model entry
# 3. Contains a known-stable GPT model entry
# These guard against accidentally vendoring an empty / malformed file.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="$REPO_ROOT/crates/headroom-proxy/data/model_prices_and_context_window.json"
URL="https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
echo "Fetching $URL"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
curl -fsSL "$URL" -o "$TMP"
# Validate the snapshot before swapping it in.
python3 -c "
import json, sys
with open('$TMP') as f:
data = json.load(f)
if not isinstance(data, dict):
sys.exit('top-level not an object')
if 'sample_spec' not in data:
sys.exit('missing sample_spec entry — schema may have changed')
# Spot-check stable entries.
required = ['claude-sonnet-4-5-20250929', 'gpt-4o-mini', 'gpt-4-turbo']
missing = [k for k in required if k not in data]
if missing:
sys.exit(f'missing required entries: {missing!r}')
print(f'OK: {len(data)} entries, including {required}')
"
mv "$TMP" "$DEST"
trap - EXIT
echo "Updated $DEST"
echo "Run 'cargo test -p headroom-proxy --lib compression::model_limits' to verify."