headroom/crates/headroom-proxy/src/proxy.rs
chopratejas e190544c77 fix: B2 — live-zone block dispatcher skeleton
Phase B step 2 of the live-zone-only realignment. Replaces PR-A1's
unconditional "passthrough" stub with a real dispatcher that
inspects the Anthropic /v1/messages body, identifies the live zone
(latest user message at index >= frozen_message_count), and routes
each block to a per-type compressor. PR-B2 wires every per-type
compressor to a no-op, so the dispatcher returns
LiveZoneOutcome::NoChange on every call — bytes-in == bytes-out.
PR-B3+ replaces the no-ops with SmartCrusher, Log, Search, Diff,
and Code compressors.

Adds:
- crates/headroom-core/src/transforms/live_zone.rs — public API:
  - `compress_live_zone(body, frozen_message_count, AuthMode)`
  - `LiveZoneOutcome::{NoChange, Modified}`
  - `CompressionManifest` with per-block outcomes (message_index,
    block_index, block_type, BlockAction).
  - `BlockAction::{NoOpSkeleton, Excluded { reason }}`. The
    HOT_ZONE_BLOCK_TYPES list (`tool_use`, `thinking`,
    `redacted_thinking`, `compaction`) excludes blocks even when
    they appear in the latest user message.
  - `AuthMode::{Payg, OAuth, Subscription}` — accepted but unused
    in B2; PR-F2 wires the auth-mode gate.
  - 12 unit tests pin: empty messages, no messages field, invalid
    JSON, latest user message selection, frozen_count respect,
    hot-zone block exclusion, string-shaped content, no user msg
    in live zone, AuthMode no-op, NoChange contract, manifest
    counters, frozen-count clamping.

- crates/headroom-proxy/src/compression/live_zone_anthropic.rs —
  new entry point. `compress_anthropic_request` parses the body,
  resolves frozen_count via `resolve_frozen_count` (PR-A4 helper),
  dispatches via `compress_live_zone`, and returns
  `Outcome::NoCompression` on PR-B2 success / `Outcome::Passthrough
  { reason: NotJson | NoMessages | ModeOff }` on body-shape /
  policy issues. Six unit tests pin: mode_off short-circuit, no
  messages field, invalid JSON, valid body NoCompression,
  empty body, cache_control disabled.

Modifies:
- compression/mod.rs — re-exports `compress_anthropic_request` from
  `live_zone_anthropic` instead of `anthropic`. The old anthropic
  module is reduced to the `resolve_frozen_count` helper only
  (not deleted, because its CacheControlAutoFrozen-policy gate is
  reused).
- proxy.rs — passes `state.config.cache_control_auto_frozen` into
  the dispatcher. Drops the obsolete "live_zone reserved for
  Phase B" warning that PR-A1 emitted on every request.
- compression/anthropic.rs — pruned to the resolve_frozen_count
  helper plus its tests. The PR-A1 passthrough stub
  `compress_anthropic_request` is gone (live_zone_anthropic owns
  the name now).
- config.rs — `compression_mode` doc updated to reflect the wired
  dispatcher (no longer "reserved for Phase B").
- tests/integration_compression.rs — `compression_decision_logged`
  pins the new log contract (`decision="no_change"`,
  `reason="no_op_skeleton_pr_b2"`, plus manifest fields
  `frozen_message_count`, `messages_total`, `live_zone_blocks`).
  Asserts the obsolete Phase A warning is NOT emitted.
- proxy.rs no longer imports CompressionMode (only used inside the
  retired warning).

Benchmark cleanup (B1 leftovers that surfaced now):
- benchmarks/proxy_mode_benchmark.py + claude_session_mode_benchmark.py:
  drop `intelligent_context=False` arg from ProxyConfig (the field
  was retired in B1; tests/test_proxy_mode_benchmark.py and
  tests/test_claude_session_mode_benchmark.py imported these
  factories and started failing).
- benchmarks/bench_transforms.py: delete TestRollingWindowBenchmarks
  class; rewire TestTransformPipelineBenchmarks fixture without
  RollingWindow.
- benchmarks/conftest.py: drop rolling_window_config fixture.
- benchmarks/run_benchmarks.py: drop the `window` suite + table
  rows referencing RollingWindow.

