mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
f726d0e280
commit
fa5fbfabf4
17 changed files with 40945 additions and 20 deletions
|
|
@ -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"
|
||||
|
|
|
|||
4
.github/plugin/marketplace.json
vendored
4
.github/plugin/marketplace.json
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
39767
crates/headroom-proxy/data/model_prices_and_context_window.json
Normal file
39767
crates/headroom-proxy/data/model_prices_and_context_window.json
Normal file
File diff suppressed because it is too large
Load diff
325
crates/headroom-proxy/src/compression/anthropic.rs
Normal file
325
crates/headroom-proxy/src/compression/anthropic.rs
Normal 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
|
||||
}
|
||||
}
|
||||
70
crates/headroom-proxy/src/compression/icm.rs
Normal file
70
crates/headroom-proxy/src/compression/icm.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
70
crates/headroom-proxy/src/compression/mod.rs
Normal file
70
crates/headroom-proxy/src/compression/mod.rs
Normal 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(""));
|
||||
}
|
||||
}
|
||||
211
crates/headroom-proxy/src/compression/model_limits.rs
Normal file
211
crates/headroom-proxy/src/compression/model_limits.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
216
crates/headroom-proxy/tests/integration_compression.rs
Normal file
216
crates/headroom-proxy/tests/integration_compression.rs
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
48
scripts/refresh_model_limits.sh
Executable 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."
|
||||
Loading…
Add table
Add a link
Reference in a new issue