Skip to content

Commit 1329ed7

Browse files
authored
feat(proxy): make /v1/compress usable as a gateway/Kong sidecar (headroomlabs-ai#2458)
## Description Makes the compression-only `POST /v1/compress` endpoint usable as a **network compression sidecar** behind an API gateway (Kong, LiteLLM, ...), and fixes a latent content-detector hang that silently zeroed compression on non-Windows hosts. Motivated by a LiteLLM-sidecar deployment whose team documented five build-time patches; this ports the ones that belong upstream, generalized so they cover any aliasing gateway (not just LiteLLM). Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - **`lossy_inline` compress mode** (`config.mode="lossy_inline"`, alias `"lossless_then_lossy"`): lossless byte/data fold first, then Kompress the folded remainder, with `ccr_inject_marker=False` so every compressor emits **inline, marker-free** output — no `<<ccr:…>>` markers and no CCR store write, so the result is safe to forward straight to a provider with no retrieval round-trip. The mode inherits the deployment's `enable_kompress`. - **`HEADROOM_COMPRESS_ALLOW_REMOTE`** opt-in: drops the loopback dependency on the `/v1/compress` route **only** so an authorized in-network gateway can reach it. Default is unchanged (loopback-only); inbound `HEADROOM_PROXY_TOKEN` auth still applies. - **`HEADROOM_MODEL_ALIAS_MAP`** (gateway-agnostic, fail-soft): one shared resolver in `pricing/litellm_pricing.py` reduces a gateway-aliased model name (e.g. `claude-opus`) to a priced `litellm.model_cost` key, trying the mapped target as-is and with a `bedrock/` / `vertex_ai/` prefix stripped. `proxy/savings_tracker.py` now delegates to it, so the live (`/stats`) and persisted (`/stats-history`) dollar figures price identically. - **`get_context_limit`**: an operator-configured limit (`HEADROOM_MODEL_LIMITS` / `~/.headroom/models.json`) now wins **before** the dynamic LiteLLM lookup, so an aliased name no longer falls through to the 128K default and skews compression. - **fix(content_router): first-call detector watchdog on all platforms.** The native content detector can deadlock on first use (headroomlabs-ai#575, previously flagged Windows-only). The watchdog was `win32`-only, so on macOS/Linux a first-use hang was unbounded → `_detect_content` never returned → the `/v1/compress` executor timeout fired → fail-open → **`tokens_before=0`, silent zero compression**. Now the native detector runs under the watchdog on the first call on every platform; once it returns it is marked verified and the direct fast path is used (zero steady-state overhead). A hang degrades to pure-Python detection with a clear warning. `win32` behavior is unchanged. - Thread `waste_signals` / `pipeline_timing` into the already-present `/v1/compress` outcome record so the guardrail path populates the dashboard panels like the forward-proxy paths. Deliberately **not** ported: the sidecar's LiteLLM-specific `GET /model/info` HTTP fetch (urllib/ssl/threading/TTL). Kong has no such endpoint; the static `HEADROOM_MODEL_ALIAS_MAP` covers any gateway with no network dependency on the pricing path. ## Testing - [x] Unit tests pass (targeted — see output) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ ruff check <changed files> All checks passed! $ mypy <changed source files> Success: no issues found in 6 source files $ pytest tests/test_gateway_sidecar_ports.py tests/test_proxy_compress_endpoint.py -q tests/test_gateway_sidecar_ports.py ........ [ 34%] tests/test_proxy_compress_endpoint.py ............... [100%] ============================= 23 passed in 20.62s ============================== ``` ## Real Behavior Proof - **Environment:** macOS (darwin/arm64), Python 3.12, `.venv`; Kompress offloaded to a Modal endpoint via `HEADROOM_KOMPRESS_ENDPOINT`. - **Exact command / steps:** posted typical tool-output payloads to `POST /v1/compress` (via the FastAPI `TestClient`, loopback) in both `default` and `lossy_inline` modes; separately reproduced the detector hang with `faulthandler.dump_traceback_later`. - **Observed result:** - Real savings through the endpoint (structural/lossless, Kompress off): **JSON 150 records 13,982→9,514 (32.0%)**, **logs 314 lines 12,240→9,549 (22.0%)**, **search 200 hits 5,231→3,471 (33.6%)**. `lossy_inline` emits **zero** CCR markers. - `faulthandler` pinned the pre-fix hang to `content_router.py:_detect_content` → native `_rust_detect`. With the fix, the first call degrades at the 5s watchdog with `"Native content detector hung … using pure-Python detection"` and compression proceeds (previously it hung and the endpoint returned `tokens_before=0`). - Modal Kompress warm latency measured ~0.8s/call; the learned pass compresses prose further (62→56 words on a sample). - **Not tested:** full `pytest` suite (ran the two affected test files only); the native-detector hang was reproduced on a local macOS/arm64 build — the fix's degrade path is verified, but a healthy-native CI Linux run should confirm the fast (verified) path there. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes - The five-item context comes from a downstream LiteLLM sidecar's `PATCHES.md`; item #3 (record an outcome from the guardrail path) was already upstreamed — this PR only adds the missing `waste_signals`/`pipeline_timing` threading. Item #2 (observability read-only exemption when `HEADROOM_PROXY_TOKEN` is set) is not addressed here. - All new config is opt-in and fail-soft; with nothing set, behavior is byte-identical to today.
1 parent f4070c4 commit 1329ed7

8 files changed

Lines changed: 384 additions & 10 deletions

File tree

headroom/pricing/litellm_pricing.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
from __future__ import annotations
1010

11+
import json
12+
import logging
13+
import os
1114
from dataclasses import dataclass
1215
from typing import Any
1316

@@ -40,15 +43,65 @@
4043

4144
_resolved_model_cache: dict[str, str] = {}
4245

46+
logger = logging.getLogger("headroom.pricing")
47+
48+
# --- Gateway model-name resolution ---------------------------------------
49+
# When Headroom sits behind a gateway (Kong, LiteLLM, ...) that aliases model
50+
# names, the raw client name it sees (e.g. "claude-opus") is not a priced key
51+
# in litellm.model_cost, so dollar savings read $0. HEADROOM_MODEL_ALIAS_MAP is
52+
# an optional, gateway-agnostic, fail-soft static JSON map {client_name: target}
53+
# that reduces that name to a priced model_cost key (trying the target as-is and
54+
# with a bedrock/ or vertex_ai/ provider prefix stripped). Unset -> behavior is
55+
# identical to today's bare-prefix resolution; pricing never breaks.
56+
_GATEWAY_PROVIDER_PREFIXES = ("bedrock/", "vertex_ai/")
57+
58+
59+
def _static_alias_map() -> dict[str, str]:
60+
raw = os.environ.get("HEADROOM_MODEL_ALIAS_MAP", "").strip()
61+
if not raw:
62+
return {}
63+
try:
64+
data = json.loads(raw)
65+
except ValueError:
66+
logger.debug("invalid HEADROOM_MODEL_ALIAS_MAP JSON", exc_info=True)
67+
return {}
68+
if not isinstance(data, dict):
69+
return {}
70+
return {str(k): str(v) for k, v in data.items() if k and v}
71+
72+
73+
def _reduce_to_priced_key(target: str) -> str | None:
74+
"""Reduce a gateway target to a priced litellm.model_cost key, or None."""
75+
if not LITELLM_AVAILABLE or litellm is None:
76+
return None
77+
candidates = [target]
78+
for prefix in _GATEWAY_PROVIDER_PREFIXES:
79+
if target.startswith(prefix):
80+
candidates.append(target[len(prefix) :])
81+
for candidate in candidates:
82+
info = litellm.model_cost.get(candidate)
83+
if info and info.get("input_cost_per_token") is not None:
84+
return candidate
85+
return None
86+
4387

4488
def resolve_litellm_model(model: str) -> str:
4589
"""Resolve model name to one LiteLLM recognizes, adding provider prefix if needed.
4690
Results are cached per model name to avoid blocking the event loop
4791
with repeated synchronous litellm lookups.
92+
93+
When HEADROOM_MODEL_ALIAS_MAP is configured, a raw client name / group alias
94+
is first reduced to a priced model_cost key; otherwise this falls through to
95+
the bare-prefix rules. Shared by the live (cost.py) and persisted
96+
(savings_tracker) pricing paths so both figures price identically.
4897
"""
4998
if model in _resolved_model_cache:
5099
return _resolved_model_cache[model]
51-
resolved = _resolve_litellm_model_uncached(model)
100+
priced: str | None = None
101+
alias = _static_alias_map()
102+
if alias:
103+
priced = _reduce_to_priced_key(alias.get(model, model))
104+
resolved = priced if priced is not None else _resolve_litellm_model_uncached(model)
52105
_resolved_model_cache[model] = resolved
53106
return resolved
54107

headroom/providers/openai.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,16 @@ def get_context_limit(self, model: str) -> int:
460460
461461
Never raises an exception - uses sensible defaults for unknown models.
462462
"""
463+
# Explicitly configured limits win first. Behind a gateway/alias proxy
464+
# (Kong, LiteLLM, ...) Headroom sees the raw client model name
465+
# (e.g. "claude-opus"), which litellm.get_model_info can't resolve, so
466+
# resolution would fall through to the 128K default + an "Unknown model"
467+
# warning and skew compression. Configuring the alias via
468+
# HEADROOM_MODEL_LIMITS / ~/.headroom/models.json makes it authoritative
469+
# here, before the dynamic LiteLLM lookup. Fail-soft, no network.
470+
if model in self._context_limits:
471+
return self._context_limits[model]
472+
463473
# Try LiteLLM first
464474
litellm = _get_litellm_module()
465475
if litellm is not None:

headroom/proxy/handlers/openai.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7939,11 +7939,54 @@ async def _ws_http_fallback(
79397939
with contextlib.suppress(Exception):
79407940
await websocket.close()
79417941

7942+
def _lossy_inline_pipeline(self) -> Any:
7943+
"""Cached pipeline for ``/v1/compress`` ``config.mode="lossy_inline"``.
7944+
7945+
Runs the lossless byte/data fold first, then Kompresses the folded
7946+
remainder (``lossless_then_lossy``). ``ccr_inject_marker=False`` makes
7947+
every compressor (Kompress, SmartCrusher, search/log/config) emit inline
7948+
lossy output with NO ``<<ccr:…>>`` / ``Retrieve more: hash=`` marker and
7949+
NO CCR store write, so the result is safe to forward straight to a
7950+
provider with no retrieval round-trip. Derived once from the live OpenAI
7951+
router's config and reused read-only across requests.
7952+
7953+
ponytail: a first-request race just builds it twice — both are
7954+
equivalent and Kompress weights are cached at module level, so no lock.
7955+
"""
7956+
cached = getattr(self, "_lossy_inline_pipeline_cache", None)
7957+
if cached is not None:
7958+
return cached
7959+
7960+
from headroom.transforms.compression_units import find_content_router
7961+
from headroom.transforms.content_router import ContentRouter
7962+
from headroom.transforms.pipeline import TransformPipeline
7963+
7964+
base = find_content_router(self.openai_pipeline)
7965+
if base is None: # ponytail: nothing to derive from — use default pipeline
7966+
return self.openai_pipeline
7967+
cfg = replace(
7968+
base.config,
7969+
lossless=False, # lossy mode (not lossless-only)
7970+
lossless_then_lossy=True, # fold first, then Kompress the remainder
7971+
ccr_inject_marker=False, # inline, marker-free everywhere
7972+
ccr_enabled=False, # no CCR store writes
7973+
smart_crusher_lossless_only=False, # keep SmartCrusher lossy
7974+
) # enable_kompress inherited: on by default, off if operator disabled it
7975+
pipeline = TransformPipeline(
7976+
transforms=[ContentRouter(cfg, observer=self.metrics)],
7977+
provider=self.openai_provider,
7978+
)
7979+
self._lossy_inline_pipeline_cache = pipeline
7980+
return pipeline
7981+
79427982
async def handle_compress(self, request: Request) -> JSONResponse:
79437983
"""Compress messages without calling an LLM.
79447984
79457985
POST /v1/compress
79467986
Body: {"messages": [...], "model": "...", "config": {}}
7987+
``config.mode="lossy_inline"`` (alias ``"lossless_then_lossy"``) selects
7988+
the marker-free lossless-then-lossy pipeline whose output needs no CCR
7989+
retrieval round-trip — the mode to use behind a gateway/sidecar.
79477990
Returns compressed messages + metrics.
79487991
"""
79497992
from fastapi.responses import JSONResponse
@@ -8040,10 +8083,20 @@ async def handle_compress(self, request: Request) -> JSONResponse:
80408083
)
80418084
# Extract CompressConfig options from request body
80428085
compress_config = body.get("config", {})
8086+
if not isinstance(compress_config, dict):
8087+
compress_config = {}
80438088
compress_user_messages = compress_config.get("compress_user_messages", False)
80448089
target_ratio = compress_config.get("target_ratio")
80458090
protect_recent = compress_config.get("protect_recent")
80468091
protect_analysis_context = compress_config.get("protect_analysis_context")
8092+
# Marker-free lossless-then-lossy mode: safe to forward downstream
8093+
# with no CCR retrieval round-trip (see _lossy_inline_pipeline).
8094+
mode = compress_config.get("mode")
8095+
pipeline = (
8096+
self._lossy_inline_pipeline()
8097+
if mode in ("lossy_inline", "lossless_then_lossy")
8098+
else self.openai_pipeline
8099+
)
80478100

80488101
pipeline_kwargs: dict = {
80498102
"model_limit": context_limit,
@@ -8064,7 +8117,7 @@ async def handle_compress(self, request: Request) -> JSONResponse:
80648117
# until it finished (#718). The executor also enforces a timeout so a
80658118
# too-large body fails fast instead of hanging forever.
80668119
result = await self._run_compression_in_executor(
8067-
lambda: self.openai_pipeline.apply(
8120+
lambda: pipeline.apply(
80688121
messages=messages,
80698122
model=model,
80708123
**pipeline_kwargs,
@@ -8094,6 +8147,12 @@ async def handle_compress(self, request: Request) -> JSONResponse:
80948147
overhead_ms=latency_ms,
80958148
num_messages=len(messages) if isinstance(messages, list) else 0,
80968149
transforms_applied=tuple(result.transforms_applied or ()),
8150+
waste_signals=(
8151+
result.waste_signals.to_dict()
8152+
if getattr(result, "waste_signals", None) is not None
8153+
else None
8154+
),
8155+
pipeline_timing=getattr(result, "timing", None) or None,
80978156
tags=tags,
80988157
client=client,
80998158
)

headroom/proxy/savings_tracker.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,29 @@ def _normalize_model(value: Any) -> str:
165165

166166

167167
def _resolve_litellm_model(model: str) -> str:
168-
"""Resolve model name to one LiteLLM recognizes."""
168+
"""Resolve model name to one LiteLLM recognizes.
169+
170+
Delegates to the shared alias-map-aware resolver in
171+
``headroom.pricing.litellm_pricing`` so the persisted /stats-history funnel
172+
(PROXY $ SAVED tile + Historical Checkpoints) prices gateway aliases like
173+
"claude-opus" identically to the live /stats path. Uses the shared result
174+
only when it maps to a priced model_cost key; otherwise falls through to the
175+
bare-prefix logic below. Fail-soft: pricing never breaks bookkeeping.
176+
"""
169177
litellm = _get_litellm_module()
170178
if litellm is None:
171179
return model
172180

181+
try:
182+
from headroom.pricing.litellm_pricing import resolve_litellm_model
183+
184+
resolved = resolve_litellm_model(model)
185+
info = litellm.model_cost.get(resolved)
186+
if info and info.get("input_cost_per_token") is not None:
187+
return resolved
188+
except Exception:
189+
pass
190+
173191
try:
174192
litellm.cost_per_token(model=model, prompt_tokens=1, completion_tokens=0)
175193
return model

headroom/proxy/server.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4862,8 +4862,21 @@ async def ccr_handle_tool_call(request: Request):
48624862
"data": retrieval_data,
48634863
}
48644864

4865-
# Compression-only endpoint (for TypeScript SDK and other HTTP clients)
4866-
@app.post("/v1/compress", dependencies=[Depends(_require_loopback)])
4865+
# Compression-only endpoint (for TypeScript SDK and other HTTP clients).
4866+
# Loopback-only by default (guard added in #1537). An operator can opt in to
4867+
# network access for an authorized in-network sidecar/gateway (e.g. Kong,
4868+
# LiteLLM) on a trusted network by setting HEADROOM_COMPRESS_ALLOW_REMOTE=1,
4869+
# which drops ONLY this route's loopback dependency. Inbound auth
4870+
# (HEADROOM_PROXY_TOKEN via _security_gate) and network scoping still apply;
4871+
# all other _require_loopback routes are unaffected. Unset/false preserves
4872+
# today's loopback-only behavior.
4873+
_compress_dependencies = (
4874+
[]
4875+
if _get_env_bool("HEADROOM_COMPRESS_ALLOW_REMOTE", False)
4876+
else [Depends(_require_loopback)]
4877+
)
4878+
4879+
@app.post("/v1/compress", dependencies=_compress_dependencies)
48674880
async def compress_messages(request: Request):
48684881
return await proxy.handle_compress(request)
48694882

headroom/transforms/content_router.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
_detect_backend_warned = False
9393
_detect_panic_warned = False
9494
_detect_native_unhealthy = False # circuit breaker: native detect hung once (#575)
95+
_detect_native_verified = False # native detect has returned once -> skip the watchdog
9596

9697

9798
# Shared calibrated fallback estimator (tiktoken cl100k_base ~90% accuracy,
@@ -871,6 +872,7 @@ def _detect_content(content: str) -> DetectionResult:
871872
`_strategy_from_detection` keys off that field alone.
872873
"""
873874
global _detect_backend_warned, _detect_panic_warned, _detect_native_unhealthy
875+
global _detect_native_verified
874876

875877
# Detect on the unwrapped payload so a tool-output envelope's tags don't get
876878
# the whole result misclassified as HTML/XML (#route-converter corruption).
@@ -896,14 +898,19 @@ def _detect_content(content: str) -> DetectionResult:
896898
from headroom._core import detect_content_type as _rust_detect
897899

898900
try:
899-
if sys.platform == "win32":
900-
# Windows is the only platform where the native detector can deadlock
901-
# on first use (#575); bound it with a watchdog so a hang degrades to
902-
# the pure-Python detector below. Elsewhere it is the trusted default
903-
# hot path — call it directly, with no per-call thread overhead.
901+
# The native detector can deadlock on FIRST use (#575 — seen on Windows
902+
# and macOS/arm64). Bound it with a watchdog so a hang degrades to the
903+
# pure-Python detector; the previous win32-only guard left other
904+
# platforms unprotected, so a hung Linux sidecar silently stopped
905+
# compressing (every request failed open to passthrough). Watchdog until
906+
# the native detector has returned once, then use the direct fast path —
907+
# the hang is first-use only, so steady state pays no per-call thread
908+
# overhead. win32 keeps watchdogging every call (unchanged).
909+
if sys.platform == "win32" or not _detect_native_verified:
904910
rust_result = _rust_detect_watchdogged(_rust_detect, content, _detect_timeout_secs())
905911
else:
906912
rust_result = _rust_detect(content)
913+
_detect_native_verified = True # returned without hanging -> trusted hot path
907914
# Rust's `content_type` is the lowercase string tag (e.g.
908915
# "json_array"); translate to the Python `ContentType` enum so
909916
# downstream mapping keys match.

0 commit comments

Comments
 (0)