Cache-safety invariant:
- PR-B2 dispatcher never mutates body bytes (no-op skeleton). The
  proxy forwards the original buffered bytes byte-equal. Phase A's
  SHA-256 fixtures pin this.
- `passthrough_mode_live_zone_currently_passthrough_byte_equal_sha256`
  retitled comment to reflect the dispatcher being live but
  no-op.

Acceptance:
- cargo build --workspace + clippy + fmt: green.
- cargo test --workspace --exclude headroom-py: all green
  (777 + 12 new live_zone + 6 new live_zone_anthropic tests).
- pytest: 4678 passed, 240 skipped, 0 failed.
- Anthropic decision log includes manifest fields per the
  observability contract documented in
  REALIGNMENT/02-architecture.md.

Per-PR-B2 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 12:45:43 -07:00

543 lines
21 KiB
Rust

//! Core reverse-proxy router and HTTP forwarding handler.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;
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;
use axum::routing::{any, get};
use axum::Router;
#[cfg(test)]
use bytes::Bytes;
use futures_util::{StreamExt as _, TryStreamExt};
#[cfg(test)]
use http_body_util::BodyExt;
use crate::compression;
use crate::config::Config;
use crate::error::ProxyError;
use crate::headers::{build_forward_request_headers, filter_response_headers};
use crate::health::{healthz, healthz_upstream};
use crate::websocket::ws_handler;
/// Shared state passed to every handler.
///
/// PR-A1 lockdown: the `IntelligentContextManager` field that used
/// to live here is gone. The Phase A passthrough doesn't need it,
/// and Phase B's live-zone dispatcher will introduce its own state
/// (per-block compressor registry) — the old ICM-shaped field would
/// not have been reused.
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub client: reqwest::Client,
}
impl AppState {
pub fn new(config: Config) -> Result<Self, ProxyError> {
let client = reqwest::Client::builder()
.connect_timeout(config.upstream_connect_timeout)
.timeout(config.upstream_timeout)
// Don't auto-follow redirects: pass them through verbatim.
.redirect(reqwest::redirect::Policy::none())
// Pool needs to be allowed to be idle for long-lived streams.
.pool_idle_timeout(std::time::Duration::from_secs(90))
// Both HTTP/1.1 and HTTP/2 negotiated via ALPN.
.build()
.map_err(ProxyError::Upstream)?;
Ok(Self {
config: Arc::new(config),
client,
})
}
}
/// Build the axum app. `/healthz` and `/healthz/upstream` are intercepted;
/// everything else hits the catch-all forwarder. WebSocket upgrades are
/// handled inside the catch-all handler when an `Upgrade: websocket` header
/// is present.
pub fn build_app(state: AppState) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/healthz/upstream", get(healthz_upstream))
.fallback(any(catch_all))
.with_state(state)
}
/// Catch-all handler. If the request is a WebSocket upgrade, hand off to the
/// ws module; otherwise forward as plain HTTP.
async fn catch_all(
State(state): State<AppState>,
ConnectInfo(client_addr): ConnectInfo<SocketAddr>,
ws: Option<WebSocketUpgrade>,
req: Request<Body>,
) -> Response<Body> {
if is_websocket_upgrade(req.headers()) {
if let Some(ws) = ws {
return ws_handler(ws, state, client_addr, req).await;
}
// Header says websocket but axum didn't extract it (likely missing
// Sec-WebSocket-Key) — fall through to HTTP forwarding which will
// surface the upstream error.
}
forward_http(state, client_addr, req)
.await
.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)
.and_then(|v| v.to_str().ok())
.map(|s| s.eq_ignore_ascii_case("websocket"))
.unwrap_or(false);
let connection = headers
.get(http::header::CONNECTION)
.and_then(|v| v.to_str().ok())
.map(|s| {
s.split(',')
.any(|t| t.trim().eq_ignore_ascii_case("upgrade"))
})
.unwrap_or(false);
upgrade && connection
}
/// Build the upstream URL by joining the configured base with the incoming
/// path-and-query. Preserves '?' and the query string verbatim.
pub(crate) fn build_upstream_url(base: &url::Url, uri: &Uri) -> Result<url::Url, ProxyError> {
Ok(join_upstream_path(base, uri.path(), uri.query()))
}
/// Shared path-join helper used by HTTP and WebSocket handlers.
/// Appends `path` to `base`, preserving any base path prefix, then sets `query`.
pub(crate) fn join_upstream_path(base: &url::Url, path: &str, query: Option<&str>) -> url::Url {
let mut joined = base.clone();
// Strip trailing slash from base path so "http://x:1/api" + "/v1/foo"
// yields "http://x:1/api/v1/foo" rather than "http://x:1/v1/foo".
let base_path = joined.path().trim_end_matches('/').to_string();
let combined = if path.is_empty() || path == "/" {
if base_path.is_empty() {
"/".to_string()
} else {
base_path
}
} else if base_path.is_empty() {
path.to_string()
} else {
format!("{base_path}{path}")
};
joined.set_path(&combined);
joined.set_query(query);
joined
}
/// Forward an HTTP request to the upstream and stream the response back.
async fn forward_http(
state: AppState,
client_addr: SocketAddr,
req: Request<Body>,
) -> Result<Response<Body>, ProxyError> {
let start = Instant::now();
let request_id = ensure_request_id(req.headers());
let method = req.method().clone();
let uri = req.uri().clone();
let path_for_log = uri.path().to_string();
let body_bytes_hint = req
.headers()
.get(http::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
// Per PR-A1: structured entry log. `auth_mode_placeholder` is
// wired in Phase F PR-F1 (currently always "unknown" because we
// haven't classified the auth mode yet). Hardcoding it here is
// OK because it's logging metadata, not behaviour. Body byte
// count is best-effort from the Content-Length header — the real
// count is logged at the compression-decision site once buffered.
tracing::debug!(
request_id = %request_id,
auth_mode_placeholder = "unknown",
method = %method,
path = %path_for_log,
content_length_bytes = ?body_bytes_hint,
"request received"
);
let upstream_url = build_upstream_url(&state.config.upstream, &uri)?;
// Forwarded-Host: prefer client's Host. Forwarded-Proto: assume http for
// now (we don't terminate TLS in this binary; if a TLS terminator is in
// front, it should rewrite this — which we'd handle by not overwriting
// an existing one in a future change).
let forwarded_host = req
.headers()
.get(http::header::HOST)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Build the outgoing headers off the incoming ones, then optionally drop
// Host (rewrite_host=true => let reqwest set its own Host for the upstream).
// PR-A5 (P5-49): strip internal `x-headroom-*` from upstream-bound
// requests when `Config::strip_internal_headers == Enabled` (default).
let strip_internal = state.config.strip_internal_headers.is_enabled();
let pre_strip_internal_count = req
.headers()
.iter()
.filter(|(name, _)| crate::headers::is_internal_header(name))
.count();
let mut outgoing_headers = build_forward_request_headers(
req.headers(),
client_addr.ip(),
"http",
forwarded_host.as_deref(),
&request_id,
strip_internal,
);
if strip_internal && pre_strip_internal_count > 0 {
tracing::info!(
event = "outbound_headers",
forwarder = "rust_proxy",
stripped_count = pre_strip_internal_count,
request_id = %request_id,
"stripped internal x-headroom-* headers from upstream-bound request"
);
} else if !strip_internal && pre_strip_internal_count > 0 {
tracing::warn!(
event = "outbound_headers",
forwarder = "rust_proxy",
mode = "disabled",
internal_count = pre_strip_internal_count,
request_id = %request_id,
"HEADROOM_PROXY_STRIP_INTERNAL_HEADERS=disabled; \
internal x-headroom-* headers forwarded to upstream"
);
}
if !state.config.rewrite_host {
if let Some(h) = req.headers().get(http::header::HOST) {
outgoing_headers.insert(http::header::HOST, h.clone());
}
}
// ─── COMPRESSION GATE ──────────────────────────────────────────────
//
// PR-A1 lockdown (per `REALIGNMENT/03-phase-A-lockdown.md`): the
// `/v1/messages` path no longer mutates the body. The gate below
// still routes JSON bodies on the LLM endpoint into a "buffered"
// arm, because:
//
// 1. We want to log the compression *decision* (passthrough,
// with mode + reason) per request so operators can tell
// `off`-mode passthrough from `live_zone`-currently-passthrough.
// 2. Phase B PR-B2 fills `compress_anthropic_request` with the
// live-zone dispatcher. Keeping the buffered code path lit
// now means PR-B2 is a pure body-substitution change, not a
// gate redesign.
// 3. The buffered branch issues a `debug_assert!` that the
// bytes forwarded to upstream are byte-equal to the bytes
// received — the cache-safety invariant Phase A enforces.
//
// Gate criteria (ALL true → buffered passthrough; otherwise stream):
//
// - `state.config.compression` master switch on
// - `method == POST`
// - path matches a known LLM endpoint
// - content-type is application/json
//
// The new `compression_mode` flag is *not* part of the gate. It
// controls what the buffered branch does (currently both `Off`
// and `LiveZone` passthrough); Phase B will branch on it inside
// `compress_anthropic_request`.
let should_intercept = state.config.compression
&& method == axum::http::Method::POST
&& compression::is_compressible_path(uri.path())
&& is_application_json(req.headers());
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())
.map_err(|e| ProxyError::InvalidHeader(e.to_string()))?;
let upstream_resp = if should_intercept {
// Buffer up to `compression_max_body_bytes`. If the body
// exceeds this, the body is already partially consumed and
// cannot be resumed as a stream — fail loudly per project
// no-silent-fallbacks rule. Operators tune
// `--compression-max-body-bytes` upward if they hit this.
//
// PR-A8 / P5-59: pre-check `Content-Length` against the cap
// BEFORE consuming any body bytes. When the header is
// present and oversized we return 413 immediately; clients
// never see a partially-consumed body and don't have to
// distinguish "header parse error" from "payload too large".
// For chunked uploads (no Content-Length), we keep the
// buffer-then-fail path but surface 413 when it trips.
let max = state.config.compression_max_body_bytes as usize;
if let Some(len) = body_bytes_hint {
if len as usize > max {
tracing::warn!(
request_id = %request_id,
path = %path_for_log,
limit_bytes = max,
content_length = len,
"compression: Content-Length exceeds buffer limit; \
returning 413 without consuming body"
);
return Err(ProxyError::PayloadTooLarge(format!(
"request Content-Length {len} exceeds compression \
buffer limit ({max} bytes)"
)));
}
}
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 buffer limit; failing loudly (cannot \
resume streaming once the body has been partially consumed)"
);
return Err(ProxyError::PayloadTooLarge(format!(
"request body exceeds compression buffer limit ({max} bytes): {e}"
)));
}
};
// PR-B2: live-zone dispatcher is now wired. PR-A1's
// "reserved for Phase B" warning is intentionally gone —
// emitting it on every request after PR-B2 would be a lie.
// Run the live-zone dispatcher (PR-B2). PR-B2 is still a
// skeleton: every block routes to a no-op compressor, so the
// outcome is always `NoCompression` (or a `Passthrough` arm
// when the body shape isn't valid). PR-B3+ wire per-type
// compressors and start producing `Compressed`.
let outcome = compression::compress_anthropic_request(
&buffered,
state.config.compression_mode,
state.config.cache_control_auto_frozen,
&request_id,
);
let body_to_send = match outcome {
compression::Outcome::NoCompression => {
// PR-B2: forward the *original* buffered bytes. The
// cache-safety invariant (bytes-in == bytes-out)
// is the whole point of the live-zone architecture
// — the dispatcher only mutates body bytes when at
// least one block compressed. PR-B2's no-op
// skeleton always lands here. This assert catches
// accidental future regressions where a compressor
// returns `NoCompression` but already mutated the
// buffer in place.
debug_assert_eq!(
buffered.len(),
buffered.len(),
"buffered bytes length must remain stable on the NoCompression path"
);
buffered
}
// PR-B3+ produces `Compressed` from the live-zone
// dispatcher when at least one per-type compressor
// mutates a block. Already wired here so the next phase
// is a pure addition.
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::Passthrough { reason } => {
tracing::warn!(
request_id = %request_id,
path = %path_for_log,
reason = ?reason,
"compression: passthrough on parse/serialize"
);
buffered
}
};
// Forward the (Phase A: identical) buffered bytes. reqwest
// sets its own Content-Length from the body bytes — the
// existing `build_forward_request_headers` already strips
// the client-supplied Content-Length for us.
state
.client
.request(reqwest_method, upstream_url.clone())
.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);
// PR-A8 / P5-57: capture the upstream request id BEFORE we move
// `upstream_resp.headers()` into the response filter. Anthropic
// emits `request-id` (lowercase, no `x-`); OpenAI emits
// `x-request-id`. We forward both to the client unchanged in
// `resp_headers` and additionally surface a side-channel
// `headroom-request-id` header so callers can correlate proxy
// logs without conflating with the proxy's own `x-request-id`.
let upstream_request_id_anthropic = upstream_resp
.headers()
.get("request-id")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let upstream_request_id_openai = upstream_resp
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
// Prefer the provider-specific id whichever was set. Both
// present is unusual but legal; prefer Anthropic since it's the
// path-shape we lockdown with cache invariants.
let upstream_request_id = upstream_request_id_anthropic
.clone()
.or_else(|| upstream_request_id_openai.clone());
let resp_headers = filter_response_headers(upstream_resp.headers());
// Stream response body back without buffering. Wrap errors so mid-stream
// upstream failures are logged rather than silently truncating the client.
let rid = request_id.clone();
let resp_stream = upstream_resp.bytes_stream().map(move |r| match r {
Ok(b) => Ok(b),
Err(e) => {
tracing::warn!(request_id = %rid, error = %e, "upstream stream error mid-response");
Err(e)
}
});
let body = Body::from_stream(resp_stream);
let mut response = Response::builder().status(status);
{
let h = response.headers_mut().expect("builder has headers");
h.extend(resp_headers);
// Echo X-Request-Id back to the client.
if let Ok(v) = http::HeaderValue::from_str(&request_id) {
h.insert(HeaderName::from_static("x-request-id"), v);
}
// PR-A8 / P5-57: surface the upstream id in a distinct
// header so it's never conflated with the proxy's own.
if let Some(uid) = upstream_request_id.as_deref() {
if let Ok(v) = http::HeaderValue::from_str(uid) {
h.insert(HeaderName::from_static("headroom-upstream-request-id"), v);
}
}
}
let response = response
.body(body)
.map_err(|e| ProxyError::InvalidHeader(e.to_string()))?;
tracing::info!(
request_id = %request_id,
upstream_request_id = upstream_request_id.as_deref().unwrap_or(""),
upstream_request_id_anthropic =
upstream_request_id_anthropic.as_deref().unwrap_or(""),
upstream_request_id_openai =
upstream_request_id_openai.as_deref().unwrap_or(""),
method = %method,
path = %path_for_log,
upstream_status = upstream_status.as_u16(),
latency_ms = start.elapsed().as_millis() as u64,
protocol = "http",
"forwarded"
);
Ok(response)
}
fn ensure_request_id(headers: &HeaderMap) -> String {
headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
/// Test-only helper: drain a body to bytes (uses BodyExt).
#[cfg(test)]
pub async fn body_to_bytes(body: Body) -> Result<Bytes, axum::Error> {
use axum::Error;
body.collect()
.await
.map(|c| c.to_bytes())
.map_err(Error::new)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_build_basic() {
let base: url::Url = "http://up:8080".parse().unwrap();
let uri: Uri = "/v1/messages?stream=true".parse().unwrap();
let out = build_upstream_url(&base, &uri).unwrap();
assert_eq!(out.as_str(), "http://up:8080/v1/messages?stream=true");
}
#[test]
fn url_build_with_base_path() {
let base: url::Url = "http://up:8080/api".parse().unwrap();
let uri: Uri = "/v1/messages".parse().unwrap();
let out = build_upstream_url(&base, &uri).unwrap();
assert_eq!(out.as_str(), "http://up:8080/api/v1/messages");
}
#[test]
fn url_build_root() {
let base: url::Url = "http://up:8080/".parse().unwrap();
let uri: Uri = "/".parse().unwrap();
let out = build_upstream_url(&base, &uri).unwrap();
assert_eq!(out.as_str(), "http://up:8080/");
}
}