Spaces:
Build error
Build error
Commit ·
356d8ba
1
Parent(s): ce7905c
Remove LLMLingua: Kompress is the sole text compressor
Browse filesLLMLingua was the original ML text compressor (BERT-based). Kompress
(ModernBERT, trained on 330K structured tool outputs) replaced it with
better compression quality and simpler architecture.
Removed across 35 files:
- Deleted headroom/transforms/llmlingua_compressor.py
- Deleted tests/test_transforms/test_llmlingua_compressor.py
- Deleted tests/test_proxy_llmlingua.py
- Removed all enable_llmlingua config, _get_llmlingua methods,
LLMLingua fallback paths, LLMLINGUA strategy enum values
- Removed CLI flags, model configs, compression handler references
- Simplified ContentRouter: Kompress is primary and only text compressor
- .gitignore +3 -0
- benchmarks/compression_benchmark.py +20 -26
- headroom/ccr/tool_injection.py +0 -1
- headroom/cli/proxy.py +0 -21
- headroom/compress.py +1 -1
- headroom/compression/__init__.py +1 -1
- headroom/compression/handlers/__init__.py +1 -1
- headroom/compression/handlers/base.py +1 -1
- headroom/compression/masks.py +5 -5
- headroom/compression/universal.py +15 -16
- headroom/config.py +1 -1
- headroom/evals/batch_compression_eval.py +2 -2
- headroom/evals/html_extraction.py +13 -14
- headroom/models/config.py +0 -22
- headroom/models/ml_models.py +0 -28
- headroom/perf/analyzer.py +1 -1
- headroom/proxy/server.py +1 -111
- headroom/transforms/__init__.py +0 -30
- headroom/transforms/code_compressor.py +15 -15
- headroom/transforms/content_detector.py +1 -1
- headroom/transforms/content_router.py +14 -65
- headroom/transforms/intelligent_context.py +0 -1
- headroom/transforms/kompress_compressor.py +4 -5
- headroom/transforms/llmlingua_compressor.py +0 -652
- headroom/transforms/pipeline.py +1 -1
- headroom/transforms/tag_protector.py +1 -1
- tests/test_ccr_tool_injection.py +1 -1
- tests/test_compression/test_evals.py +6 -6
- tests/test_compression/test_llm_eval.py +4 -4
- tests/test_compression/test_universal.py +4 -4
- tests/test_evals/test_html_extraction_eval.py +5 -5
- tests/test_proxy_llmlingua.py +0 -468
- tests/test_transforms/test_code_compressor.py +5 -5
- tests/test_transforms/test_content_router.py +0 -4
- tests/test_transforms/test_llmlingua_compressor.py +0 -941
.gitignore
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Byte-compiled / optimized / DLL files
|
| 2 |
__pycache__/
|
| 3 |
*.py[cod]
|
|
|
|
| 1 |
+
# Swift SDK (separate repo)
|
| 2 |
+
swift/
|
| 3 |
+
|
| 4 |
# Byte-compiled / optimized / DLL files
|
| 5 |
__pycache__/
|
| 6 |
*.py[cod]
|
benchmarks/compression_benchmark.py
CHANGED
|
@@ -44,13 +44,13 @@ try:
|
|
| 44 |
except ImportError:
|
| 45 |
HEADROOM_AVAILABLE = False
|
| 46 |
|
| 47 |
-
#
|
| 48 |
try:
|
| 49 |
-
from headroom.transforms.
|
| 50 |
|
| 51 |
-
|
| 52 |
except ImportError:
|
| 53 |
-
|
| 54 |
|
| 55 |
|
| 56 |
@dataclass
|
|
@@ -427,27 +427,21 @@ Provide a structured summary that retains all critical details."""
|
|
| 427 |
return summary, cost, latency
|
| 428 |
|
| 429 |
|
| 430 |
-
def
|
| 431 |
"""
|
| 432 |
-
Use
|
| 433 |
Returns (compressed_text, metadata).
|
| 434 |
"""
|
| 435 |
-
if not
|
| 436 |
-
raise RuntimeError(
|
| 437 |
-
"LLMLingua not available. Install with: pip install headroom-ai[llmlingua]"
|
| 438 |
-
)
|
| 439 |
|
| 440 |
-
|
| 441 |
-
target_compression_rate=0.3, # Keep ~30% of tokens
|
| 442 |
-
min_tokens_for_compression=50,
|
| 443 |
-
)
|
| 444 |
-
compressor = LLMLinguaCompressor(config)
|
| 445 |
|
| 446 |
-
# Convert data to string for
|
| 447 |
data_str = json.dumps(data, indent=2)
|
| 448 |
|
| 449 |
start = time.time()
|
| 450 |
-
result = compressor.compress(data_str
|
| 451 |
latency = (time.time() - start) * 1000
|
| 452 |
|
| 453 |
metadata = {
|
|
@@ -590,7 +584,7 @@ class BenchmarkConfig:
|
|
| 590 |
max_truncate_items: int = 20
|
| 591 |
max_headroom_items: int = 20
|
| 592 |
run_summarization: bool = True # Can disable to save cost
|
| 593 |
-
|
| 594 |
|
| 595 |
|
| 596 |
def run_scenario_benchmark(
|
|
@@ -701,12 +695,12 @@ def run_scenario_benchmark(
|
|
| 701 |
except Exception as e:
|
| 702 |
print(f" Summarization failed: {e}")
|
| 703 |
|
| 704 |
-
# ---
|
| 705 |
-
if config.
|
| 706 |
-
print("\n[3/4] Running
|
| 707 |
-
if
|
| 708 |
try:
|
| 709 |
-
ll_compressed, ll_metadata =
|
| 710 |
ll_tokens = count_tokens(ll_compressed)
|
| 711 |
|
| 712 |
ll_answers = []
|
|
@@ -729,7 +723,7 @@ def run_scenario_benchmark(
|
|
| 729 |
|
| 730 |
results.append(
|
| 731 |
ApproachResult(
|
| 732 |
-
approach="
|
| 733 |
scenario=scenario.name,
|
| 734 |
tokens_original=original_tokens,
|
| 735 |
tokens_after=ll_tokens,
|
|
@@ -747,9 +741,9 @@ def run_scenario_benchmark(
|
|
| 747 |
print(f" Accuracy: {ll_accuracy:.1%}")
|
| 748 |
print(f" Compression latency: {ll_metadata['latency_ms']:.1f}ms")
|
| 749 |
except Exception as e:
|
| 750 |
-
print(f"
|
| 751 |
else:
|
| 752 |
-
print("
|
| 753 |
|
| 754 |
# --- HEADROOM ---
|
| 755 |
print("\n[4/4] Running Headroom...")
|
|
|
|
| 44 |
except ImportError:
|
| 45 |
HEADROOM_AVAILABLE = False
|
| 46 |
|
| 47 |
+
# Kompress imports (ML baseline)
|
| 48 |
try:
|
| 49 |
+
from headroom.transforms.kompress_compressor import KompressCompressor, is_kompress_available
|
| 50 |
|
| 51 |
+
KOMPRESS_AVAILABLE = is_kompress_available()
|
| 52 |
except ImportError:
|
| 53 |
+
KOMPRESS_AVAILABLE = False
|
| 54 |
|
| 55 |
|
| 56 |
@dataclass
|
|
|
|
| 427 |
return summary, cost, latency
|
| 428 |
|
| 429 |
|
| 430 |
+
def kompress_compress(data: list[dict]) -> tuple[str, dict]:
|
| 431 |
"""
|
| 432 |
+
Use Kompress (ModernBERT) for ML-based compression.
|
| 433 |
Returns (compressed_text, metadata).
|
| 434 |
"""
|
| 435 |
+
if not KOMPRESS_AVAILABLE:
|
| 436 |
+
raise RuntimeError("Kompress not available. Install with: pip install headroom-ai[ml]")
|
|
|
|
|
|
|
| 437 |
|
| 438 |
+
compressor = KompressCompressor()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
|
| 440 |
+
# Convert data to string for Kompress (it works on text, not structured data)
|
| 441 |
data_str = json.dumps(data, indent=2)
|
| 442 |
|
| 443 |
start = time.time()
|
| 444 |
+
result = compressor.compress(data_str)
|
| 445 |
latency = (time.time() - start) * 1000
|
| 446 |
|
| 447 |
metadata = {
|
|
|
|
| 584 |
max_truncate_items: int = 20
|
| 585 |
max_headroom_items: int = 20
|
| 586 |
run_summarization: bool = True # Can disable to save cost
|
| 587 |
+
run_kompress: bool = True # Run Kompress (ML baseline)
|
| 588 |
|
| 589 |
|
| 590 |
def run_scenario_benchmark(
|
|
|
|
| 695 |
except Exception as e:
|
| 696 |
print(f" Summarization failed: {e}")
|
| 697 |
|
| 698 |
+
# --- KOMPRESS (ML baseline) ---
|
| 699 |
+
if config.run_kompress:
|
| 700 |
+
print("\n[3/4] Running Kompress (ModernBERT ML baseline)...")
|
| 701 |
+
if KOMPRESS_AVAILABLE:
|
| 702 |
try:
|
| 703 |
+
ll_compressed, ll_metadata = kompress_compress(scenario.data)
|
| 704 |
ll_tokens = count_tokens(ll_compressed)
|
| 705 |
|
| 706 |
ll_answers = []
|
|
|
|
| 723 |
|
| 724 |
results.append(
|
| 725 |
ApproachResult(
|
| 726 |
+
approach="kompress",
|
| 727 |
scenario=scenario.name,
|
| 728 |
tokens_original=original_tokens,
|
| 729 |
tokens_after=ll_tokens,
|
|
|
|
| 741 |
print(f" Accuracy: {ll_accuracy:.1%}")
|
| 742 |
print(f" Compression latency: {ll_metadata['latency_ms']:.1f}ms")
|
| 743 |
except Exception as e:
|
| 744 |
+
print(f" Kompress failed: {e}")
|
| 745 |
else:
|
| 746 |
+
print(" Kompress not available. Install with: pip install headroom-ai[ml]")
|
| 747 |
|
| 748 |
# --- HEADROOM ---
|
| 749 |
print("\n[4/4] Running Headroom...")
|
headroom/ccr/tool_injection.py
CHANGED
|
@@ -196,7 +196,6 @@ class CCRToolInjector:
|
|
| 196 |
_detected_hashes: list[str] = field(default_factory=list)
|
| 197 |
# Multiple marker patterns to match different compressors:
|
| 198 |
# - SmartCrusher: [100 items compressed to 10. Retrieve more: hash=abc123]
|
| 199 |
-
# - LLMLingua: [1000 items compressed to 300. Retrieve more: hash=abc123]
|
| 200 |
# - Kompress: [100 lines compressed to 10. Retrieve more: hash=abc123]
|
| 201 |
# - LogCompressor: [200 lines compressed to 20. Retrieve more: hash=abc123]
|
| 202 |
# - SearchCompressor: [50 matches compressed to 5. Retrieve more: hash=abc123]
|
|
|
|
| 196 |
_detected_hashes: list[str] = field(default_factory=list)
|
| 197 |
# Multiple marker patterns to match different compressors:
|
| 198 |
# - SmartCrusher: [100 items compressed to 10. Retrieve more: hash=abc123]
|
|
|
|
| 199 |
# - Kompress: [100 lines compressed to 10. Retrieve more: hash=abc123]
|
| 200 |
# - LogCompressor: [200 lines compressed to 20. Retrieve more: hash=abc123]
|
| 201 |
# - SearchCompressor: [50 matches compressed to 5. Retrieve more: hash=abc123]
|
headroom/cli/proxy.py
CHANGED
|
@@ -21,20 +21,6 @@ from .main import main
|
|
| 21 |
@click.option("--no-rate-limit", is_flag=True, help="Disable rate limiting")
|
| 22 |
@click.option("--log-file", default=None, help="Path to JSONL log file")
|
| 23 |
@click.option("--budget", type=float, default=None, help="Daily budget limit in USD")
|
| 24 |
-
# LLMLingua ML-based compression (ON by default if installed)
|
| 25 |
-
@click.option("--no-llmlingua", is_flag=True, help="Disable LLMLingua-2 ML-based compression")
|
| 26 |
-
@click.option(
|
| 27 |
-
"--llmlingua-device",
|
| 28 |
-
type=click.Choice(["auto", "cuda", "cpu", "mps"]),
|
| 29 |
-
default="auto",
|
| 30 |
-
help="Device for LLMLingua model (default: auto)",
|
| 31 |
-
)
|
| 32 |
-
@click.option(
|
| 33 |
-
"--llmlingua-rate",
|
| 34 |
-
type=float,
|
| 35 |
-
default=0.3,
|
| 36 |
-
help="LLMLingua compression rate 0.0-1.0 (default: 0.3 = keep 30%)",
|
| 37 |
-
)
|
| 38 |
# Code-aware compression (ON by default if installed)
|
| 39 |
@click.option("--no-code-aware", is_flag=True, help="Disable AST-based code compression")
|
| 40 |
# Read lifecycle (ON by default: compresses stale/superseded Read outputs)
|
|
@@ -149,9 +135,6 @@ def proxy(
|
|
| 149 |
no_rate_limit: bool,
|
| 150 |
log_file: str | None,
|
| 151 |
budget: float | None,
|
| 152 |
-
no_llmlingua: bool,
|
| 153 |
-
llmlingua_device: str,
|
| 154 |
-
llmlingua_rate: float,
|
| 155 |
no_code_aware: bool,
|
| 156 |
no_read_lifecycle: bool,
|
| 157 |
no_intelligent_context: bool,
|
|
@@ -225,10 +208,6 @@ def proxy(
|
|
| 225 |
rate_limit_enabled=not no_rate_limit,
|
| 226 |
log_file=log_file,
|
| 227 |
budget_limit_usd=budget,
|
| 228 |
-
# LLMLingua: ON by default (use --no-llmlingua to disable)
|
| 229 |
-
llmlingua_enabled=not no_llmlingua,
|
| 230 |
-
llmlingua_device=llmlingua_device,
|
| 231 |
-
llmlingua_target_rate=llmlingua_rate,
|
| 232 |
# Code-aware: ON by default (use --no-code-aware to disable)
|
| 233 |
code_aware_enabled=not no_code_aware,
|
| 234 |
# Read lifecycle: ON by default (use --no-read-lifecycle to disable)
|
|
|
|
| 21 |
@click.option("--no-rate-limit", is_flag=True, help="Disable rate limiting")
|
| 22 |
@click.option("--log-file", default=None, help="Path to JSONL log file")
|
| 23 |
@click.option("--budget", type=float, default=None, help="Daily budget limit in USD")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# Code-aware compression (ON by default if installed)
|
| 25 |
@click.option("--no-code-aware", is_flag=True, help="Disable AST-based code compression")
|
| 26 |
# Read lifecycle (ON by default: compresses stale/superseded Read outputs)
|
|
|
|
| 135 |
no_rate_limit: bool,
|
| 136 |
log_file: str | None,
|
| 137 |
budget: float | None,
|
|
|
|
|
|
|
|
|
|
| 138 |
no_code_aware: bool,
|
| 139 |
no_read_lifecycle: bool,
|
| 140 |
no_intelligent_context: bool,
|
|
|
|
| 208 |
rate_limit_enabled=not no_rate_limit,
|
| 209 |
log_file=log_file,
|
| 210 |
budget_limit_usd=budget,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
# Code-aware: ON by default (use --no-code-aware to disable)
|
| 212 |
code_aware_enabled=not no_code_aware,
|
| 213 |
# Read lifecycle: ON by default (use --no-read-lifecycle to disable)
|
headroom/compress.py
CHANGED
|
@@ -203,7 +203,7 @@ def _get_pipeline() -> Any:
|
|
| 203 |
# Default pipeline: CacheAligner → ContentRouter → IntelligentContext
|
| 204 |
# CacheAligner: stabilizes prefix for provider KV cache hits
|
| 205 |
# ContentRouter: routes to the right compressor per content type
|
| 206 |
-
# (SmartCrusher for JSON, CodeCompressor for code,
|
| 207 |
# IntelligentContext: enforces token limits with score-based dropping
|
| 208 |
_pipeline = TransformPipeline()
|
| 209 |
logger.debug("Headroom compression pipeline initialized")
|
|
|
|
| 203 |
# Default pipeline: CacheAligner → ContentRouter → IntelligentContext
|
| 204 |
# CacheAligner: stabilizes prefix for provider KV cache hits
|
| 205 |
# ContentRouter: routes to the right compressor per content type
|
| 206 |
+
# (SmartCrusher for JSON, CodeCompressor for code, Kompress for text)
|
| 207 |
# IntelligentContext: enforces token limits with score-based dropping
|
| 208 |
_pipeline = TransformPipeline()
|
| 209 |
logger.debug("Headroom compression pipeline initialized")
|
headroom/compression/__init__.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
This module provides intelligent, automatic compression that:
|
| 4 |
1. Detects content type using ML (Magika)
|
| 5 |
2. Preserves structure (keys, signatures, templates)
|
| 6 |
-
3. Compresses content with
|
| 7 |
4. Enables retrieval via CCR
|
| 8 |
|
| 9 |
Quick Start:
|
|
|
|
| 3 |
This module provides intelligent, automatic compression that:
|
| 4 |
1. Detects content type using ML (Magika)
|
| 5 |
2. Preserves structure (keys, signatures, templates)
|
| 6 |
+
3. Compresses content with Kompress
|
| 7 |
4. Enables retrieval via CCR
|
| 8 |
|
| 9 |
Quick Start:
|
headroom/compression/handlers/__init__.py
CHANGED
|
@@ -4,7 +4,7 @@ Each handler knows how to extract structural information from a specific
|
|
| 4 |
content type and create a StructureMask marking what should be preserved.
|
| 5 |
|
| 6 |
Handlers don't compress - they only identify structure. The actual
|
| 7 |
-
compression is done by
|
| 8 |
"""
|
| 9 |
|
| 10 |
from headroom.compression.handlers.base import (
|
|
|
|
| 4 |
content type and create a StructureMask marking what should be preserved.
|
| 5 |
|
| 6 |
Handlers don't compress - they only identify structure. The actual
|
| 7 |
+
compression is done by Kompress on the non-structural parts.
|
| 8 |
"""
|
| 9 |
|
| 10 |
from headroom.compression.handlers.base import (
|
headroom/compression/handlers/base.py
CHANGED
|
@@ -181,7 +181,7 @@ class BaseStructureHandler(ABC):
|
|
| 181 |
|
| 182 |
Subclasses may override for more sophisticated tokenization.
|
| 183 |
For mask purposes, character-level is often sufficient and
|
| 184 |
-
aligns well with
|
| 185 |
|
| 186 |
Args:
|
| 187 |
content: Content to tokenize.
|
|
|
|
| 181 |
|
| 182 |
Subclasses may override for more sophisticated tokenization.
|
| 183 |
For mask purposes, character-level is often sufficient and
|
| 184 |
+
aligns well with token-level compression.
|
| 185 |
|
| 186 |
Args:
|
| 187 |
content: Content to tokenize.
|
headroom/compression/masks.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
"""Structure mask system for compression.
|
| 2 |
|
| 3 |
A StructureMask identifies which parts of content are "structural" (should be
|
| 4 |
-
preserved) vs "compressible" (can be compressed by
|
| 5 |
|
| 6 |
This separates the concerns of:
|
| 7 |
1. Structure detection (handlers) - What tokens are navigational?
|
| 8 |
-
2. Content compression (
|
| 9 |
|
| 10 |
The mask is content-agnostic - it's just a boolean array aligned to tokens.
|
| 11 |
"""
|
|
@@ -21,7 +21,7 @@ class StructureMask:
|
|
| 21 |
"""A mask identifying structural vs compressible tokens.
|
| 22 |
|
| 23 |
The mask is aligned to a token sequence. True means "preserve this token"
|
| 24 |
-
(it's structural/navigational), False means "compressible" (
|
| 25 |
potentially remove it).
|
| 26 |
|
| 27 |
Attributes:
|
|
@@ -207,7 +207,7 @@ def apply_mask_to_text(
|
|
| 207 |
Args:
|
| 208 |
text: Original text.
|
| 209 |
mask: Structure mask aligned to tokens.
|
| 210 |
-
compress_fn: Function to compress text (e.g.,
|
| 211 |
tokenizer_decode: Optional function to decode tokens to text.
|
| 212 |
If not provided, assumes tokens are strings and joins them.
|
| 213 |
|
|
@@ -245,7 +245,7 @@ class EntropyScore:
|
|
| 245 |
be preserved because:
|
| 246 |
1. They're information-dense (can't be reconstructed)
|
| 247 |
2. They're often identifiers (semantically important)
|
| 248 |
-
3.
|
| 249 |
|
| 250 |
This is a self-signal - no external classifier needed.
|
| 251 |
"""
|
|
|
|
| 1 |
"""Structure mask system for compression.
|
| 2 |
|
| 3 |
A StructureMask identifies which parts of content are "structural" (should be
|
| 4 |
+
preserved) vs "compressible" (can be compressed by Kompress).
|
| 5 |
|
| 6 |
This separates the concerns of:
|
| 7 |
1. Structure detection (handlers) - What tokens are navigational?
|
| 8 |
+
2. Content compression (Kompress) - What tokens can be removed?
|
| 9 |
|
| 10 |
The mask is content-agnostic - it's just a boolean array aligned to tokens.
|
| 11 |
"""
|
|
|
|
| 21 |
"""A mask identifying structural vs compressible tokens.
|
| 22 |
|
| 23 |
The mask is aligned to a token sequence. True means "preserve this token"
|
| 24 |
+
(it's structural/navigational), False means "compressible" (Kompress can
|
| 25 |
potentially remove it).
|
| 26 |
|
| 27 |
Attributes:
|
|
|
|
| 207 |
Args:
|
| 208 |
text: Original text.
|
| 209 |
mask: Structure mask aligned to tokens.
|
| 210 |
+
compress_fn: Function to compress text (e.g., Kompress).
|
| 211 |
tokenizer_decode: Optional function to decode tokens to text.
|
| 212 |
If not provided, assumes tokens are strings and joins them.
|
| 213 |
|
|
|
|
| 245 |
be preserved because:
|
| 246 |
1. They're information-dense (can't be reconstructed)
|
| 247 |
2. They're often identifiers (semantically important)
|
| 248 |
+
3. Token-level compressors may mangle them
|
| 249 |
|
| 250 |
This is a self-signal - no external classifier needed.
|
| 251 |
"""
|
headroom/compression/universal.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
This is the main entry point for compression. It:
|
| 4 |
1. Detects content type using Magika (ML)
|
| 5 |
2. Extracts structure using appropriate handler
|
| 6 |
-
3. Compresses non-structural content with
|
| 7 |
4. Optionally stores original in CCR for retrieval
|
| 8 |
|
| 9 |
Usage:
|
|
@@ -51,7 +51,7 @@ class UniversalCompressorConfig:
|
|
| 51 |
|
| 52 |
Attributes:
|
| 53 |
use_magika: Use ML-based detection (requires magika package).
|
| 54 |
-
|
| 55 |
use_entropy_preservation: Preserve high-entropy tokens (UUIDs, etc.).
|
| 56 |
entropy_threshold: Threshold for entropy-based preservation.
|
| 57 |
min_content_length: Minimum content length to compress.
|
|
@@ -60,7 +60,7 @@ class UniversalCompressorConfig:
|
|
| 60 |
"""
|
| 61 |
|
| 62 |
use_magika: bool = True
|
| 63 |
-
|
| 64 |
use_entropy_preservation: bool = True
|
| 65 |
entropy_threshold: float = 0.85
|
| 66 |
min_content_length: int = 100
|
|
@@ -139,7 +139,7 @@ class UniversalCompressor:
|
|
| 139 |
config: Compression configuration.
|
| 140 |
handlers: Custom handlers for content types.
|
| 141 |
compress_fn: Custom compression function. If None, uses
|
| 142 |
-
|
| 143 |
"""
|
| 144 |
self.config = config or UniversalCompressorConfig()
|
| 145 |
|
|
@@ -165,18 +165,18 @@ class UniversalCompressor:
|
|
| 165 |
def _get_default_compress_fn(self) -> Callable[[str], str]:
|
| 166 |
"""Get default compression function.
|
| 167 |
|
| 168 |
-
Returns
|
| 169 |
"""
|
| 170 |
-
if self.config.
|
| 171 |
try:
|
| 172 |
-
return self.
|
| 173 |
except ImportError:
|
| 174 |
-
logger.info("
|
| 175 |
|
| 176 |
return self._simple_compress
|
| 177 |
|
| 178 |
-
def
|
| 179 |
-
"""Compress using
|
| 180 |
|
| 181 |
Args:
|
| 182 |
text: Text to compress.
|
|
@@ -185,16 +185,15 @@ class UniversalCompressor:
|
|
| 185 |
Compressed text.
|
| 186 |
"""
|
| 187 |
try:
|
| 188 |
-
from headroom.transforms.
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
)
|
| 194 |
except ImportError:
|
| 195 |
return self._simple_compress(text)
|
| 196 |
except Exception as e:
|
| 197 |
-
logger.warning("
|
| 198 |
return self._simple_compress(text)
|
| 199 |
|
| 200 |
def _simple_compress(self, text: str) -> str:
|
|
|
|
| 3 |
This is the main entry point for compression. It:
|
| 4 |
1. Detects content type using Magika (ML)
|
| 5 |
2. Extracts structure using appropriate handler
|
| 6 |
+
3. Compresses non-structural content with Kompress
|
| 7 |
4. Optionally stores original in CCR for retrieval
|
| 8 |
|
| 9 |
Usage:
|
|
|
|
| 51 |
|
| 52 |
Attributes:
|
| 53 |
use_magika: Use ML-based detection (requires magika package).
|
| 54 |
+
use_kompress: Use Kompress for content compression.
|
| 55 |
use_entropy_preservation: Preserve high-entropy tokens (UUIDs, etc.).
|
| 56 |
entropy_threshold: Threshold for entropy-based preservation.
|
| 57 |
min_content_length: Minimum content length to compress.
|
|
|
|
| 60 |
"""
|
| 61 |
|
| 62 |
use_magika: bool = True
|
| 63 |
+
use_kompress: bool = True
|
| 64 |
use_entropy_preservation: bool = True
|
| 65 |
entropy_threshold: float = 0.85
|
| 66 |
min_content_length: int = 100
|
|
|
|
| 139 |
config: Compression configuration.
|
| 140 |
handlers: Custom handlers for content types.
|
| 141 |
compress_fn: Custom compression function. If None, uses
|
| 142 |
+
Kompress when available, else simple truncation.
|
| 143 |
"""
|
| 144 |
self.config = config or UniversalCompressorConfig()
|
| 145 |
|
|
|
|
| 165 |
def _get_default_compress_fn(self) -> Callable[[str], str]:
|
| 166 |
"""Get default compression function.
|
| 167 |
|
| 168 |
+
Returns Kompress wrapper if available, else simple truncation.
|
| 169 |
"""
|
| 170 |
+
if self.config.use_kompress:
|
| 171 |
try:
|
| 172 |
+
return self._kompress_compress
|
| 173 |
except ImportError:
|
| 174 |
+
logger.info("Kompress not available, using simple compression")
|
| 175 |
|
| 176 |
return self._simple_compress
|
| 177 |
|
| 178 |
+
def _kompress_compress(self, text: str) -> str:
|
| 179 |
+
"""Compress using Kompress.
|
| 180 |
|
| 181 |
Args:
|
| 182 |
text: Text to compress.
|
|
|
|
| 185 |
Compressed text.
|
| 186 |
"""
|
| 187 |
try:
|
| 188 |
+
from headroom.transforms.kompress_compressor import KompressCompressor
|
| 189 |
|
| 190 |
+
compressor = KompressCompressor()
|
| 191 |
+
result = compressor.compress(text)
|
| 192 |
+
return result.compressed
|
|
|
|
| 193 |
except ImportError:
|
| 194 |
return self._simple_compress(text)
|
| 195 |
except Exception as e:
|
| 196 |
+
logger.warning("Kompress compression failed: %s", e)
|
| 197 |
return self._simple_compress(text)
|
| 198 |
|
| 199 |
def _simple_compress(self, text: str) -> str:
|
headroom/config.py
CHANGED
|
@@ -619,7 +619,7 @@ class HeadroomConfig:
|
|
| 619 |
prefix_freeze: PrefixFreezeConfig = field(default_factory=PrefixFreezeConfig)
|
| 620 |
|
| 621 |
# Content Router - intelligent content-type based compression
|
| 622 |
-
# Routes content to appropriate compressor (
|
| 623 |
# CodeCompressor for code, LogCompressor for logs, etc.)
|
| 624 |
content_router_enabled: bool = True
|
| 625 |
|
|
|
|
| 619 |
prefix_freeze: PrefixFreezeConfig = field(default_factory=PrefixFreezeConfig)
|
| 620 |
|
| 621 |
# Content Router - intelligent content-type based compression
|
| 622 |
+
# Routes content to appropriate compressor (Kompress for text, SmartCrusher for JSON,
|
| 623 |
# CodeCompressor for code, LogCompressor for logs, etc.)
|
| 624 |
content_router_enabled: bool = True
|
| 625 |
|
headroom/evals/batch_compression_eval.py
CHANGED
|
@@ -353,7 +353,7 @@ def generate_factual_test_cases() -> list[BatchTestCase]:
|
|
| 353 |
Context:
|
| 354 |
The Headroom SDK is a context optimization layer for LLM applications. It was created
|
| 355 |
by Anthropic in 2024. The main features include SmartCrusher for JSON compression,
|
| 356 |
-
|
| 357 |
compression. The SDK supports Python 3.9+ and can save up to 70% of tokens on
|
| 358 |
large JSON arrays.
|
| 359 |
|
|
@@ -363,7 +363,7 @@ Question: What percentage of tokens can the SDK save on large JSON arrays?""",
|
|
| 363 |
),
|
| 364 |
ground_truth="70%",
|
| 365 |
ground_truth_keywords=["70", "percent", "%"],
|
| 366 |
-
context_facts=["70%", "SmartCrusher", "
|
| 367 |
),
|
| 368 |
BatchTestCase(
|
| 369 |
id="factual_002",
|
|
|
|
| 353 |
Context:
|
| 354 |
The Headroom SDK is a context optimization layer for LLM applications. It was created
|
| 355 |
by Anthropic in 2024. The main features include SmartCrusher for JSON compression,
|
| 356 |
+
Kompress for text compression, and CCR (Compress-Cache-Retrieve) for reversible
|
| 357 |
compression. The SDK supports Python 3.9+ and can save up to 70% of tokens on
|
| 358 |
large JSON arrays.
|
| 359 |
|
|
|
|
| 363 |
),
|
| 364 |
ground_truth="70%",
|
| 365 |
ground_truth_keywords=["70", "percent", "%"],
|
| 366 |
+
context_facts=["70%", "SmartCrusher", "Kompress", "CCR", "Python 3.9"],
|
| 367 |
),
|
| 368 |
BatchTestCase(
|
| 369 |
id="factual_002",
|
headroom/evals/html_extraction.py
CHANGED
|
@@ -4,7 +4,6 @@ This module evaluates whether HTMLExtractor preserves the information
|
|
| 4 |
that LLMs need to answer questions about web content. We compare:
|
| 5 |
1. LLM answers from original HTML
|
| 6 |
2. LLM answers from HTMLExtractor output
|
| 7 |
-
3. LLM answers from LLMLingua baseline (current fallback)
|
| 8 |
|
| 9 |
Uses LLM-as-judge to score answer quality on a 1-5 scale.
|
| 10 |
"""
|
|
@@ -81,7 +80,7 @@ class HTMLEvalResult:
|
|
| 81 |
# Answers from different methods
|
| 82 |
answer_from_original: str
|
| 83 |
answer_from_extracted: str
|
| 84 |
-
answer_from_baseline: str | None = None #
|
| 85 |
|
| 86 |
# Judge scores (1-5 scale)
|
| 87 |
extracted_score: float = 0.0
|
|
@@ -211,7 +210,7 @@ class HTMLExtractionEvaluator:
|
|
| 211 |
Args:
|
| 212 |
answer_model: Model for generating answers from content.
|
| 213 |
judge_model: Model for judging answer quality.
|
| 214 |
-
compare_baseline: Whether to also test
|
| 215 |
provider: API provider ("openai", "anthropic", "litellm").
|
| 216 |
"""
|
| 217 |
self.answer_model = answer_model
|
|
@@ -221,7 +220,7 @@ class HTMLExtractionEvaluator:
|
|
| 221 |
|
| 222 |
# Lazy-loaded components
|
| 223 |
self._extractor: HTMLExtractor | None = None
|
| 224 |
-
self.
|
| 225 |
self._judge_fn: Callable[[str, str, str], tuple[float, str]] | None = None
|
| 226 |
self._answer_fn: Any = None
|
| 227 |
|
|
@@ -235,16 +234,16 @@ class HTMLExtractionEvaluator:
|
|
| 235 |
return self._extractor
|
| 236 |
|
| 237 |
@property
|
| 238 |
-
def
|
| 239 |
-
"""Lazy-load
|
| 240 |
-
if self.
|
| 241 |
try:
|
| 242 |
-
from headroom.transforms.
|
| 243 |
|
| 244 |
-
self.
|
| 245 |
except ImportError:
|
| 246 |
-
logger.warning("
|
| 247 |
-
return self.
|
| 248 |
|
| 249 |
@property
|
| 250 |
def judge_fn(self) -> Callable[[str, str, str], tuple[float, str]]:
|
|
@@ -429,14 +428,14 @@ Answer concisely and factually based only on the content provided."""
|
|
| 429 |
answer_from_extracted,
|
| 430 |
)
|
| 431 |
|
| 432 |
-
# Optionally compare with
|
| 433 |
baseline_answer = None
|
| 434 |
baseline_score = None
|
| 435 |
baseline_reasoning = None
|
| 436 |
|
| 437 |
-
if self.compare_baseline and self.
|
| 438 |
try:
|
| 439 |
-
baseline_result = self.
|
| 440 |
baseline_content = baseline_result.compressed
|
| 441 |
baseline_answer = self._get_answer(baseline_content, case.question)
|
| 442 |
baseline_score, baseline_reasoning = self.judge_fn(
|
|
|
|
| 4 |
that LLMs need to answer questions about web content. We compare:
|
| 5 |
1. LLM answers from original HTML
|
| 6 |
2. LLM answers from HTMLExtractor output
|
|
|
|
| 7 |
|
| 8 |
Uses LLM-as-judge to score answer quality on a 1-5 scale.
|
| 9 |
"""
|
|
|
|
| 80 |
# Answers from different methods
|
| 81 |
answer_from_original: str
|
| 82 |
answer_from_extracted: str
|
| 83 |
+
answer_from_baseline: str | None = None # Baseline comparison
|
| 84 |
|
| 85 |
# Judge scores (1-5 scale)
|
| 86 |
extracted_score: float = 0.0
|
|
|
|
| 210 |
Args:
|
| 211 |
answer_model: Model for generating answers from content.
|
| 212 |
judge_model: Model for judging answer quality.
|
| 213 |
+
compare_baseline: Whether to also test Kompress baseline.
|
| 214 |
provider: API provider ("openai", "anthropic", "litellm").
|
| 215 |
"""
|
| 216 |
self.answer_model = answer_model
|
|
|
|
| 220 |
|
| 221 |
# Lazy-loaded components
|
| 222 |
self._extractor: HTMLExtractor | None = None
|
| 223 |
+
self._kompress: Any = None
|
| 224 |
self._judge_fn: Callable[[str, str, str], tuple[float, str]] | None = None
|
| 225 |
self._answer_fn: Any = None
|
| 226 |
|
|
|
|
| 234 |
return self._extractor
|
| 235 |
|
| 236 |
@property
|
| 237 |
+
def kompress(self) -> Any:
|
| 238 |
+
"""Lazy-load Kompress compressor for baseline."""
|
| 239 |
+
if self._kompress is None and self.compare_baseline:
|
| 240 |
try:
|
| 241 |
+
from headroom.transforms.kompress_compressor import KompressCompressor
|
| 242 |
|
| 243 |
+
self._kompress = KompressCompressor()
|
| 244 |
except ImportError:
|
| 245 |
+
logger.warning("Kompress not available for baseline comparison")
|
| 246 |
+
return self._kompress
|
| 247 |
|
| 248 |
@property
|
| 249 |
def judge_fn(self) -> Callable[[str, str, str], tuple[float, str]]:
|
|
|
|
| 428 |
answer_from_extracted,
|
| 429 |
)
|
| 430 |
|
| 431 |
+
# Optionally compare with Kompress baseline
|
| 432 |
baseline_answer = None
|
| 433 |
baseline_score = None
|
| 434 |
baseline_reasoning = None
|
| 435 |
|
| 436 |
+
if self.compare_baseline and self.kompress:
|
| 437 |
try:
|
| 438 |
+
baseline_result = self.kompress.compress(case.html)
|
| 439 |
baseline_content = baseline_result.compressed
|
| 440 |
baseline_answer = self._get_answer(baseline_content, case.question)
|
| 441 |
baseline_score, baseline_reasoning = self.judge_fn(
|
headroom/models/config.py
CHANGED
|
@@ -12,7 +12,6 @@ Usage:
|
|
| 12 |
# Or use environment variables to override at runtime:
|
| 13 |
# HEADROOM_SENTENCE_TRANSFORMER=intfloat/e5-small-v2
|
| 14 |
# HEADROOM_SIGLIP=google/siglip-base-patch16-224
|
| 15 |
-
# HEADROOM_LLMLINGUA=microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
|
@@ -31,7 +30,6 @@ class MLModelConfig:
|
|
| 31 |
Environment variables can override any default:
|
| 32 |
- HEADROOM_SENTENCE_TRANSFORMER
|
| 33 |
- HEADROOM_SIGLIP
|
| 34 |
-
- HEADROOM_LLMLINGUA
|
| 35 |
- HEADROOM_SPACY
|
| 36 |
- HEADROOM_TECHNIQUE_ROUTER
|
| 37 |
|
|
@@ -47,10 +45,6 @@ class MLModelConfig:
|
|
| 47 |
Default: google/siglip-base-patch16-224 (~400MB)
|
| 48 |
Alternative: google/siglip-so400m-patch14-384 (larger, more accurate)
|
| 49 |
|
| 50 |
-
llmlingua: Model for ML-based prompt compression.
|
| 51 |
-
Default: microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank (~350MB)
|
| 52 |
-
Alternative: microsoft/llmlingua-2-xlm-roberta-large-meetingbank (~1GB, slightly more accurate)
|
| 53 |
-
|
| 54 |
spacy: Model for named entity recognition.
|
| 55 |
Default: en_core_web_sm (~40MB)
|
| 56 |
Alternative: en_core_web_md (~120MB, more accurate)
|
|
@@ -70,13 +64,6 @@ class MLModelConfig:
|
|
| 70 |
default_factory=lambda: os.environ.get("HEADROOM_SIGLIP", "google/siglip-base-patch16-224")
|
| 71 |
)
|
| 72 |
|
| 73 |
-
# Prompt Compression (LLMLingua-2)
|
| 74 |
-
llmlingua: str = field(
|
| 75 |
-
default_factory=lambda: os.environ.get(
|
| 76 |
-
"HEADROOM_LLMLINGUA", "microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank"
|
| 77 |
-
)
|
| 78 |
-
)
|
| 79 |
-
|
| 80 |
# Named Entity Recognition (spaCy)
|
| 81 |
spacy: str = field(default_factory=lambda: os.environ.get("HEADROOM_SPACY", "en_core_web_sm"))
|
| 82 |
|
|
@@ -99,9 +86,6 @@ class MLModelConfig:
|
|
| 99 |
"google/siglip-base-patch16-224": 400,
|
| 100 |
"google/siglip-so400m-patch14-384": 900,
|
| 101 |
"google/siglip-large-patch16-384": 1200,
|
| 102 |
-
# LLMLingua
|
| 103 |
-
"microsoft/llmlingua-2-xlm-roberta-large-meetingbank": 1000,
|
| 104 |
-
"microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank": 350,
|
| 105 |
# spaCy
|
| 106 |
"en_core_web_sm": 40,
|
| 107 |
"en_core_web_md": 120,
|
|
@@ -131,7 +115,6 @@ class MLModelConfig:
|
|
| 131 |
return (
|
| 132 |
self.get_memory_estimate(self.sentence_transformer)
|
| 133 |
+ self.get_memory_estimate(self.siglip)
|
| 134 |
-
+ self.get_memory_estimate(self.llmlingua)
|
| 135 |
+ self.get_memory_estimate(self.spacy)
|
| 136 |
+ self.get_memory_estimate(self.technique_router)
|
| 137 |
)
|
|
@@ -160,8 +143,3 @@ def get_default_spacy_model() -> str:
|
|
| 160 |
def get_default_siglip_model() -> str:
|
| 161 |
"""Get the default SIGLIP model name."""
|
| 162 |
return ML_MODEL_DEFAULTS.siglip
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def get_default_llmlingua_model() -> str:
|
| 166 |
-
"""Get the default LLMLingua model name."""
|
| 167 |
-
return ML_MODEL_DEFAULTS.llmlingua
|
|
|
|
| 12 |
# Or use environment variables to override at runtime:
|
| 13 |
# HEADROOM_SENTENCE_TRANSFORMER=intfloat/e5-small-v2
|
| 14 |
# HEADROOM_SIGLIP=google/siglip-base-patch16-224
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
|
|
| 30 |
Environment variables can override any default:
|
| 31 |
- HEADROOM_SENTENCE_TRANSFORMER
|
| 32 |
- HEADROOM_SIGLIP
|
|
|
|
| 33 |
- HEADROOM_SPACY
|
| 34 |
- HEADROOM_TECHNIQUE_ROUTER
|
| 35 |
|
|
|
|
| 45 |
Default: google/siglip-base-patch16-224 (~400MB)
|
| 46 |
Alternative: google/siglip-so400m-patch14-384 (larger, more accurate)
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
spacy: Model for named entity recognition.
|
| 49 |
Default: en_core_web_sm (~40MB)
|
| 50 |
Alternative: en_core_web_md (~120MB, more accurate)
|
|
|
|
| 64 |
default_factory=lambda: os.environ.get("HEADROOM_SIGLIP", "google/siglip-base-patch16-224")
|
| 65 |
)
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
# Named Entity Recognition (spaCy)
|
| 68 |
spacy: str = field(default_factory=lambda: os.environ.get("HEADROOM_SPACY", "en_core_web_sm"))
|
| 69 |
|
|
|
|
| 86 |
"google/siglip-base-patch16-224": 400,
|
| 87 |
"google/siglip-so400m-patch14-384": 900,
|
| 88 |
"google/siglip-large-patch16-384": 1200,
|
|
|
|
|
|
|
|
|
|
| 89 |
# spaCy
|
| 90 |
"en_core_web_sm": 40,
|
| 91 |
"en_core_web_md": 120,
|
|
|
|
| 115 |
return (
|
| 116 |
self.get_memory_estimate(self.sentence_transformer)
|
| 117 |
+ self.get_memory_estimate(self.siglip)
|
|
|
|
| 118 |
+ self.get_memory_estimate(self.spacy)
|
| 119 |
+ self.get_memory_estimate(self.technique_router)
|
| 120 |
)
|
|
|
|
| 143 |
def get_default_siglip_model() -> str:
|
| 144 |
"""Get the default SIGLIP model name."""
|
| 145 |
return ML_MODEL_DEFAULTS.siglip
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
headroom/models/ml_models.py
CHANGED
|
@@ -252,34 +252,6 @@ class MLModelRegistry:
|
|
| 252 |
result: tuple[Any, Any] = instance._models[key]
|
| 253 |
return result
|
| 254 |
|
| 255 |
-
# =========================================================================
|
| 256 |
-
# LLMLingua (uses existing singleton pattern)
|
| 257 |
-
# =========================================================================
|
| 258 |
-
|
| 259 |
-
@classmethod
|
| 260 |
-
def get_llmlingua(cls, device: str | None = None, model_name: str | None = None) -> Any:
|
| 261 |
-
"""Get the LLMLingua compressor.
|
| 262 |
-
|
| 263 |
-
Note: LLMLingua already has its own singleton in llmlingua_compressor.py.
|
| 264 |
-
This method delegates to that implementation.
|
| 265 |
-
|
| 266 |
-
Args:
|
| 267 |
-
device: Device to use. Auto-detected if None.
|
| 268 |
-
model_name: Model name (default: microsoft/llmlingua-2-xlm-roberta-large-meetingbank).
|
| 269 |
-
|
| 270 |
-
Returns:
|
| 271 |
-
PromptCompressor instance.
|
| 272 |
-
"""
|
| 273 |
-
from headroom.transforms.llmlingua_compressor import _get_llmlingua_compressor
|
| 274 |
-
|
| 275 |
-
if device is None:
|
| 276 |
-
device = cls._detect_device()
|
| 277 |
-
|
| 278 |
-
if model_name is None:
|
| 279 |
-
model_name = ML_MODEL_DEFAULTS.llmlingua
|
| 280 |
-
|
| 281 |
-
return _get_llmlingua_compressor(model_name=model_name, device=device)
|
| 282 |
-
|
| 283 |
# =========================================================================
|
| 284 |
# Utility Methods
|
| 285 |
# =========================================================================
|
|
|
|
| 252 |
result: tuple[Any, Any] = instance._models[key]
|
| 253 |
return result
|
| 254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
# =========================================================================
|
| 256 |
# Utility Methods
|
| 257 |
# =========================================================================
|
headroom/perf/analyzer.py
CHANGED
|
@@ -562,7 +562,7 @@ def _generate_recommendations(report: PerfReport) -> list[str]:
|
|
| 562 |
if len(slow) > len(report.perf_records) * 0.2:
|
| 563 |
recs.append(
|
| 564 |
f"{len(slow)} requests took >500ms for optimization — "
|
| 565 |
-
"consider
|
| 566 |
)
|
| 567 |
|
| 568 |
if report.router_records:
|
|
|
|
| 562 |
if len(slow) > len(report.perf_records) * 0.2:
|
| 563 |
recs.append(
|
| 564 |
f"{len(slow)} requests took >500ms for optimization — "
|
| 565 |
+
"consider reducing transform pipeline"
|
| 566 |
)
|
| 567 |
|
| 568 |
if report.router_records:
|
headroom/proxy/server.py
CHANGED
|
@@ -93,7 +93,6 @@ from headroom.telemetry import get_telemetry_collector
|
|
| 93 |
from headroom.telemetry.toin import get_toin
|
| 94 |
from headroom.tokenizers import get_tokenizer
|
| 95 |
from headroom.transforms import (
|
| 96 |
-
_LLMLINGUA_AVAILABLE,
|
| 97 |
CacheAligner,
|
| 98 |
CodeAwareCompressor,
|
| 99 |
CodeCompressorConfig,
|
|
@@ -127,10 +126,6 @@ def _get_image_compressor():
|
|
| 127 |
return _image_compressor if _image_compressor else None
|
| 128 |
|
| 129 |
|
| 130 |
-
# Conditionally import LLMLingua if available
|
| 131 |
-
if _LLMLINGUA_AVAILABLE:
|
| 132 |
-
from headroom.transforms import LLMLinguaCompressor, LLMLinguaConfig
|
| 133 |
-
|
| 134 |
# Try to import LiteLLM for pricing
|
| 135 |
try:
|
| 136 |
import litellm
|
|
@@ -648,11 +643,6 @@ class ProxyConfig:
|
|
| 648 |
ccr_proactive_expansion: bool = True # Proactively expand based on query relevance
|
| 649 |
ccr_max_proactive_expansions: int = 2 # Max contexts to proactively expand per turn
|
| 650 |
|
| 651 |
-
# LLMLingua ML-based compression (ON by default if installed)
|
| 652 |
-
llmlingua_enabled: bool = True # Enable LLMLingua-2 for ML-based compression
|
| 653 |
-
llmlingua_device: str = "auto" # Device: 'auto', 'cuda', 'cpu', 'mps'
|
| 654 |
-
llmlingua_target_rate: float = 0.3 # Target compression rate (0.3 = keep 30%)
|
| 655 |
-
|
| 656 |
# Code-aware compression (ON by default if installed)
|
| 657 |
code_aware_enabled: bool = True # Enable AST-based code compression
|
| 658 |
|
|
@@ -1641,9 +1631,8 @@ class HeadroomProxy:
|
|
| 1641 |
|
| 1642 |
if config.smart_routing:
|
| 1643 |
# Smart routing: ContentRouter handles all content types intelligently
|
| 1644 |
-
# It lazy-loads compressors
|
| 1645 |
router_config = ContentRouterConfig(
|
| 1646 |
-
enable_llmlingua=config.llmlingua_enabled,
|
| 1647 |
enable_code_aware=config.code_aware_enabled,
|
| 1648 |
tool_profiles=config.tool_profiles,
|
| 1649 |
read_lifecycle=ReadLifecycleConfig(enabled=config.read_lifecycle),
|
|
@@ -1656,7 +1645,6 @@ class HeadroomProxy:
|
|
| 1656 |
ContentRouter(router_config),
|
| 1657 |
context_manager,
|
| 1658 |
]
|
| 1659 |
-
self._llmlingua_status = "lazy" if config.llmlingua_enabled else "disabled"
|
| 1660 |
self._code_aware_status = "lazy" if config.code_aware_enabled else "disabled"
|
| 1661 |
else:
|
| 1662 |
# Legacy mode: sequential pipeline
|
|
@@ -1675,8 +1663,6 @@ class HeadroomProxy:
|
|
| 1675 |
),
|
| 1676 |
context_manager,
|
| 1677 |
]
|
| 1678 |
-
# Add LLMLingua if enabled and available
|
| 1679 |
-
self._llmlingua_status = self._setup_llmlingua(config, transforms)
|
| 1680 |
# Add CodeAware if enabled and available
|
| 1681 |
self._code_aware_status = self._setup_code_aware(config, transforms)
|
| 1682 |
|
|
@@ -1893,38 +1879,6 @@ class HeadroomProxy:
|
|
| 1893 |
self._compression_caches[session_id] = CompressionCache()
|
| 1894 |
return self._compression_caches[session_id]
|
| 1895 |
|
| 1896 |
-
def _setup_llmlingua(self, config: ProxyConfig, transforms: list) -> str:
|
| 1897 |
-
"""Set up LLMLingua compression if enabled.
|
| 1898 |
-
|
| 1899 |
-
Args:
|
| 1900 |
-
config: Proxy configuration
|
| 1901 |
-
transforms: Transform list to append to
|
| 1902 |
-
|
| 1903 |
-
Returns:
|
| 1904 |
-
Status string for logging: 'enabled', 'disabled', 'available', 'unavailable'
|
| 1905 |
-
"""
|
| 1906 |
-
if config.llmlingua_enabled:
|
| 1907 |
-
if _LLMLINGUA_AVAILABLE:
|
| 1908 |
-
llmlingua_config = LLMLinguaConfig(
|
| 1909 |
-
device=config.llmlingua_device,
|
| 1910 |
-
target_compression_rate=config.llmlingua_target_rate,
|
| 1911 |
-
enable_ccr=config.ccr_inject_tool, # Link to CCR
|
| 1912 |
-
)
|
| 1913 |
-
# Insert before RollingWindow (which should be last)
|
| 1914 |
-
# LLMLingua works best on individual tool outputs before windowing
|
| 1915 |
-
transforms.insert(-1, LLMLinguaCompressor(llmlingua_config))
|
| 1916 |
-
return "enabled"
|
| 1917 |
-
else:
|
| 1918 |
-
logger.warning(
|
| 1919 |
-
"LLMLingua requested but not installed. "
|
| 1920 |
-
"Install with: pip install headroom-ai[llmlingua]"
|
| 1921 |
-
)
|
| 1922 |
-
return "unavailable"
|
| 1923 |
-
else:
|
| 1924 |
-
if _LLMLINGUA_AVAILABLE:
|
| 1925 |
-
return "available" # Available but not enabled - hint to user
|
| 1926 |
-
return "disabled"
|
| 1927 |
-
|
| 1928 |
def _setup_code_aware(self, config: ProxyConfig, transforms: list) -> str:
|
| 1929 |
"""Set up code-aware compression if enabled.
|
| 1930 |
|
|
@@ -2013,8 +1967,6 @@ class HeadroomProxy:
|
|
| 2013 |
# Update internal status from eager loading results
|
| 2014 |
if eager_status.get("kompress") == "enabled":
|
| 2015 |
self._kompress_status = "enabled"
|
| 2016 |
-
if eager_status.get("llmlingua") == "enabled":
|
| 2017 |
-
self._llmlingua_status = "enabled"
|
| 2018 |
if eager_status.get("code_aware") == "enabled":
|
| 2019 |
self._code_aware_status = "enabled"
|
| 2020 |
|
|
@@ -2024,18 +1976,6 @@ class HeadroomProxy:
|
|
| 2024 |
elif self.config.optimize:
|
| 2025 |
logger.info("Kompress: not installed (pip install headroom-ai[ml] for ML compression)")
|
| 2026 |
|
| 2027 |
-
if self._llmlingua_status == "enabled":
|
| 2028 |
-
logger.info(
|
| 2029 |
-
f"LLMLingua: ENABLED (device={self.config.llmlingua_device}, "
|
| 2030 |
-
f"rate={self.config.llmlingua_target_rate})"
|
| 2031 |
-
)
|
| 2032 |
-
elif self._kompress_status == "enabled":
|
| 2033 |
-
logger.info("LLMLingua: skipped (Kompress is active)")
|
| 2034 |
-
elif self._llmlingua_status == "lazy":
|
| 2035 |
-
logger.info("LLMLingua: LAZY (will load when prose content detected)")
|
| 2036 |
-
elif self._llmlingua_status == "disabled":
|
| 2037 |
-
logger.info("LLMLingua: DISABLED")
|
| 2038 |
-
|
| 2039 |
if self._code_aware_status == "enabled":
|
| 2040 |
logger.info("Code-Aware: ENABLED (AST-based compression)")
|
| 2041 |
if "tree_sitter" in eager_status:
|
|
@@ -7969,21 +7909,6 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|
| 7969 |
return app
|
| 7970 |
|
| 7971 |
|
| 7972 |
-
def _get_llmlingua_banner_status(config: ProxyConfig) -> str:
|
| 7973 |
-
"""Get LLMLingua status line for banner."""
|
| 7974 |
-
if config.llmlingua_enabled:
|
| 7975 |
-
if _LLMLINGUA_AVAILABLE:
|
| 7976 |
-
return (
|
| 7977 |
-
f"ENABLED (device={config.llmlingua_device}, rate={config.llmlingua_target_rate})"
|
| 7978 |
-
)
|
| 7979 |
-
else:
|
| 7980 |
-
return "NOT INSTALLED (pip install headroom-ai[llmlingua])"
|
| 7981 |
-
else:
|
| 7982 |
-
if _LLMLINGUA_AVAILABLE:
|
| 7983 |
-
return "DISABLED (remove --no-llmlingua to enable)"
|
| 7984 |
-
return "DISABLED"
|
| 7985 |
-
|
| 7986 |
-
|
| 7987 |
def _get_code_aware_banner_status(config: ProxyConfig) -> str:
|
| 7988 |
"""Get code-aware compression status line for banner."""
|
| 7989 |
if config.code_aware_enabled:
|
|
@@ -8016,7 +7941,6 @@ def run_server(
|
|
| 8016 |
config = config or ProxyConfig()
|
| 8017 |
app = create_app(config)
|
| 8018 |
|
| 8019 |
-
llmlingua_status = _get_llmlingua_banner_status(config)
|
| 8020 |
code_aware_status = _get_code_aware_banner_status(config)
|
| 8021 |
|
| 8022 |
# Format connection pool info
|
|
@@ -8055,7 +7979,6 @@ def run_server(
|
|
| 8055 |
║ Rate Limiting: {"ENABLED " if config.rate_limit_enabled else "DISABLED"} ({config.rate_limit_requests_per_minute} req/min, {config.rate_limit_tokens_per_minute:,} tok/min) ║
|
| 8056 |
║ Retry: {"ENABLED " if config.retry_enabled else "DISABLED"} (max {config.retry_max_attempts} attempts) ║
|
| 8057 |
║ Cost Tracking: {"ENABLED " if config.cost_tracking_enabled else "DISABLED"} (budget: {"$" + str(config.budget_limit_usd) + "/" + config.budget_period if config.budget_limit_usd else "unlimited"}) ║
|
| 8058 |
-
║ LLMLingua: {llmlingua_status:<52}║
|
| 8059 |
║ Code-Aware: {code_aware_status:<52}║
|
| 8060 |
║ HTTP/2: {http2_status:<52}║
|
| 8061 |
║ Conn Pool: {pool_info:<52}║
|
|
@@ -8269,30 +8192,6 @@ if __name__ == "__main__":
|
|
| 8269 |
help="Disable smart routing (use legacy sequential pipeline)",
|
| 8270 |
)
|
| 8271 |
|
| 8272 |
-
# LLMLingua ML-based compression
|
| 8273 |
-
parser.add_argument(
|
| 8274 |
-
"--llmlingua",
|
| 8275 |
-
action="store_true",
|
| 8276 |
-
help="Enable LLMLingua-2 ML-based compression (requires: pip install headroom-ai[llmlingua])",
|
| 8277 |
-
)
|
| 8278 |
-
parser.add_argument(
|
| 8279 |
-
"--no-llmlingua",
|
| 8280 |
-
action="store_true",
|
| 8281 |
-
help="Disable LLMLingua compression",
|
| 8282 |
-
)
|
| 8283 |
-
parser.add_argument(
|
| 8284 |
-
"--llmlingua-device",
|
| 8285 |
-
choices=["auto", "cuda", "cpu", "mps"],
|
| 8286 |
-
default="auto",
|
| 8287 |
-
help="Device for LLMLingua model (default: auto)",
|
| 8288 |
-
)
|
| 8289 |
-
parser.add_argument(
|
| 8290 |
-
"--llmlingua-rate",
|
| 8291 |
-
type=float,
|
| 8292 |
-
default=0.3,
|
| 8293 |
-
help="LLMLingua target compression rate, 0.0-1.0 (default: 0.3 = keep 30%%)",
|
| 8294 |
-
)
|
| 8295 |
-
|
| 8296 |
# Code-aware compression
|
| 8297 |
parser.add_argument(
|
| 8298 |
"--code-aware",
|
|
@@ -8310,7 +8209,6 @@ if __name__ == "__main__":
|
|
| 8310 |
# Environment variable defaults (HEADROOM_* prefix)
|
| 8311 |
# CLI args override env vars, env vars override ProxyConfig defaults
|
| 8312 |
env_smart_routing = _get_env_bool("HEADROOM_SMART_ROUTING", True)
|
| 8313 |
-
env_llmlingua = _get_env_bool("HEADROOM_LLMLINGUA_ENABLED", True)
|
| 8314 |
env_code_aware = _get_env_bool("HEADROOM_CODE_AWARE_ENABLED", True)
|
| 8315 |
env_optimize = _get_env_bool("HEADROOM_OPTIMIZE", True)
|
| 8316 |
env_cache = _get_env_bool("HEADROOM_CACHE_ENABLED", True)
|
|
@@ -8319,11 +8217,6 @@ if __name__ == "__main__":
|
|
| 8319 |
# Determine settings: CLI flags override env vars
|
| 8320 |
# --no-X explicitly disables, --X explicitly enables, neither uses env var
|
| 8321 |
smart_routing = env_smart_routing if not args.no_smart_routing else False
|
| 8322 |
-
llmlingua_enabled = (
|
| 8323 |
-
env_llmlingua
|
| 8324 |
-
if not (args.llmlingua or args.no_llmlingua)
|
| 8325 |
-
else (args.llmlingua or not args.no_llmlingua)
|
| 8326 |
-
)
|
| 8327 |
code_aware_enabled = (
|
| 8328 |
env_code_aware
|
| 8329 |
if not (args.code_aware or args.no_code_aware)
|
|
@@ -8364,9 +8257,6 @@ if __name__ == "__main__":
|
|
| 8364 |
else os.environ.get("HEADROOM_LOG_FILE"),
|
| 8365 |
log_full_messages=args.log_messages or _get_env_bool("HEADROOM_LOG_MESSAGES", False),
|
| 8366 |
smart_routing=smart_routing,
|
| 8367 |
-
llmlingua_enabled=llmlingua_enabled,
|
| 8368 |
-
llmlingua_device=_get_env_str("HEADROOM_LLMLINGUA_DEVICE", args.llmlingua_device),
|
| 8369 |
-
llmlingua_target_rate=_get_env_float("HEADROOM_LLMLINGUA_RATE", args.llmlingua_rate),
|
| 8370 |
code_aware_enabled=code_aware_enabled,
|
| 8371 |
# Connection pool settings
|
| 8372 |
max_connections=_get_env_int("HEADROOM_MAX_CONNECTIONS", args.max_connections),
|
|
|
|
| 93 |
from headroom.telemetry.toin import get_toin
|
| 94 |
from headroom.tokenizers import get_tokenizer
|
| 95 |
from headroom.transforms import (
|
|
|
|
| 96 |
CacheAligner,
|
| 97 |
CodeAwareCompressor,
|
| 98 |
CodeCompressorConfig,
|
|
|
|
| 126 |
return _image_compressor if _image_compressor else None
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
# Try to import LiteLLM for pricing
|
| 130 |
try:
|
| 131 |
import litellm
|
|
|
|
| 643 |
ccr_proactive_expansion: bool = True # Proactively expand based on query relevance
|
| 644 |
ccr_max_proactive_expansions: int = 2 # Max contexts to proactively expand per turn
|
| 645 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
# Code-aware compression (ON by default if installed)
|
| 647 |
code_aware_enabled: bool = True # Enable AST-based code compression
|
| 648 |
|
|
|
|
| 1631 |
|
| 1632 |
if config.smart_routing:
|
| 1633 |
# Smart routing: ContentRouter handles all content types intelligently
|
| 1634 |
+
# It lazy-loads compressors only when needed
|
| 1635 |
router_config = ContentRouterConfig(
|
|
|
|
| 1636 |
enable_code_aware=config.code_aware_enabled,
|
| 1637 |
tool_profiles=config.tool_profiles,
|
| 1638 |
read_lifecycle=ReadLifecycleConfig(enabled=config.read_lifecycle),
|
|
|
|
| 1645 |
ContentRouter(router_config),
|
| 1646 |
context_manager,
|
| 1647 |
]
|
|
|
|
| 1648 |
self._code_aware_status = "lazy" if config.code_aware_enabled else "disabled"
|
| 1649 |
else:
|
| 1650 |
# Legacy mode: sequential pipeline
|
|
|
|
| 1663 |
),
|
| 1664 |
context_manager,
|
| 1665 |
]
|
|
|
|
|
|
|
| 1666 |
# Add CodeAware if enabled and available
|
| 1667 |
self._code_aware_status = self._setup_code_aware(config, transforms)
|
| 1668 |
|
|
|
|
| 1879 |
self._compression_caches[session_id] = CompressionCache()
|
| 1880 |
return self._compression_caches[session_id]
|
| 1881 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1882 |
def _setup_code_aware(self, config: ProxyConfig, transforms: list) -> str:
|
| 1883 |
"""Set up code-aware compression if enabled.
|
| 1884 |
|
|
|
|
| 1967 |
# Update internal status from eager loading results
|
| 1968 |
if eager_status.get("kompress") == "enabled":
|
| 1969 |
self._kompress_status = "enabled"
|
|
|
|
|
|
|
| 1970 |
if eager_status.get("code_aware") == "enabled":
|
| 1971 |
self._code_aware_status = "enabled"
|
| 1972 |
|
|
|
|
| 1976 |
elif self.config.optimize:
|
| 1977 |
logger.info("Kompress: not installed (pip install headroom-ai[ml] for ML compression)")
|
| 1978 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1979 |
if self._code_aware_status == "enabled":
|
| 1980 |
logger.info("Code-Aware: ENABLED (AST-based compression)")
|
| 1981 |
if "tree_sitter" in eager_status:
|
|
|
|
| 7909 |
return app
|
| 7910 |
|
| 7911 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7912 |
def _get_code_aware_banner_status(config: ProxyConfig) -> str:
|
| 7913 |
"""Get code-aware compression status line for banner."""
|
| 7914 |
if config.code_aware_enabled:
|
|
|
|
| 7941 |
config = config or ProxyConfig()
|
| 7942 |
app = create_app(config)
|
| 7943 |
|
|
|
|
| 7944 |
code_aware_status = _get_code_aware_banner_status(config)
|
| 7945 |
|
| 7946 |
# Format connection pool info
|
|
|
|
| 7979 |
║ Rate Limiting: {"ENABLED " if config.rate_limit_enabled else "DISABLED"} ({config.rate_limit_requests_per_minute} req/min, {config.rate_limit_tokens_per_minute:,} tok/min) ║
|
| 7980 |
║ Retry: {"ENABLED " if config.retry_enabled else "DISABLED"} (max {config.retry_max_attempts} attempts) ║
|
| 7981 |
║ Cost Tracking: {"ENABLED " if config.cost_tracking_enabled else "DISABLED"} (budget: {"$" + str(config.budget_limit_usd) + "/" + config.budget_period if config.budget_limit_usd else "unlimited"}) ║
|
|
|
|
| 7982 |
║ Code-Aware: {code_aware_status:<52}║
|
| 7983 |
║ HTTP/2: {http2_status:<52}║
|
| 7984 |
║ Conn Pool: {pool_info:<52}║
|
|
|
|
| 8192 |
help="Disable smart routing (use legacy sequential pipeline)",
|
| 8193 |
)
|
| 8194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8195 |
# Code-aware compression
|
| 8196 |
parser.add_argument(
|
| 8197 |
"--code-aware",
|
|
|
|
| 8209 |
# Environment variable defaults (HEADROOM_* prefix)
|
| 8210 |
# CLI args override env vars, env vars override ProxyConfig defaults
|
| 8211 |
env_smart_routing = _get_env_bool("HEADROOM_SMART_ROUTING", True)
|
|
|
|
| 8212 |
env_code_aware = _get_env_bool("HEADROOM_CODE_AWARE_ENABLED", True)
|
| 8213 |
env_optimize = _get_env_bool("HEADROOM_OPTIMIZE", True)
|
| 8214 |
env_cache = _get_env_bool("HEADROOM_CACHE_ENABLED", True)
|
|
|
|
| 8217 |
# Determine settings: CLI flags override env vars
|
| 8218 |
# --no-X explicitly disables, --X explicitly enables, neither uses env var
|
| 8219 |
smart_routing = env_smart_routing if not args.no_smart_routing else False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8220 |
code_aware_enabled = (
|
| 8221 |
env_code_aware
|
| 8222 |
if not (args.code_aware or args.no_code_aware)
|
|
|
|
| 8257 |
else os.environ.get("HEADROOM_LOG_FILE"),
|
| 8258 |
log_full_messages=args.log_messages or _get_env_bool("HEADROOM_LOG_MESSAGES", False),
|
| 8259 |
smart_routing=smart_routing,
|
|
|
|
|
|
|
|
|
|
| 8260 |
code_aware_enabled=code_aware_enabled,
|
| 8261 |
# Connection pool settings
|
| 8262 |
max_connections=_get_env_int("HEADROOM_MAX_CONNECTIONS", args.max_connections),
|
headroom/transforms/__init__.py
CHANGED
|
@@ -25,21 +25,6 @@ from .search_compressor import (
|
|
| 25 |
from .smart_crusher import SmartCrusher, SmartCrusherConfig
|
| 26 |
from .tool_crusher import ToolCrusher
|
| 27 |
|
| 28 |
-
# ML-based compression (optional dependency)
|
| 29 |
-
try:
|
| 30 |
-
from .llmlingua_compressor import ( # noqa: F401
|
| 31 |
-
LLMLinguaCompressor,
|
| 32 |
-
LLMLinguaConfig,
|
| 33 |
-
LLMLinguaResult,
|
| 34 |
-
compress_with_llmlingua,
|
| 35 |
-
is_llmlingua_model_loaded,
|
| 36 |
-
unload_llmlingua_model,
|
| 37 |
-
)
|
| 38 |
-
|
| 39 |
-
_LLMLINGUA_AVAILABLE = True
|
| 40 |
-
except ImportError:
|
| 41 |
-
_LLMLINGUA_AVAILABLE = False
|
| 42 |
-
|
| 43 |
# HTML content extraction (optional dependency - requires trafilatura)
|
| 44 |
try:
|
| 45 |
from .html_extractor import ( # noqa: F401
|
|
@@ -122,25 +107,10 @@ __all__ = [
|
|
| 122 |
"MessageScorer",
|
| 123 |
"MessageScore",
|
| 124 |
"EmbeddingProvider",
|
| 125 |
-
# ML-based compression (optional)
|
| 126 |
-
"_LLMLINGUA_AVAILABLE",
|
| 127 |
# HTML extraction (optional)
|
| 128 |
"_HTML_EXTRACTOR_AVAILABLE",
|
| 129 |
]
|
| 130 |
|
| 131 |
-
# Conditionally add LLMLingua exports
|
| 132 |
-
if _LLMLINGUA_AVAILABLE:
|
| 133 |
-
__all__.extend(
|
| 134 |
-
[
|
| 135 |
-
"LLMLinguaCompressor",
|
| 136 |
-
"LLMLinguaConfig",
|
| 137 |
-
"LLMLinguaResult",
|
| 138 |
-
"compress_with_llmlingua",
|
| 139 |
-
"is_llmlingua_model_loaded",
|
| 140 |
-
"unload_llmlingua_model",
|
| 141 |
-
]
|
| 142 |
-
)
|
| 143 |
-
|
| 144 |
# Conditionally add HTML extractor exports
|
| 145 |
if _HTML_EXTRACTOR_AVAILABLE:
|
| 146 |
__all__.extend(
|
|
|
|
| 25 |
from .smart_crusher import SmartCrusher, SmartCrusherConfig
|
| 26 |
from .tool_crusher import ToolCrusher
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
# HTML content extraction (optional dependency - requires trafilatura)
|
| 29 |
try:
|
| 30 |
from .html_extractor import ( # noqa: F401
|
|
|
|
| 107 |
"MessageScorer",
|
| 108 |
"MessageScore",
|
| 109 |
"EmbeddingProvider",
|
|
|
|
|
|
|
| 110 |
# HTML extraction (optional)
|
| 111 |
"_HTML_EXTRACTOR_AVAILABLE",
|
| 112 |
]
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
# Conditionally add HTML extractor exports
|
| 115 |
if _HTML_EXTRACTOR_AVAILABLE:
|
| 116 |
__all__.extend(
|
headroom/transforms/code_compressor.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""Code-aware compressor using AST parsing for syntax-preserving compression.
|
| 2 |
|
| 3 |
This module provides AST-based compression for source code that guarantees
|
| 4 |
-
valid syntax output. Unlike token-level compression
|
| 5 |
-
|
| 6 |
|
| 7 |
Key Features:
|
| 8 |
- Syntax validity guaranteed (output always parses)
|
|
@@ -329,7 +329,7 @@ class CodeCompressorConfig:
|
|
| 329 |
compress_comments: Remove non-docstring comments.
|
| 330 |
min_tokens_for_compression: Minimum tokens to trigger compression.
|
| 331 |
language_hint: Explicit language (None = auto-detect).
|
| 332 |
-
|
| 333 |
enable_ccr: Store originals for retrieval.
|
| 334 |
ccr_ttl: TTL for CCR entries in seconds.
|
| 335 |
"""
|
|
@@ -351,7 +351,7 @@ class CodeCompressorConfig:
|
|
| 351 |
|
| 352 |
# Language handling
|
| 353 |
language_hint: str | None = None
|
| 354 |
-
|
| 355 |
|
| 356 |
# Semantic analysis (symbol importance scoring)
|
| 357 |
semantic_analysis: bool = True
|
|
@@ -615,7 +615,7 @@ class CodeAwareCompressor(Transform):
|
|
| 615 |
- Syntax validity guaranteed
|
| 616 |
- Preserves imports, signatures, types
|
| 617 |
- Better compression ratios for code (5-8x vs 3-5x)
|
| 618 |
-
- Lower latency (~20-50ms vs 50-200ms for
|
| 619 |
- Smaller memory footprint (~50MB vs ~1GB)
|
| 620 |
- Thread-safe (no mutable instance state during compression)
|
| 621 |
|
|
@@ -947,9 +947,9 @@ class CodeAwareCompressor(Transform):
|
|
| 947 |
else:
|
| 948 |
detected_lang, confidence = detect_language(code)
|
| 949 |
|
| 950 |
-
# If language unknown and fallback enabled, try
|
| 951 |
if detected_lang == CodeLanguage.UNKNOWN:
|
| 952 |
-
if self.config.
|
| 953 |
return self._fallback_compress(code, original_tokens)
|
| 954 |
else:
|
| 955 |
return CodeCompressionResult(
|
|
@@ -966,7 +966,7 @@ class CodeAwareCompressor(Transform):
|
|
| 966 |
# Check if tree-sitter is available
|
| 967 |
if not _check_tree_sitter_available():
|
| 968 |
logger.warning("tree-sitter not available. Install with: pip install headroom-ai[code]")
|
| 969 |
-
if self.config.
|
| 970 |
return self._fallback_compress(code, original_tokens)
|
| 971 |
return CodeCompressionResult(
|
| 972 |
compressed=code,
|
|
@@ -1062,7 +1062,7 @@ class CodeAwareCompressor(Transform):
|
|
| 1062 |
|
| 1063 |
except Exception as e:
|
| 1064 |
logger.warning("AST compression failed: %s, falling back", e)
|
| 1065 |
-
if self.config.
|
| 1066 |
return self._fallback_compress(code, original_tokens)
|
| 1067 |
return CodeCompressionResult(
|
| 1068 |
compressed=code,
|
|
@@ -1629,13 +1629,13 @@ class CodeAwareCompressor(Transform):
|
|
| 1629 |
return False
|
| 1630 |
|
| 1631 |
def _fallback_compress(self, code: str, original_tokens: int) -> CodeCompressionResult:
|
| 1632 |
-
"""Fall back to
|
| 1633 |
try:
|
| 1634 |
-
from .
|
| 1635 |
|
| 1636 |
-
if
|
| 1637 |
-
compressor =
|
| 1638 |
-
result = compressor.compress(code
|
| 1639 |
return CodeCompressionResult(
|
| 1640 |
compressed=result.compressed,
|
| 1641 |
original=code,
|
|
@@ -1644,7 +1644,7 @@ class CodeAwareCompressor(Transform):
|
|
| 1644 |
compression_ratio=result.compression_ratio,
|
| 1645 |
language=CodeLanguage.UNKNOWN,
|
| 1646 |
language_confidence=0.0,
|
| 1647 |
-
#
|
| 1648 |
syntax_valid=False,
|
| 1649 |
)
|
| 1650 |
except ImportError:
|
|
|
|
| 1 |
"""Code-aware compressor using AST parsing for syntax-preserving compression.
|
| 2 |
|
| 3 |
This module provides AST-based compression for source code that guarantees
|
| 4 |
+
valid syntax output. Unlike token-level compression, this preserves
|
| 5 |
+
structural elements while compressing function bodies.
|
| 6 |
|
| 7 |
Key Features:
|
| 8 |
- Syntax validity guaranteed (output always parses)
|
|
|
|
| 329 |
compress_comments: Remove non-docstring comments.
|
| 330 |
min_tokens_for_compression: Minimum tokens to trigger compression.
|
| 331 |
language_hint: Explicit language (None = auto-detect).
|
| 332 |
+
fallback_to_kompress: Use Kompress for unknown languages.
|
| 333 |
enable_ccr: Store originals for retrieval.
|
| 334 |
ccr_ttl: TTL for CCR entries in seconds.
|
| 335 |
"""
|
|
|
|
| 351 |
|
| 352 |
# Language handling
|
| 353 |
language_hint: str | None = None
|
| 354 |
+
fallback_to_kompress: bool = True
|
| 355 |
|
| 356 |
# Semantic analysis (symbol importance scoring)
|
| 357 |
semantic_analysis: bool = True
|
|
|
|
| 615 |
- Syntax validity guaranteed
|
| 616 |
- Preserves imports, signatures, types
|
| 617 |
- Better compression ratios for code (5-8x vs 3-5x)
|
| 618 |
+
- Lower latency (~20-50ms vs 50-200ms for token-level compressors)
|
| 619 |
- Smaller memory footprint (~50MB vs ~1GB)
|
| 620 |
- Thread-safe (no mutable instance state during compression)
|
| 621 |
|
|
|
|
| 947 |
else:
|
| 948 |
detected_lang, confidence = detect_language(code)
|
| 949 |
|
| 950 |
+
# If language unknown and fallback enabled, try Kompress
|
| 951 |
if detected_lang == CodeLanguage.UNKNOWN:
|
| 952 |
+
if self.config.fallback_to_kompress:
|
| 953 |
return self._fallback_compress(code, original_tokens)
|
| 954 |
else:
|
| 955 |
return CodeCompressionResult(
|
|
|
|
| 966 |
# Check if tree-sitter is available
|
| 967 |
if not _check_tree_sitter_available():
|
| 968 |
logger.warning("tree-sitter not available. Install with: pip install headroom-ai[code]")
|
| 969 |
+
if self.config.fallback_to_kompress:
|
| 970 |
return self._fallback_compress(code, original_tokens)
|
| 971 |
return CodeCompressionResult(
|
| 972 |
compressed=code,
|
|
|
|
| 1062 |
|
| 1063 |
except Exception as e:
|
| 1064 |
logger.warning("AST compression failed: %s, falling back", e)
|
| 1065 |
+
if self.config.fallback_to_kompress:
|
| 1066 |
return self._fallback_compress(code, original_tokens)
|
| 1067 |
return CodeCompressionResult(
|
| 1068 |
compressed=code,
|
|
|
|
| 1629 |
return False
|
| 1630 |
|
| 1631 |
def _fallback_compress(self, code: str, original_tokens: int) -> CodeCompressionResult:
|
| 1632 |
+
"""Fall back to Kompress compression."""
|
| 1633 |
try:
|
| 1634 |
+
from .kompress_compressor import KompressCompressor, is_kompress_available
|
| 1635 |
|
| 1636 |
+
if is_kompress_available():
|
| 1637 |
+
compressor = KompressCompressor()
|
| 1638 |
+
result = compressor.compress(code)
|
| 1639 |
return CodeCompressionResult(
|
| 1640 |
compressed=result.compressed,
|
| 1641 |
original=code,
|
|
|
|
| 1644 |
compression_ratio=result.compression_ratio,
|
| 1645 |
language=CodeLanguage.UNKNOWN,
|
| 1646 |
language_confidence=0.0,
|
| 1647 |
+
# Kompress does NOT guarantee syntax validity
|
| 1648 |
syntax_valid=False,
|
| 1649 |
)
|
| 1650 |
except ImportError:
|
headroom/transforms/content_detector.py
CHANGED
|
@@ -224,7 +224,7 @@ def _try_detect_html(content: str) -> DetectionResult | None:
|
|
| 224 |
"""Try to detect HTML content.
|
| 225 |
|
| 226 |
HTML needs content extraction (removing scripts, styles, nav, etc.),
|
| 227 |
-
not token-level compression like
|
| 228 |
"""
|
| 229 |
# Check first 3000 chars for HTML indicators
|
| 230 |
sample = content[:3000]
|
|
|
|
| 224 |
"""Try to detect HTML content.
|
| 225 |
|
| 226 |
HTML needs content extraction (removing scripts, styles, nav, etc.),
|
| 227 |
+
not token-level compression like Kompress.
|
| 228 |
"""
|
| 229 |
# Check first 3000 chars for HTML indicators
|
| 230 |
sample = content[:3000]
|
headroom/transforms/content_router.py
CHANGED
|
@@ -9,7 +9,7 @@ Supported Compressors:
|
|
| 9 |
- SmartCrusher: JSON arrays
|
| 10 |
- SearchCompressor: grep/ripgrep results
|
| 11 |
- LogCompressor: Build/test output
|
| 12 |
-
-
|
| 13 |
- Kompress: Plain text (ML-based, requires [ml] extra)
|
| 14 |
|
| 15 |
Routing Strategy:
|
|
@@ -251,7 +251,6 @@ class CompressionStrategy(Enum):
|
|
| 251 |
SEARCH = "search"
|
| 252 |
LOG = "log"
|
| 253 |
KOMPRESS = "kompress"
|
| 254 |
-
LLMLINGUA = "llmlingua"
|
| 255 |
TEXT = "text"
|
| 256 |
DIFF = "diff"
|
| 257 |
HTML = "html"
|
|
@@ -360,12 +359,11 @@ class ContentRouterConfig:
|
|
| 360 |
|
| 361 |
Attributes:
|
| 362 |
enable_code_aware: Enable AST-based code compression.
|
| 363 |
-
enable_llmlingua: Enable ML-based text compression.
|
| 364 |
enable_smart_crusher: Enable JSON array compression.
|
| 365 |
enable_search_compressor: Enable search result compression.
|
| 366 |
enable_log_compressor: Enable build/test log compression.
|
| 367 |
enable_image_optimizer: Enable image token optimization.
|
| 368 |
-
prefer_code_aware_for_code: Use CodeAware over
|
| 369 |
mixed_content_threshold: Min distinct types to consider "mixed".
|
| 370 |
min_section_tokens: Minimum tokens for a section to compress.
|
| 371 |
fallback_strategy: Strategy when no compressor matches.
|
|
@@ -376,8 +374,7 @@ class ContentRouterConfig:
|
|
| 376 |
|
| 377 |
# Enable/disable specific compressors
|
| 378 |
enable_code_aware: bool = True
|
| 379 |
-
enable_kompress: bool = True # Kompress: ModernBERT token compressor
|
| 380 |
-
enable_llmlingua: bool = True
|
| 381 |
enable_smart_crusher: bool = True
|
| 382 |
enable_search_compressor: bool = True
|
| 383 |
enable_log_compressor: bool = True
|
|
@@ -647,7 +644,6 @@ class ContentRouter(Transform):
|
|
| 647 |
self._diff_compressor: Any = None
|
| 648 |
self._html_extractor: Any = None
|
| 649 |
self._kompress: Any = None
|
| 650 |
-
self._llmlingua: Any = None
|
| 651 |
self._image_optimizer: Any = None
|
| 652 |
|
| 653 |
# TOIN integration for cross-strategy learning
|
|
@@ -813,7 +809,7 @@ class ContentRouter(Transform):
|
|
| 813 |
strategy == CompressionStrategy.CODE_AWARE
|
| 814 |
and not self.config.prefer_code_aware_for_code
|
| 815 |
):
|
| 816 |
-
strategy = CompressionStrategy.
|
| 817 |
|
| 818 |
return strategy
|
| 819 |
|
|
@@ -960,9 +956,11 @@ class ContentRouter(Transform):
|
|
| 960 |
result = compressor.compress(content, language=language, context=context)
|
| 961 |
compressed, compressed_tokens = result.compressed, result.compressed_tokens
|
| 962 |
if compressed is None:
|
| 963 |
-
# Fallback to
|
| 964 |
-
compressed, compressed_tokens = self.
|
| 965 |
-
|
|
|
|
|
|
|
| 966 |
|
| 967 |
elif strategy == CompressionStrategy.SMART_CRUSHER:
|
| 968 |
# SmartCrusher handles its own TOIN recording
|
|
@@ -1013,12 +1011,9 @@ class ContentRouter(Transform):
|
|
| 1013 |
elif strategy == CompressionStrategy.KOMPRESS:
|
| 1014 |
compressed, compressed_tokens = self._try_ml_compressor(content, context, question)
|
| 1015 |
|
| 1016 |
-
elif strategy == CompressionStrategy.LLMLINGUA:
|
| 1017 |
-
compressed, compressed_tokens = self._try_ml_compressor(content, context, question)
|
| 1018 |
-
|
| 1019 |
elif strategy == CompressionStrategy.TEXT:
|
| 1020 |
-
# Prefer ML compressor
|
| 1021 |
-
# Passes through unchanged if
|
| 1022 |
compressed, compressed_tokens = self._try_ml_compressor(content, context, question)
|
| 1023 |
|
| 1024 |
except Exception as e:
|
|
@@ -1043,7 +1038,7 @@ class ContentRouter(Transform):
|
|
| 1043 |
def _try_ml_compressor(
|
| 1044 |
self, content: str, context: str, question: str | None = None
|
| 1045 |
) -> tuple[str, int]:
|
| 1046 |
-
"""ML-based compression
|
| 1047 |
|
| 1048 |
Kompress (ModernBERT, trained on 330K structured tool outputs)
|
| 1049 |
auto-downloads from HuggingFace on first use. No heuristic fallback.
|
|
@@ -1090,19 +1085,6 @@ class ContentRouter(Transform):
|
|
| 1090 |
except Exception as e:
|
| 1091 |
logger.warning("Kompress failed: %s", e)
|
| 1092 |
|
| 1093 |
-
# Fallback: LLMLingua (only if Kompress not installed)
|
| 1094 |
-
if compressed is None and self.config.enable_llmlingua:
|
| 1095 |
-
compressor = self._get_llmlingua()
|
| 1096 |
-
if compressor:
|
| 1097 |
-
try:
|
| 1098 |
-
result = compressor.compress(
|
| 1099 |
-
text_to_compress, context=context, question=question
|
| 1100 |
-
)
|
| 1101 |
-
compressed = result.compressed
|
| 1102 |
-
compressed_tokens = result.compressed_tokens
|
| 1103 |
-
except Exception as e:
|
| 1104 |
-
logger.warning("LLMLingua failed: %s", e)
|
| 1105 |
-
|
| 1106 |
if compressed is None:
|
| 1107 |
return content, len(content.split())
|
| 1108 |
|
|
@@ -1113,9 +1095,6 @@ class ContentRouter(Transform):
|
|
| 1113 |
|
| 1114 |
return compressed, compressed_tokens or len(compressed.split())
|
| 1115 |
|
| 1116 |
-
# Backwards compatibility
|
| 1117 |
-
_try_llmlingua = _try_ml_compressor
|
| 1118 |
-
|
| 1119 |
def _strategy_from_detection_type(self, content_type: ContentType) -> CompressionStrategy:
|
| 1120 |
"""Get strategy from ContentType enum."""
|
| 1121 |
mapping = {
|
|
@@ -1140,7 +1119,6 @@ class ContentRouter(Transform):
|
|
| 1140 |
CompressionStrategy.HTML: ContentType.HTML,
|
| 1141 |
CompressionStrategy.TEXT: ContentType.PLAIN_TEXT,
|
| 1142 |
CompressionStrategy.KOMPRESS: ContentType.PLAIN_TEXT,
|
| 1143 |
-
CompressionStrategy.LLMLINGUA: ContentType.PLAIN_TEXT,
|
| 1144 |
CompressionStrategy.PASSTHROUGH: ContentType.PLAIN_TEXT,
|
| 1145 |
}
|
| 1146 |
return mapping.get(strategy, ContentType.PLAIN_TEXT)
|
|
@@ -1233,7 +1211,7 @@ class ContentRouter(Transform):
|
|
| 1233 |
"""
|
| 1234 |
status: dict[str, str] = {}
|
| 1235 |
|
| 1236 |
-
# 1. ML text compressor: Kompress
|
| 1237 |
if self.config.enable_kompress:
|
| 1238 |
compressor = self._get_kompress()
|
| 1239 |
if compressor:
|
|
@@ -1241,20 +1219,6 @@ class ContentRouter(Transform):
|
|
| 1241 |
status["kompress"] = "enabled"
|
| 1242 |
else:
|
| 1243 |
status["kompress"] = "unavailable"
|
| 1244 |
-
if "kompress" not in status or status["kompress"] != "enabled":
|
| 1245 |
-
if self.config.enable_llmlingua:
|
| 1246 |
-
compressor = self._get_llmlingua()
|
| 1247 |
-
if compressor:
|
| 1248 |
-
try:
|
| 1249 |
-
from .llmlingua_compressor import _get_llmlingua_compressor
|
| 1250 |
-
|
| 1251 |
-
device = compressor._resolve_device()
|
| 1252 |
-
_get_llmlingua_compressor(compressor.config.model_name, device)
|
| 1253 |
-
logger.info("LLMLingua model pre-loaded at startup")
|
| 1254 |
-
status["llmlingua"] = "enabled"
|
| 1255 |
-
except Exception as e:
|
| 1256 |
-
logger.warning("Failed to pre-load LLMLingua model: %s", e)
|
| 1257 |
-
status["llmlingua"] = f"failed: {e}"
|
| 1258 |
|
| 1259 |
# 2. Magika content detector (avoids 100-200ms on first content detection)
|
| 1260 |
try:
|
|
@@ -1326,21 +1290,6 @@ class ContentRouter(Transform):
|
|
| 1326 |
logger.debug("Kompress dependencies not available")
|
| 1327 |
return self._kompress
|
| 1328 |
|
| 1329 |
-
def _get_llmlingua(self) -> Any:
|
| 1330 |
-
"""Get LLMLinguaCompressor (lazy load). Fallback if Kompress unavailable."""
|
| 1331 |
-
if self._llmlingua is None:
|
| 1332 |
-
try:
|
| 1333 |
-
from .llmlingua_compressor import (
|
| 1334 |
-
LLMLinguaCompressor,
|
| 1335 |
-
_check_llmlingua_available,
|
| 1336 |
-
)
|
| 1337 |
-
|
| 1338 |
-
if _check_llmlingua_available():
|
| 1339 |
-
self._llmlingua = LLMLinguaCompressor()
|
| 1340 |
-
except ImportError:
|
| 1341 |
-
logger.debug("LLMLinguaCompressor not available")
|
| 1342 |
-
return self._llmlingua
|
| 1343 |
-
|
| 1344 |
def _get_image_optimizer(self) -> Any:
|
| 1345 |
"""Get ImageCompressor (lazy load).
|
| 1346 |
|
|
@@ -1558,7 +1507,7 @@ class ContentRouter(Transform):
|
|
| 1558 |
"non_string": 0,
|
| 1559 |
"content_blocks": 0,
|
| 1560 |
}
|
| 1561 |
-
compressed_details: list[str] = [] # e.g. ["code_aware:0.72", "
|
| 1562 |
|
| 1563 |
# Check for analysis intent in the most recent user message
|
| 1564 |
analysis_intent = False
|
|
|
|
| 9 |
- SmartCrusher: JSON arrays
|
| 10 |
- SearchCompressor: grep/ripgrep results
|
| 11 |
- LogCompressor: Build/test output
|
| 12 |
+
- KompressCompressor: Plain text (ML-based)
|
| 13 |
- Kompress: Plain text (ML-based, requires [ml] extra)
|
| 14 |
|
| 15 |
Routing Strategy:
|
|
|
|
| 251 |
SEARCH = "search"
|
| 252 |
LOG = "log"
|
| 253 |
KOMPRESS = "kompress"
|
|
|
|
| 254 |
TEXT = "text"
|
| 255 |
DIFF = "diff"
|
| 256 |
HTML = "html"
|
|
|
|
| 359 |
|
| 360 |
Attributes:
|
| 361 |
enable_code_aware: Enable AST-based code compression.
|
|
|
|
| 362 |
enable_smart_crusher: Enable JSON array compression.
|
| 363 |
enable_search_compressor: Enable search result compression.
|
| 364 |
enable_log_compressor: Enable build/test log compression.
|
| 365 |
enable_image_optimizer: Enable image token optimization.
|
| 366 |
+
prefer_code_aware_for_code: Use CodeAware over Kompress for code.
|
| 367 |
mixed_content_threshold: Min distinct types to consider "mixed".
|
| 368 |
min_section_tokens: Minimum tokens for a section to compress.
|
| 369 |
fallback_strategy: Strategy when no compressor matches.
|
|
|
|
| 374 |
|
| 375 |
# Enable/disable specific compressors
|
| 376 |
enable_code_aware: bool = True
|
| 377 |
+
enable_kompress: bool = True # Kompress: ModernBERT token compressor
|
|
|
|
| 378 |
enable_smart_crusher: bool = True
|
| 379 |
enable_search_compressor: bool = True
|
| 380 |
enable_log_compressor: bool = True
|
|
|
|
| 644 |
self._diff_compressor: Any = None
|
| 645 |
self._html_extractor: Any = None
|
| 646 |
self._kompress: Any = None
|
|
|
|
| 647 |
self._image_optimizer: Any = None
|
| 648 |
|
| 649 |
# TOIN integration for cross-strategy learning
|
|
|
|
| 809 |
strategy == CompressionStrategy.CODE_AWARE
|
| 810 |
and not self.config.prefer_code_aware_for_code
|
| 811 |
):
|
| 812 |
+
strategy = CompressionStrategy.KOMPRESS
|
| 813 |
|
| 814 |
return strategy
|
| 815 |
|
|
|
|
| 956 |
result = compressor.compress(content, language=language, context=context)
|
| 957 |
compressed, compressed_tokens = result.compressed, result.compressed_tokens
|
| 958 |
if compressed is None:
|
| 959 |
+
# Fallback to Kompress
|
| 960 |
+
compressed, compressed_tokens = self._try_ml_compressor(
|
| 961 |
+
content, context, question
|
| 962 |
+
)
|
| 963 |
+
strategy = CompressionStrategy.KOMPRESS # Update for TOIN
|
| 964 |
|
| 965 |
elif strategy == CompressionStrategy.SMART_CRUSHER:
|
| 966 |
# SmartCrusher handles its own TOIN recording
|
|
|
|
| 1011 |
elif strategy == CompressionStrategy.KOMPRESS:
|
| 1012 |
compressed, compressed_tokens = self._try_ml_compressor(content, context, question)
|
| 1013 |
|
|
|
|
|
|
|
|
|
|
| 1014 |
elif strategy == CompressionStrategy.TEXT:
|
| 1015 |
+
# Prefer Kompress ML compressor for text
|
| 1016 |
+
# Passes through unchanged if Kompress not available
|
| 1017 |
compressed, compressed_tokens = self._try_ml_compressor(content, context, question)
|
| 1018 |
|
| 1019 |
except Exception as e:
|
|
|
|
| 1038 |
def _try_ml_compressor(
|
| 1039 |
self, content: str, context: str, question: str | None = None
|
| 1040 |
) -> tuple[str, int]:
|
| 1041 |
+
"""ML-based compression using Kompress.
|
| 1042 |
|
| 1043 |
Kompress (ModernBERT, trained on 330K structured tool outputs)
|
| 1044 |
auto-downloads from HuggingFace on first use. No heuristic fallback.
|
|
|
|
| 1085 |
except Exception as e:
|
| 1086 |
logger.warning("Kompress failed: %s", e)
|
| 1087 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1088 |
if compressed is None:
|
| 1089 |
return content, len(content.split())
|
| 1090 |
|
|
|
|
| 1095 |
|
| 1096 |
return compressed, compressed_tokens or len(compressed.split())
|
| 1097 |
|
|
|
|
|
|
|
|
|
|
| 1098 |
def _strategy_from_detection_type(self, content_type: ContentType) -> CompressionStrategy:
|
| 1099 |
"""Get strategy from ContentType enum."""
|
| 1100 |
mapping = {
|
|
|
|
| 1119 |
CompressionStrategy.HTML: ContentType.HTML,
|
| 1120 |
CompressionStrategy.TEXT: ContentType.PLAIN_TEXT,
|
| 1121 |
CompressionStrategy.KOMPRESS: ContentType.PLAIN_TEXT,
|
|
|
|
| 1122 |
CompressionStrategy.PASSTHROUGH: ContentType.PLAIN_TEXT,
|
| 1123 |
}
|
| 1124 |
return mapping.get(strategy, ContentType.PLAIN_TEXT)
|
|
|
|
| 1211 |
"""
|
| 1212 |
status: dict[str, str] = {}
|
| 1213 |
|
| 1214 |
+
# 1. ML text compressor: Kompress
|
| 1215 |
if self.config.enable_kompress:
|
| 1216 |
compressor = self._get_kompress()
|
| 1217 |
if compressor:
|
|
|
|
| 1219 |
status["kompress"] = "enabled"
|
| 1220 |
else:
|
| 1221 |
status["kompress"] = "unavailable"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1222 |
|
| 1223 |
# 2. Magika content detector (avoids 100-200ms on first content detection)
|
| 1224 |
try:
|
|
|
|
| 1290 |
logger.debug("Kompress dependencies not available")
|
| 1291 |
return self._kompress
|
| 1292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1293 |
def _get_image_optimizer(self) -> Any:
|
| 1294 |
"""Get ImageCompressor (lazy load).
|
| 1295 |
|
|
|
|
| 1507 |
"non_string": 0,
|
| 1508 |
"content_blocks": 0,
|
| 1509 |
}
|
| 1510 |
+
compressed_details: list[str] = [] # e.g. ["code_aware:0.72", "kompress:0.65"]
|
| 1511 |
|
| 1512 |
# Check for analysis intent in the most recent user message
|
| 1513 |
analysis_intent = False
|
headroom/transforms/intelligent_context.py
CHANGED
|
@@ -501,7 +501,6 @@ class IntelligentContextManager(Transform):
|
|
| 501 |
# Configure for aggressive compression in COMPRESS_FIRST context
|
| 502 |
router_config = ContentRouterConfig(
|
| 503 |
enable_code_aware=True,
|
| 504 |
-
enable_llmlingua=True,
|
| 505 |
enable_smart_crusher=True,
|
| 506 |
enable_search_compressor=True,
|
| 507 |
enable_log_compressor=True,
|
|
|
|
| 501 |
# Configure for aggressive compression in COMPRESS_FIRST context
|
| 502 |
router_config = ContentRouterConfig(
|
| 503 |
enable_code_aware=True,
|
|
|
|
| 504 |
enable_smart_crusher=True,
|
| 505 |
enable_search_compressor=True,
|
| 506 |
enable_log_compressor=True,
|
headroom/transforms/kompress_compressor.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Kompress: ModernBERT token compressor for structured tool outputs.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
|
| 6 |
Requires the [ml] extra: pip install headroom-ai[ml]
|
| 7 |
|
|
@@ -222,7 +222,6 @@ class KompressCompressor(Transform):
|
|
| 222 |
"""Kompress: ModernBERT token compressor for structured tool outputs.
|
| 223 |
|
| 224 |
Auto-downloads chopratejas/kompress-base from HuggingFace on first use.
|
| 225 |
-
Drop-in replacement for LLMLinguaCompressor with identical interface.
|
| 226 |
"""
|
| 227 |
|
| 228 |
name: str = "kompress_compressor"
|
|
@@ -242,9 +241,9 @@ class KompressCompressor(Transform):
|
|
| 242 |
|
| 243 |
Args:
|
| 244 |
content: Text to compress.
|
| 245 |
-
context: Optional surrounding context (unused by model
|
| 246 |
content_type: Ignored — model decides importance per content type.
|
| 247 |
-
question: Ignored —
|
| 248 |
target_ratio: If None (default), model decides how much to keep using
|
| 249 |
score threshold. If set (e.g. 0.3), forces that keep ratio.
|
| 250 |
The proxy never sets this — only user-facing API does.
|
|
|
|
| 1 |
"""Kompress: ModernBERT token compressor for structured tool outputs.
|
| 2 |
|
| 3 |
+
Auto-downloads the model from HuggingFace (chopratejas/kompress-base)
|
| 4 |
+
on first use.
|
| 5 |
|
| 6 |
Requires the [ml] extra: pip install headroom-ai[ml]
|
| 7 |
|
|
|
|
| 222 |
"""Kompress: ModernBERT token compressor for structured tool outputs.
|
| 223 |
|
| 224 |
Auto-downloads chopratejas/kompress-base from HuggingFace on first use.
|
|
|
|
| 225 |
"""
|
| 226 |
|
| 227 |
name: str = "kompress_compressor"
|
|
|
|
| 241 |
|
| 242 |
Args:
|
| 243 |
content: Text to compress.
|
| 244 |
+
context: Optional surrounding context (unused by model).
|
| 245 |
content_type: Ignored — model decides importance per content type.
|
| 246 |
+
question: Ignored — reserved for future QA-aware compression.
|
| 247 |
target_ratio: If None (default), model decides how much to keep using
|
| 248 |
score threshold. If set (e.g. 0.3), forces that keep ratio.
|
| 249 |
The proxy never sets this — only user-facing API does.
|
headroom/transforms/llmlingua_compressor.py
DELETED
|
@@ -1,652 +0,0 @@
|
|
| 1 |
-
"""LLMLingua-2 compressor for ML-based prompt compression.
|
| 2 |
-
|
| 3 |
-
This module provides integration with LLMLingua-2, a BERT-based token classifier
|
| 4 |
-
trained via GPT-4 distillation. It achieves superior compression (up to 20x)
|
| 5 |
-
while maintaining high fidelity on tool outputs and structured content.
|
| 6 |
-
|
| 7 |
-
Key Features:
|
| 8 |
-
- Token-level classification (keep/remove) using fine-tuned BERT
|
| 9 |
-
- 3-6x faster than LLMLingua-1 with better results
|
| 10 |
-
- Especially effective on tool outputs, code, and structured data
|
| 11 |
-
- Reversible compression via CCR integration
|
| 12 |
-
|
| 13 |
-
Reference:
|
| 14 |
-
LLMLingua-2: Data Distillation for Efficient and Faithful Task-Agnostic Prompt Compression
|
| 15 |
-
https://arxiv.org/abs/2403.12968
|
| 16 |
-
|
| 17 |
-
Installation:
|
| 18 |
-
pip install headroom-ai[llmlingua]
|
| 19 |
-
|
| 20 |
-
Usage:
|
| 21 |
-
>>> from headroom.transforms import LLMLinguaCompressor
|
| 22 |
-
>>> compressor = LLMLinguaCompressor()
|
| 23 |
-
>>> result = compressor.compress(long_tool_output)
|
| 24 |
-
>>> print(result.compressed) # Significantly reduced output
|
| 25 |
-
"""
|
| 26 |
-
|
| 27 |
-
from __future__ import annotations
|
| 28 |
-
|
| 29 |
-
import logging
|
| 30 |
-
import threading
|
| 31 |
-
from dataclasses import dataclass, field
|
| 32 |
-
from typing import Any
|
| 33 |
-
|
| 34 |
-
from ..config import TransformResult
|
| 35 |
-
from ..models.config import ML_MODEL_DEFAULTS
|
| 36 |
-
from ..tokenizer import Tokenizer
|
| 37 |
-
from .base import Transform
|
| 38 |
-
|
| 39 |
-
logger = logging.getLogger(__name__)
|
| 40 |
-
|
| 41 |
-
# Lazy import for optional dependency
|
| 42 |
-
_llmlingua_available: bool | None = None
|
| 43 |
-
_llmlingua_instance: Any = None
|
| 44 |
-
_llmlingua_lock = threading.Lock() # Thread safety for model access
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _check_llmlingua_available() -> bool:
|
| 48 |
-
"""Check if llmlingua package is available."""
|
| 49 |
-
global _llmlingua_available
|
| 50 |
-
if _llmlingua_available is None:
|
| 51 |
-
try:
|
| 52 |
-
import llmlingua # noqa: F401
|
| 53 |
-
|
| 54 |
-
_llmlingua_available = True
|
| 55 |
-
except ImportError:
|
| 56 |
-
_llmlingua_available = False
|
| 57 |
-
return _llmlingua_available
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def _get_llmlingua_compressor(model_name: str, device: str) -> Any:
|
| 61 |
-
"""Get or create the LLMLingua compressor instance.
|
| 62 |
-
|
| 63 |
-
Uses lazy initialization and caches the instance to avoid repeated model loading.
|
| 64 |
-
Thread-safe: uses lock to prevent race conditions during model initialization.
|
| 65 |
-
|
| 66 |
-
Args:
|
| 67 |
-
model_name: HuggingFace model name for the compressor.
|
| 68 |
-
device: Device to run the model on ('cuda', 'cpu', or 'auto').
|
| 69 |
-
|
| 70 |
-
Returns:
|
| 71 |
-
PromptCompressor instance from llmlingua.
|
| 72 |
-
|
| 73 |
-
Raises:
|
| 74 |
-
ImportError: If llmlingua is not installed.
|
| 75 |
-
RuntimeError: If model loading fails.
|
| 76 |
-
"""
|
| 77 |
-
global _llmlingua_instance
|
| 78 |
-
|
| 79 |
-
if not _check_llmlingua_available():
|
| 80 |
-
raise ImportError(
|
| 81 |
-
"llmlingua is not installed. Install with: pip install headroom-ai[llmlingua]\n"
|
| 82 |
-
"Note: This requires ~2GB of disk space and ~1GB RAM for the model."
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
with _llmlingua_lock:
|
| 86 |
-
# Double-check after acquiring lock
|
| 87 |
-
if _llmlingua_instance is None or _llmlingua_instance._model_name != model_name:
|
| 88 |
-
try:
|
| 89 |
-
from llmlingua import PromptCompressor
|
| 90 |
-
|
| 91 |
-
logger.info(
|
| 92 |
-
"Loading LLMLingua-2 model: %s on device: %s "
|
| 93 |
-
"(this may take 10-30s on first run)",
|
| 94 |
-
model_name,
|
| 95 |
-
device,
|
| 96 |
-
)
|
| 97 |
-
_llmlingua_instance = PromptCompressor(
|
| 98 |
-
model_name=model_name,
|
| 99 |
-
device_map=device,
|
| 100 |
-
use_llmlingua2=True, # Use LLMLingua-2 (BERT classifier)
|
| 101 |
-
)
|
| 102 |
-
# Store model name for later comparison
|
| 103 |
-
_llmlingua_instance._model_name = model_name
|
| 104 |
-
logger.info("LLMLingua-2 model loaded successfully")
|
| 105 |
-
|
| 106 |
-
except Exception as e:
|
| 107 |
-
error_msg = str(e).lower()
|
| 108 |
-
if "out of memory" in error_msg or "oom" in error_msg:
|
| 109 |
-
raise RuntimeError(
|
| 110 |
-
f"Out of memory loading LLMLingua model. Try:\n"
|
| 111 |
-
f" 1. Use device='cpu' instead of 'cuda'\n"
|
| 112 |
-
f" 2. Close other GPU applications\n"
|
| 113 |
-
f" 3. Use a smaller model\n"
|
| 114 |
-
f"Original error: {e}"
|
| 115 |
-
) from e
|
| 116 |
-
elif "not found" in error_msg or "404" in error_msg:
|
| 117 |
-
raise RuntimeError(
|
| 118 |
-
f"Model '{model_name}' not found on HuggingFace. Try:\n"
|
| 119 |
-
f" 1. Check the model name is correct\n"
|
| 120 |
-
f" 2. Use default: 'microsoft/llmlingua-2-xlm-roberta-large-meetingbank'\n"
|
| 121 |
-
f"Original error: {e}"
|
| 122 |
-
) from e
|
| 123 |
-
else:
|
| 124 |
-
raise RuntimeError(
|
| 125 |
-
f"Failed to load LLMLingua model: {e}\n"
|
| 126 |
-
f"Ensure you have sufficient disk space and memory."
|
| 127 |
-
) from e
|
| 128 |
-
|
| 129 |
-
return _llmlingua_instance
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def unload_llmlingua_model() -> bool:
|
| 133 |
-
"""Unload the LLMLingua model to free memory.
|
| 134 |
-
|
| 135 |
-
Use this when you're done with compression and want to reclaim GPU/CPU memory.
|
| 136 |
-
The model will be reloaded automatically on the next compression call.
|
| 137 |
-
|
| 138 |
-
Returns:
|
| 139 |
-
True if a model was unloaded, False if no model was loaded.
|
| 140 |
-
|
| 141 |
-
Example:
|
| 142 |
-
>>> from headroom.transforms import LLMLinguaCompressor, unload_llmlingua_model
|
| 143 |
-
>>> compressor = LLMLinguaCompressor()
|
| 144 |
-
>>> result = compressor.compress(content) # Model loaded here
|
| 145 |
-
>>> # ... do other work ...
|
| 146 |
-
>>> unload_llmlingua_model() # Free ~1GB of memory
|
| 147 |
-
"""
|
| 148 |
-
global _llmlingua_instance
|
| 149 |
-
|
| 150 |
-
with _llmlingua_lock:
|
| 151 |
-
if _llmlingua_instance is not None:
|
| 152 |
-
model_name = getattr(_llmlingua_instance, "_model_name", "unknown")
|
| 153 |
-
logger.info("Unloading LLMLingua model: %s", model_name)
|
| 154 |
-
|
| 155 |
-
# Clear the instance
|
| 156 |
-
_llmlingua_instance = None
|
| 157 |
-
|
| 158 |
-
# Attempt to free GPU memory if torch is available
|
| 159 |
-
try:
|
| 160 |
-
import torch
|
| 161 |
-
|
| 162 |
-
if torch.cuda.is_available():
|
| 163 |
-
torch.cuda.empty_cache()
|
| 164 |
-
logger.debug("Cleared CUDA cache")
|
| 165 |
-
except ImportError:
|
| 166 |
-
pass
|
| 167 |
-
|
| 168 |
-
return True
|
| 169 |
-
|
| 170 |
-
return False
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def is_llmlingua_model_loaded() -> bool:
|
| 174 |
-
"""Check if an LLMLingua model is currently loaded.
|
| 175 |
-
|
| 176 |
-
Returns:
|
| 177 |
-
True if a model is loaded in memory, False otherwise.
|
| 178 |
-
"""
|
| 179 |
-
return _llmlingua_instance is not None
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
@dataclass
|
| 183 |
-
class LLMLinguaConfig:
|
| 184 |
-
"""Configuration for LLMLingua-2 compression.
|
| 185 |
-
|
| 186 |
-
Attributes:
|
| 187 |
-
model_name: HuggingFace model for the compressor. Default is the
|
| 188 |
-
LLMLingua-2 xlm-roberta-large model fine-tuned for compression.
|
| 189 |
-
device: Device to run on ('cuda', 'cpu', 'auto'). Auto will use CUDA if available.
|
| 190 |
-
target_compression_rate: Target compression ratio (e.g., 0.3 = keep 30% of tokens).
|
| 191 |
-
force_tokens: Tokens to always preserve (e.g., important keywords).
|
| 192 |
-
drop_consecutive: Whether to drop consecutive punctuation/whitespace.
|
| 193 |
-
min_tokens_for_compression: Minimum token count to trigger compression.
|
| 194 |
-
Content below this threshold is passed through unchanged.
|
| 195 |
-
enable_ccr: Whether to store originals in CCR for retrieval.
|
| 196 |
-
ccr_ttl: TTL for CCR entries in seconds.
|
| 197 |
-
|
| 198 |
-
GOTCHA: Lower target_compression_rate = more aggressive compression.
|
| 199 |
-
A rate of 0.2 means keeping only 20% of tokens.
|
| 200 |
-
"""
|
| 201 |
-
|
| 202 |
-
# Model configuration
|
| 203 |
-
model_name: str = field(default_factory=lambda: ML_MODEL_DEFAULTS.llmlingua)
|
| 204 |
-
device: str = "auto"
|
| 205 |
-
|
| 206 |
-
# Compression parameters
|
| 207 |
-
target_compression_rate: float = 0.3
|
| 208 |
-
force_tokens: list[str] = field(default_factory=list)
|
| 209 |
-
drop_consecutive: bool = True
|
| 210 |
-
|
| 211 |
-
# Thresholds
|
| 212 |
-
min_tokens_for_compression: int = 100
|
| 213 |
-
|
| 214 |
-
# CCR integration
|
| 215 |
-
enable_ccr: bool = True
|
| 216 |
-
ccr_ttl: int = 300 # 5 minutes
|
| 217 |
-
|
| 218 |
-
# Content type specific settings
|
| 219 |
-
code_compression_rate: float = 0.5 # Conservative for code
|
| 220 |
-
json_compression_rate: float = 0.4 # Somewhat conservative for JSON
|
| 221 |
-
text_compression_rate: float = 0.5 # Balanced for plain text (higher = more accurate)
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
@dataclass
|
| 225 |
-
class LLMLinguaResult:
|
| 226 |
-
"""Result of LLMLingua-2 compression.
|
| 227 |
-
|
| 228 |
-
Attributes:
|
| 229 |
-
compressed: Compressed content.
|
| 230 |
-
original: Original content before compression.
|
| 231 |
-
original_tokens: Token count of original content.
|
| 232 |
-
compressed_tokens: Token count after compression.
|
| 233 |
-
compression_ratio: Actual compression ratio achieved.
|
| 234 |
-
cache_key: CCR cache key if stored.
|
| 235 |
-
model_used: Model that performed the compression.
|
| 236 |
-
tokens_saved: Number of tokens saved.
|
| 237 |
-
"""
|
| 238 |
-
|
| 239 |
-
compressed: str
|
| 240 |
-
original: str
|
| 241 |
-
original_tokens: int
|
| 242 |
-
compressed_tokens: int
|
| 243 |
-
compression_ratio: float
|
| 244 |
-
cache_key: str | None = None
|
| 245 |
-
model_used: str | None = None
|
| 246 |
-
|
| 247 |
-
@property
|
| 248 |
-
def tokens_saved(self) -> int:
|
| 249 |
-
"""Number of tokens saved by compression."""
|
| 250 |
-
return max(0, self.original_tokens - self.compressed_tokens)
|
| 251 |
-
|
| 252 |
-
@property
|
| 253 |
-
def savings_percentage(self) -> float:
|
| 254 |
-
"""Percentage of tokens saved."""
|
| 255 |
-
if self.original_tokens == 0:
|
| 256 |
-
return 0.0
|
| 257 |
-
return (self.tokens_saved / self.original_tokens) * 100
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
class LLMLinguaCompressor(Transform):
|
| 261 |
-
"""LLMLingua-2 based prompt compressor.
|
| 262 |
-
|
| 263 |
-
Uses a BERT-based token classifier trained via GPT-4 distillation to
|
| 264 |
-
identify and remove non-essential tokens while preserving semantic meaning.
|
| 265 |
-
|
| 266 |
-
Key advantages over statistical compression:
|
| 267 |
-
- Learned token importance from LLM feedback
|
| 268 |
-
- Better handling of context-dependent importance
|
| 269 |
-
- More aggressive compression with less information loss
|
| 270 |
-
- Especially effective on structured outputs (JSON, code, logs)
|
| 271 |
-
|
| 272 |
-
Example:
|
| 273 |
-
>>> compressor = LLMLinguaCompressor()
|
| 274 |
-
>>> result = compressor.compress(long_tool_output)
|
| 275 |
-
>>> print(f"Saved {result.tokens_saved} tokens ({result.savings_percentage:.1f}%)")
|
| 276 |
-
|
| 277 |
-
>>> # Use as a Transform in pipeline
|
| 278 |
-
>>> from headroom.transforms import TransformPipeline
|
| 279 |
-
>>> pipeline = TransformPipeline([LLMLinguaCompressor()])
|
| 280 |
-
>>> result = pipeline.apply(messages, tokenizer)
|
| 281 |
-
"""
|
| 282 |
-
|
| 283 |
-
name: str = "llmlingua_compressor"
|
| 284 |
-
|
| 285 |
-
def __init__(self, config: LLMLinguaConfig | None = None):
|
| 286 |
-
"""Initialize LLMLingua compressor.
|
| 287 |
-
|
| 288 |
-
Args:
|
| 289 |
-
config: Compression configuration. If None, uses defaults.
|
| 290 |
-
|
| 291 |
-
Note:
|
| 292 |
-
The underlying model is loaded lazily on first use to avoid
|
| 293 |
-
startup overhead when the compressor isn't used.
|
| 294 |
-
"""
|
| 295 |
-
self.config = config or LLMLinguaConfig()
|
| 296 |
-
self._compressor: Any = None # Lazy loaded
|
| 297 |
-
|
| 298 |
-
def compress(
|
| 299 |
-
self,
|
| 300 |
-
content: str,
|
| 301 |
-
context: str = "",
|
| 302 |
-
content_type: str | None = None,
|
| 303 |
-
question: str | None = None,
|
| 304 |
-
) -> LLMLinguaResult:
|
| 305 |
-
"""Compress content using LLMLingua-2.
|
| 306 |
-
|
| 307 |
-
Args:
|
| 308 |
-
content: Content to compress.
|
| 309 |
-
context: Optional context for relevance-aware compression.
|
| 310 |
-
content_type: Type of content ('code', 'json', 'text').
|
| 311 |
-
If None, auto-detected.
|
| 312 |
-
question: Optional question for QA-aware compression. When provided,
|
| 313 |
-
LLMLingua preserves tokens relevant to answering this question.
|
| 314 |
-
This significantly improves accuracy for QA tasks.
|
| 315 |
-
|
| 316 |
-
Returns:
|
| 317 |
-
LLMLinguaResult with compressed content and metadata.
|
| 318 |
-
|
| 319 |
-
Raises:
|
| 320 |
-
ImportError: If llmlingua is not installed.
|
| 321 |
-
"""
|
| 322 |
-
# Check availability
|
| 323 |
-
if not _check_llmlingua_available():
|
| 324 |
-
logger.warning(
|
| 325 |
-
"LLMLingua not available. Install with: pip install headroom-ai[llmlingua]"
|
| 326 |
-
)
|
| 327 |
-
return LLMLinguaResult(
|
| 328 |
-
compressed=content,
|
| 329 |
-
original=content,
|
| 330 |
-
original_tokens=len(content.split()), # Rough estimate
|
| 331 |
-
compressed_tokens=len(content.split()),
|
| 332 |
-
compression_ratio=1.0,
|
| 333 |
-
)
|
| 334 |
-
|
| 335 |
-
# Estimate token count (rough)
|
| 336 |
-
estimated_tokens = len(content.split())
|
| 337 |
-
|
| 338 |
-
# Skip compression for small content
|
| 339 |
-
if estimated_tokens < self.config.min_tokens_for_compression:
|
| 340 |
-
return LLMLinguaResult(
|
| 341 |
-
compressed=content,
|
| 342 |
-
original=content,
|
| 343 |
-
original_tokens=estimated_tokens,
|
| 344 |
-
compressed_tokens=estimated_tokens,
|
| 345 |
-
compression_ratio=1.0,
|
| 346 |
-
)
|
| 347 |
-
|
| 348 |
-
# Get compression rate based on content type
|
| 349 |
-
compression_rate = self._get_compression_rate(content, content_type)
|
| 350 |
-
|
| 351 |
-
# Get or initialize compressor
|
| 352 |
-
device = self._resolve_device()
|
| 353 |
-
compressor = _get_llmlingua_compressor(self.config.model_name, device)
|
| 354 |
-
|
| 355 |
-
# Prepare force tokens
|
| 356 |
-
force_tokens = list(self.config.force_tokens)
|
| 357 |
-
|
| 358 |
-
# Add context words as force tokens if provided
|
| 359 |
-
if context:
|
| 360 |
-
context_words = [w for w in context.split() if len(w) > 3]
|
| 361 |
-
force_tokens.extend(context_words[:10]) # Limit to avoid overhead
|
| 362 |
-
|
| 363 |
-
# Perform compression
|
| 364 |
-
try:
|
| 365 |
-
# Build compress_prompt kwargs
|
| 366 |
-
compress_kwargs: dict[str, Any] = {
|
| 367 |
-
"context": [content], # LLMLingua expects a list of context strings
|
| 368 |
-
"rate": compression_rate,
|
| 369 |
-
"force_tokens": force_tokens if force_tokens else [],
|
| 370 |
-
"drop_consecutive": self.config.drop_consecutive,
|
| 371 |
-
}
|
| 372 |
-
|
| 373 |
-
# Add question for QA-aware token selection (LLMLingua-2 feature)
|
| 374 |
-
# This enables relevance-aware compression where tokens relevant
|
| 375 |
-
# to answering the question are preserved with higher probability
|
| 376 |
-
if question:
|
| 377 |
-
compress_kwargs["question"] = question
|
| 378 |
-
|
| 379 |
-
result = compressor.compress_prompt(**compress_kwargs)
|
| 380 |
-
|
| 381 |
-
compressed = result.get("compressed_prompt", content)
|
| 382 |
-
original_tokens = result.get("origin_tokens", estimated_tokens)
|
| 383 |
-
compressed_tokens = result.get("compressed_tokens", len(compressed.split()))
|
| 384 |
-
|
| 385 |
-
except Exception as e:
|
| 386 |
-
logger.warning("LLMLingua compression failed: %s", e)
|
| 387 |
-
return LLMLinguaResult(
|
| 388 |
-
compressed=content,
|
| 389 |
-
original=content,
|
| 390 |
-
original_tokens=estimated_tokens,
|
| 391 |
-
compressed_tokens=estimated_tokens,
|
| 392 |
-
compression_ratio=1.0,
|
| 393 |
-
)
|
| 394 |
-
|
| 395 |
-
# Calculate actual ratio
|
| 396 |
-
ratio = compressed_tokens / max(original_tokens, 1)
|
| 397 |
-
|
| 398 |
-
# Store in CCR if enabled
|
| 399 |
-
cache_key = None
|
| 400 |
-
if self.config.enable_ccr and ratio < 0.8:
|
| 401 |
-
cache_key = self._store_in_ccr(content, compressed, original_tokens)
|
| 402 |
-
if cache_key:
|
| 403 |
-
# Use standard CCR marker format for CCRToolInjector detection
|
| 404 |
-
compressed += f"\n[{original_tokens} items compressed to {compressed_tokens}. Retrieve more: hash={cache_key}]"
|
| 405 |
-
|
| 406 |
-
return LLMLinguaResult(
|
| 407 |
-
compressed=compressed,
|
| 408 |
-
original=content,
|
| 409 |
-
original_tokens=original_tokens,
|
| 410 |
-
compressed_tokens=compressed_tokens,
|
| 411 |
-
compression_ratio=ratio,
|
| 412 |
-
cache_key=cache_key,
|
| 413 |
-
model_used=self.config.model_name,
|
| 414 |
-
)
|
| 415 |
-
|
| 416 |
-
def apply(
|
| 417 |
-
self,
|
| 418 |
-
messages: list[dict[str, Any]],
|
| 419 |
-
tokenizer: Tokenizer,
|
| 420 |
-
**kwargs: Any,
|
| 421 |
-
) -> TransformResult:
|
| 422 |
-
"""Apply LLMLingua compression to messages.
|
| 423 |
-
|
| 424 |
-
This method implements the Transform interface for use in pipelines.
|
| 425 |
-
It compresses tool outputs and long assistant/user messages.
|
| 426 |
-
|
| 427 |
-
Args:
|
| 428 |
-
messages: List of message dicts to transform.
|
| 429 |
-
tokenizer: Tokenizer for accurate token counting.
|
| 430 |
-
**kwargs: Additional arguments (e.g., 'context' for relevance).
|
| 431 |
-
|
| 432 |
-
Returns:
|
| 433 |
-
TransformResult with compressed messages and metadata.
|
| 434 |
-
"""
|
| 435 |
-
tokens_before = sum(tokenizer.count_text(str(m.get("content", ""))) for m in messages)
|
| 436 |
-
context = kwargs.get("context", "")
|
| 437 |
-
|
| 438 |
-
transformed_messages = []
|
| 439 |
-
transforms_applied = []
|
| 440 |
-
warnings: list[str] = []
|
| 441 |
-
|
| 442 |
-
for message in messages:
|
| 443 |
-
role = message.get("role", "")
|
| 444 |
-
content = message.get("content", "")
|
| 445 |
-
|
| 446 |
-
# Skip non-string content (multimodal messages with images)
|
| 447 |
-
if not isinstance(content, str):
|
| 448 |
-
transformed_messages.append(message)
|
| 449 |
-
continue
|
| 450 |
-
|
| 451 |
-
# Compress tool results (highest value compression)
|
| 452 |
-
if role == "tool" and content:
|
| 453 |
-
result = self.compress(content, context=context, content_type="json")
|
| 454 |
-
if result.compression_ratio < 0.9:
|
| 455 |
-
transformed_messages.append({**message, "content": result.compressed})
|
| 456 |
-
transforms_applied.append(f"llmlingua:tool:{result.compression_ratio:.2f}")
|
| 457 |
-
else:
|
| 458 |
-
transformed_messages.append(message)
|
| 459 |
-
|
| 460 |
-
# Compress long assistant messages (tool outputs often embedded)
|
| 461 |
-
elif role == "assistant" and len(content) > 500:
|
| 462 |
-
result = self.compress(content, context=context)
|
| 463 |
-
if result.compression_ratio < 0.9:
|
| 464 |
-
transformed_messages.append({**message, "content": result.compressed})
|
| 465 |
-
transforms_applied.append(f"llmlingua:assistant:{result.compression_ratio:.2f}")
|
| 466 |
-
else:
|
| 467 |
-
transformed_messages.append(message)
|
| 468 |
-
|
| 469 |
-
# Pass through other messages
|
| 470 |
-
else:
|
| 471 |
-
transformed_messages.append(message)
|
| 472 |
-
|
| 473 |
-
tokens_after = sum(
|
| 474 |
-
tokenizer.count_text(str(m.get("content", ""))) for m in transformed_messages
|
| 475 |
-
)
|
| 476 |
-
|
| 477 |
-
# Add warning if llmlingua not available
|
| 478 |
-
if not _check_llmlingua_available():
|
| 479 |
-
warnings.append(
|
| 480 |
-
"LLMLingua not installed. Install with: pip install headroom-ai[llmlingua]"
|
| 481 |
-
)
|
| 482 |
-
|
| 483 |
-
return TransformResult(
|
| 484 |
-
messages=transformed_messages,
|
| 485 |
-
tokens_before=tokens_before,
|
| 486 |
-
tokens_after=tokens_after,
|
| 487 |
-
transforms_applied=transforms_applied if transforms_applied else ["llmlingua:noop"],
|
| 488 |
-
warnings=warnings,
|
| 489 |
-
)
|
| 490 |
-
|
| 491 |
-
def should_apply(
|
| 492 |
-
self,
|
| 493 |
-
messages: list[dict[str, Any]],
|
| 494 |
-
tokenizer: Tokenizer,
|
| 495 |
-
**kwargs: Any,
|
| 496 |
-
) -> bool:
|
| 497 |
-
"""Check if LLMLingua compression should be applied.
|
| 498 |
-
|
| 499 |
-
Returns True if:
|
| 500 |
-
- LLMLingua is available, AND
|
| 501 |
-
- Total token count exceeds minimum threshold
|
| 502 |
-
|
| 503 |
-
Args:
|
| 504 |
-
messages: Messages to check.
|
| 505 |
-
tokenizer: Tokenizer for counting.
|
| 506 |
-
**kwargs: Additional arguments.
|
| 507 |
-
|
| 508 |
-
Returns:
|
| 509 |
-
True if compression should be applied.
|
| 510 |
-
"""
|
| 511 |
-
if not _check_llmlingua_available():
|
| 512 |
-
return False
|
| 513 |
-
|
| 514 |
-
total_tokens = sum(tokenizer.count_text(str(m.get("content", ""))) for m in messages)
|
| 515 |
-
return total_tokens >= self.config.min_tokens_for_compression
|
| 516 |
-
|
| 517 |
-
def _get_compression_rate(
|
| 518 |
-
self,
|
| 519 |
-
content: str,
|
| 520 |
-
content_type: str | None,
|
| 521 |
-
) -> float:
|
| 522 |
-
"""Get appropriate compression rate based on content type.
|
| 523 |
-
|
| 524 |
-
Args:
|
| 525 |
-
content: Content to analyze.
|
| 526 |
-
content_type: Explicit content type or None for auto-detection.
|
| 527 |
-
|
| 528 |
-
Returns:
|
| 529 |
-
Target compression rate for this content.
|
| 530 |
-
"""
|
| 531 |
-
if content_type == "code":
|
| 532 |
-
return self.config.code_compression_rate
|
| 533 |
-
elif content_type == "json":
|
| 534 |
-
return self.config.json_compression_rate
|
| 535 |
-
elif content_type == "text":
|
| 536 |
-
return self.config.text_compression_rate
|
| 537 |
-
|
| 538 |
-
# Auto-detect content type
|
| 539 |
-
if self._looks_like_json(content):
|
| 540 |
-
return self.config.json_compression_rate
|
| 541 |
-
elif self._looks_like_code(content):
|
| 542 |
-
return self.config.code_compression_rate
|
| 543 |
-
else:
|
| 544 |
-
return self.config.text_compression_rate
|
| 545 |
-
|
| 546 |
-
def _looks_like_json(self, content: str) -> bool:
|
| 547 |
-
"""Check if content appears to be JSON."""
|
| 548 |
-
stripped = content.strip()
|
| 549 |
-
return (stripped.startswith("{") and stripped.endswith("}")) or (
|
| 550 |
-
stripped.startswith("[") and stripped.endswith("]")
|
| 551 |
-
)
|
| 552 |
-
|
| 553 |
-
def _looks_like_code(self, content: str) -> bool:
|
| 554 |
-
"""Check if content appears to be code."""
|
| 555 |
-
code_indicators = [
|
| 556 |
-
"def ",
|
| 557 |
-
"class ",
|
| 558 |
-
"function ",
|
| 559 |
-
"import ",
|
| 560 |
-
"from ",
|
| 561 |
-
"const ",
|
| 562 |
-
"let ",
|
| 563 |
-
"var ",
|
| 564 |
-
"public ",
|
| 565 |
-
"private ",
|
| 566 |
-
"async ",
|
| 567 |
-
"await ",
|
| 568 |
-
"return ",
|
| 569 |
-
"if (",
|
| 570 |
-
"for (",
|
| 571 |
-
"while (",
|
| 572 |
-
]
|
| 573 |
-
return any(indicator in content for indicator in code_indicators)
|
| 574 |
-
|
| 575 |
-
def _resolve_device(self) -> str:
|
| 576 |
-
"""Resolve 'auto' device to actual device."""
|
| 577 |
-
if self.config.device != "auto":
|
| 578 |
-
return self.config.device
|
| 579 |
-
|
| 580 |
-
try:
|
| 581 |
-
import torch
|
| 582 |
-
|
| 583 |
-
if torch.cuda.is_available():
|
| 584 |
-
return "cuda"
|
| 585 |
-
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
| 586 |
-
return "mps"
|
| 587 |
-
except ImportError:
|
| 588 |
-
pass
|
| 589 |
-
|
| 590 |
-
return "cpu"
|
| 591 |
-
|
| 592 |
-
def _store_in_ccr(
|
| 593 |
-
self,
|
| 594 |
-
original: str,
|
| 595 |
-
compressed: str,
|
| 596 |
-
original_tokens: int,
|
| 597 |
-
) -> str | None:
|
| 598 |
-
"""Store original content in CCR for later retrieval.
|
| 599 |
-
|
| 600 |
-
Args:
|
| 601 |
-
original: Original content before compression.
|
| 602 |
-
compressed: Compressed content.
|
| 603 |
-
original_tokens: Token count of original.
|
| 604 |
-
|
| 605 |
-
Returns:
|
| 606 |
-
Cache key if stored successfully, None otherwise.
|
| 607 |
-
"""
|
| 608 |
-
try:
|
| 609 |
-
from ..cache.compression_store import get_compression_store
|
| 610 |
-
|
| 611 |
-
store = get_compression_store()
|
| 612 |
-
return store.store(
|
| 613 |
-
original,
|
| 614 |
-
compressed,
|
| 615 |
-
original_tokens=original_tokens,
|
| 616 |
-
compressed_tokens=len(compressed.split()),
|
| 617 |
-
compression_strategy="llmlingua2",
|
| 618 |
-
)
|
| 619 |
-
except ImportError:
|
| 620 |
-
return None
|
| 621 |
-
except Exception as e:
|
| 622 |
-
logger.debug("CCR storage failed: %s", e)
|
| 623 |
-
return None
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
def compress_with_llmlingua(
|
| 627 |
-
content: str,
|
| 628 |
-
compression_rate: float = 0.3,
|
| 629 |
-
context: str = "",
|
| 630 |
-
model_name: str | None = None,
|
| 631 |
-
) -> str:
|
| 632 |
-
"""Convenience function for one-off compression.
|
| 633 |
-
|
| 634 |
-
Args:
|
| 635 |
-
content: Content to compress.
|
| 636 |
-
compression_rate: Target compression rate (0.0-1.0).
|
| 637 |
-
context: Optional context for relevance-aware compression.
|
| 638 |
-
model_name: Optional model name override.
|
| 639 |
-
|
| 640 |
-
Returns:
|
| 641 |
-
Compressed content string.
|
| 642 |
-
|
| 643 |
-
Example:
|
| 644 |
-
>>> compressed = compress_with_llmlingua(long_output, compression_rate=0.2)
|
| 645 |
-
"""
|
| 646 |
-
config = LLMLinguaConfig(target_compression_rate=compression_rate)
|
| 647 |
-
if model_name:
|
| 648 |
-
config.model_name = model_name
|
| 649 |
-
|
| 650 |
-
compressor = LLMLinguaCompressor(config)
|
| 651 |
-
result = compressor.compress(content, context=context)
|
| 652 |
-
return result.compressed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
headroom/transforms/pipeline.py
CHANGED
|
@@ -40,7 +40,7 @@ class TransformPipeline:
|
|
| 40 |
Transform order:
|
| 41 |
1. Cache Aligner - normalize prefix for cache hits
|
| 42 |
2. Content Router - intelligent content-aware compression (routes to appropriate
|
| 43 |
-
compressor:
|
| 44 |
3. SmartCrusher/ToolCrusher - fallback if ContentRouter disabled
|
| 45 |
4. IntelligentContextManager/RollingWindow - enforce token limits
|
| 46 |
"""
|
|
|
|
| 40 |
Transform order:
|
| 41 |
1. Cache Aligner - normalize prefix for cache hits
|
| 42 |
2. Content Router - intelligent content-aware compression (routes to appropriate
|
| 43 |
+
compressor: Kompress for text, SmartCrusher for JSON, CodeCompressor for code, etc.)
|
| 44 |
3. SmartCrusher/ToolCrusher - fallback if ContentRouter disabled
|
| 45 |
4. IntelligentContextManager/RollingWindow - enforce token limits
|
| 46 |
"""
|
headroom/transforms/tag_protector.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Protect workflow/custom XML tags from text compression.
|
| 2 |
|
| 3 |
LLM workflows use XML-style tags (<system-reminder>, <tool_call>, <thinking>)
|
| 4 |
-
as structural markers. Text compressors (Kompress
|
| 5 |
droppable noise and silently remove them, breaking downstream tools.
|
| 6 |
|
| 7 |
This module detects custom tags (anything NOT standard HTML), replaces entire
|
|
|
|
| 1 |
"""Protect workflow/custom XML tags from text compression.
|
| 2 |
|
| 3 |
LLM workflows use XML-style tags (<system-reminder>, <tool_call>, <thinking>)
|
| 4 |
+
as structural markers. Text compressors (Kompress) treat these as
|
| 5 |
droppable noise and silently remove them, breaking downstream tools.
|
| 6 |
|
| 7 |
This module detects custom tags (anything NOT standard HTML), replaces entire
|
tests/test_ccr_tool_injection.py
CHANGED
|
@@ -413,7 +413,7 @@ class TestAlternativeMarkerFormats:
|
|
| 413 |
- TextCompressor: [N lines compressed to M. Retrieve more: hash=xxx]
|
| 414 |
- LogCompressor: [N lines compressed to M. Retrieve more: hash=xxx]
|
| 415 |
- SearchCompressor: [N matches compressed to M. Retrieve more: hash=xxx]
|
| 416 |
-
-
|
| 417 |
|
| 418 |
The CCRToolInjector should detect all these formats.
|
| 419 |
"""
|
|
|
|
| 413 |
- TextCompressor: [N lines compressed to M. Retrieve more: hash=xxx]
|
| 414 |
- LogCompressor: [N lines compressed to M. Retrieve more: hash=xxx]
|
| 415 |
- SearchCompressor: [N matches compressed to M. Retrieve more: hash=xxx]
|
| 416 |
+
- Kompress: [N items compressed to M. Retrieve more: hash=xxx]
|
| 417 |
|
| 418 |
The CCRToolInjector should detect all these formats.
|
| 419 |
"""
|
tests/test_compression/test_evals.py
CHANGED
|
@@ -755,10 +755,10 @@ class TestJSONAPIResponseEval:
|
|
| 755 |
|
| 756 |
@pytest.fixture
|
| 757 |
def compressor(self):
|
| 758 |
-
"""Create compressor with simple compression (no
|
| 759 |
config = UniversalCompressorConfig(
|
| 760 |
use_magika=False, # Use fallback for consistent tests
|
| 761 |
-
|
| 762 |
ccr_enabled=False,
|
| 763 |
)
|
| 764 |
return UniversalCompressor(config=config)
|
|
@@ -885,7 +885,7 @@ class TestCodeFileEval:
|
|
| 885 |
"""Create compressor."""
|
| 886 |
config = UniversalCompressorConfig(
|
| 887 |
use_magika=False,
|
| 888 |
-
|
| 889 |
ccr_enabled=False,
|
| 890 |
)
|
| 891 |
return UniversalCompressor(config=config)
|
|
@@ -969,7 +969,7 @@ class TestLogOutputEval:
|
|
| 969 |
"""Create compressor."""
|
| 970 |
config = UniversalCompressorConfig(
|
| 971 |
use_magika=False,
|
| 972 |
-
|
| 973 |
ccr_enabled=False,
|
| 974 |
)
|
| 975 |
return UniversalCompressor(config=config)
|
|
@@ -1031,7 +1031,7 @@ class TestMultiToolAgentScenario:
|
|
| 1031 |
"""Create compressor."""
|
| 1032 |
config = UniversalCompressorConfig(
|
| 1033 |
use_magika=False,
|
| 1034 |
-
|
| 1035 |
ccr_enabled=False,
|
| 1036 |
)
|
| 1037 |
return UniversalCompressor(config=config)
|
|
@@ -1129,7 +1129,7 @@ class TestCompressionQualityMetrics:
|
|
| 1129 |
"""Create compressor."""
|
| 1130 |
config = UniversalCompressorConfig(
|
| 1131 |
use_magika=False,
|
| 1132 |
-
|
| 1133 |
ccr_enabled=False,
|
| 1134 |
compression_ratio_target=0.3, # Target 70% reduction
|
| 1135 |
)
|
|
|
|
| 755 |
|
| 756 |
@pytest.fixture
|
| 757 |
def compressor(self):
|
| 758 |
+
"""Create compressor with simple compression (no Kompress for tests)."""
|
| 759 |
config = UniversalCompressorConfig(
|
| 760 |
use_magika=False, # Use fallback for consistent tests
|
| 761 |
+
use_kompress=False,
|
| 762 |
ccr_enabled=False,
|
| 763 |
)
|
| 764 |
return UniversalCompressor(config=config)
|
|
|
|
| 885 |
"""Create compressor."""
|
| 886 |
config = UniversalCompressorConfig(
|
| 887 |
use_magika=False,
|
| 888 |
+
use_kompress=False,
|
| 889 |
ccr_enabled=False,
|
| 890 |
)
|
| 891 |
return UniversalCompressor(config=config)
|
|
|
|
| 969 |
"""Create compressor."""
|
| 970 |
config = UniversalCompressorConfig(
|
| 971 |
use_magika=False,
|
| 972 |
+
use_kompress=False,
|
| 973 |
ccr_enabled=False,
|
| 974 |
)
|
| 975 |
return UniversalCompressor(config=config)
|
|
|
|
| 1031 |
"""Create compressor."""
|
| 1032 |
config = UniversalCompressorConfig(
|
| 1033 |
use_magika=False,
|
| 1034 |
+
use_kompress=False,
|
| 1035 |
ccr_enabled=False,
|
| 1036 |
)
|
| 1037 |
return UniversalCompressor(config=config)
|
|
|
|
| 1129 |
"""Create compressor."""
|
| 1130 |
config = UniversalCompressorConfig(
|
| 1131 |
use_magika=False,
|
| 1132 |
+
use_kompress=False,
|
| 1133 |
ccr_enabled=False,
|
| 1134 |
compression_ratio_target=0.3, # Target 70% reduction
|
| 1135 |
)
|
tests/test_compression/test_llm_eval.py
CHANGED
|
@@ -301,7 +301,7 @@ class TestJSONDiscoverability:
|
|
| 301 |
"""Create compressor."""
|
| 302 |
config = UniversalCompressorConfig(
|
| 303 |
use_magika=False,
|
| 304 |
-
|
| 305 |
ccr_enabled=False,
|
| 306 |
)
|
| 307 |
return UniversalCompressor(config=config)
|
|
@@ -418,7 +418,7 @@ class TestCodeUnderstanding:
|
|
| 418 |
"""Create compressor."""
|
| 419 |
config = UniversalCompressorConfig(
|
| 420 |
use_magika=False,
|
| 421 |
-
|
| 422 |
ccr_enabled=False,
|
| 423 |
)
|
| 424 |
return UniversalCompressor(config=config)
|
|
@@ -528,7 +528,7 @@ class TestMultiContentAgent:
|
|
| 528 |
"""Create compressor."""
|
| 529 |
config = UniversalCompressorConfig(
|
| 530 |
use_magika=False,
|
| 531 |
-
|
| 532 |
ccr_enabled=False,
|
| 533 |
)
|
| 534 |
return UniversalCompressor(config=config)
|
|
@@ -591,7 +591,7 @@ class TestCompressionEfficacy:
|
|
| 591 |
"""Create compressor."""
|
| 592 |
config = UniversalCompressorConfig(
|
| 593 |
use_magika=False,
|
| 594 |
-
|
| 595 |
ccr_enabled=False,
|
| 596 |
)
|
| 597 |
return UniversalCompressor(config=config)
|
|
|
|
| 301 |
"""Create compressor."""
|
| 302 |
config = UniversalCompressorConfig(
|
| 303 |
use_magika=False,
|
| 304 |
+
use_kompress=False,
|
| 305 |
ccr_enabled=False,
|
| 306 |
)
|
| 307 |
return UniversalCompressor(config=config)
|
|
|
|
| 418 |
"""Create compressor."""
|
| 419 |
config = UniversalCompressorConfig(
|
| 420 |
use_magika=False,
|
| 421 |
+
use_kompress=False,
|
| 422 |
ccr_enabled=False,
|
| 423 |
)
|
| 424 |
return UniversalCompressor(config=config)
|
|
|
|
| 528 |
"""Create compressor."""
|
| 529 |
config = UniversalCompressorConfig(
|
| 530 |
use_magika=False,
|
| 531 |
+
use_kompress=False,
|
| 532 |
ccr_enabled=False,
|
| 533 |
)
|
| 534 |
return UniversalCompressor(config=config)
|
|
|
|
| 591 |
"""Create compressor."""
|
| 592 |
config = UniversalCompressorConfig(
|
| 593 |
use_magika=False,
|
| 594 |
+
use_kompress=False,
|
| 595 |
ccr_enabled=False,
|
| 596 |
)
|
| 597 |
return UniversalCompressor(config=config)
|
tests/test_compression/test_universal.py
CHANGED
|
@@ -22,7 +22,7 @@ class TestUniversalCompressorConfig:
|
|
| 22 |
config = UniversalCompressorConfig()
|
| 23 |
|
| 24 |
assert config.use_magika is True
|
| 25 |
-
assert config.
|
| 26 |
assert config.use_entropy_preservation is True
|
| 27 |
assert config.entropy_threshold == 0.85
|
| 28 |
assert config.min_content_length == 100
|
|
@@ -99,7 +99,7 @@ class TestUniversalCompressor:
|
|
| 99 |
"""Create compressor with fallback detector (no Magika required)."""
|
| 100 |
config = UniversalCompressorConfig(
|
| 101 |
use_magika=False, # Use fallback detector
|
| 102 |
-
|
| 103 |
ccr_enabled=False, # Skip CCR
|
| 104 |
)
|
| 105 |
return UniversalCompressor(config=config)
|
|
@@ -215,7 +215,7 @@ class TestUniversalCompressorBatch:
|
|
| 215 |
"""Create compressor with fallback detector."""
|
| 216 |
config = UniversalCompressorConfig(
|
| 217 |
use_magika=False,
|
| 218 |
-
|
| 219 |
ccr_enabled=False,
|
| 220 |
)
|
| 221 |
return UniversalCompressor(config=config)
|
|
@@ -262,7 +262,7 @@ class TestStructurePreservation:
|
|
| 262 |
"""Create compressor."""
|
| 263 |
config = UniversalCompressorConfig(
|
| 264 |
use_magika=False,
|
| 265 |
-
|
| 266 |
ccr_enabled=False,
|
| 267 |
)
|
| 268 |
return UniversalCompressor(config=config)
|
|
|
|
| 22 |
config = UniversalCompressorConfig()
|
| 23 |
|
| 24 |
assert config.use_magika is True
|
| 25 |
+
assert config.use_kompress is True
|
| 26 |
assert config.use_entropy_preservation is True
|
| 27 |
assert config.entropy_threshold == 0.85
|
| 28 |
assert config.min_content_length == 100
|
|
|
|
| 99 |
"""Create compressor with fallback detector (no Magika required)."""
|
| 100 |
config = UniversalCompressorConfig(
|
| 101 |
use_magika=False, # Use fallback detector
|
| 102 |
+
use_kompress=False, # Use simple compression
|
| 103 |
ccr_enabled=False, # Skip CCR
|
| 104 |
)
|
| 105 |
return UniversalCompressor(config=config)
|
|
|
|
| 215 |
"""Create compressor with fallback detector."""
|
| 216 |
config = UniversalCompressorConfig(
|
| 217 |
use_magika=False,
|
| 218 |
+
use_kompress=False,
|
| 219 |
ccr_enabled=False,
|
| 220 |
)
|
| 221 |
return UniversalCompressor(config=config)
|
|
|
|
| 262 |
"""Create compressor."""
|
| 263 |
config = UniversalCompressorConfig(
|
| 264 |
use_magika=False,
|
| 265 |
+
use_kompress=False,
|
| 266 |
ccr_enabled=False,
|
| 267 |
)
|
| 268 |
return UniversalCompressor(config=config)
|
tests/test_evals/test_html_extraction_eval.py
CHANGED
|
@@ -262,7 +262,7 @@ class TestHTMLExtractionWithLLM:
|
|
| 262 |
|
| 263 |
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
| 264 |
class TestHTMLvsBaseline:
|
| 265 |
-
"""Tests comparing HTMLExtractor vs
|
| 266 |
|
| 267 |
@pytest.fixture
|
| 268 |
def evaluator_with_baseline(self):
|
|
@@ -274,9 +274,9 @@ class TestHTMLvsBaseline:
|
|
| 274 |
provider="openai",
|
| 275 |
)
|
| 276 |
|
| 277 |
-
@pytest.mark.skipif(True, reason="
|
| 278 |
def test_extraction_beats_baseline(self, evaluator_with_baseline):
|
| 279 |
-
"""Test that HTMLExtractor outperforms
|
| 280 |
cases = get_sample_eval_cases()[:2] # Just test 2 for speed
|
| 281 |
|
| 282 |
results = evaluator_with_baseline.evaluate(cases)
|
|
@@ -286,9 +286,9 @@ class TestHTMLvsBaseline:
|
|
| 286 |
print(f"Avg extraction score: {results.avg_extraction_score}/5")
|
| 287 |
print(f"Avg baseline score: {results.avg_baseline_score}/5")
|
| 288 |
|
| 289 |
-
# HTMLExtractor should beat
|
| 290 |
assert results.avg_extraction_score >= results.avg_baseline_score, (
|
| 291 |
-
"HTMLExtractor should perform at least as well as
|
| 292 |
)
|
| 293 |
|
| 294 |
|
|
|
|
| 262 |
|
| 263 |
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
| 264 |
class TestHTMLvsBaseline:
|
| 265 |
+
"""Tests comparing HTMLExtractor vs Kompress baseline."""
|
| 266 |
|
| 267 |
@pytest.fixture
|
| 268 |
def evaluator_with_baseline(self):
|
|
|
|
| 274 |
provider="openai",
|
| 275 |
)
|
| 276 |
|
| 277 |
+
@pytest.mark.skipif(True, reason="Kompress requires GPU, skip in CI")
|
| 278 |
def test_extraction_beats_baseline(self, evaluator_with_baseline):
|
| 279 |
+
"""Test that HTMLExtractor outperforms Kompress on HTML."""
|
| 280 |
cases = get_sample_eval_cases()[:2] # Just test 2 for speed
|
| 281 |
|
| 282 |
results = evaluator_with_baseline.evaluate(cases)
|
|
|
|
| 286 |
print(f"Avg extraction score: {results.avg_extraction_score}/5")
|
| 287 |
print(f"Avg baseline score: {results.avg_baseline_score}/5")
|
| 288 |
|
| 289 |
+
# HTMLExtractor should beat Kompress on HTML content
|
| 290 |
assert results.avg_extraction_score >= results.avg_baseline_score, (
|
| 291 |
+
"HTMLExtractor should perform at least as well as Kompress on HTML"
|
| 292 |
)
|
| 293 |
|
| 294 |
|
tests/test_proxy_llmlingua.py
DELETED
|
@@ -1,468 +0,0 @@
|
|
| 1 |
-
"""Tests for LLMLingua opt-in mechanism in the proxy server.
|
| 2 |
-
|
| 3 |
-
These tests verify:
|
| 4 |
-
- ProxyConfig llmlingua settings
|
| 5 |
-
- LLMLingua transform integration in pipeline
|
| 6 |
-
- Status detection and logging hints
|
| 7 |
-
- CLI flag parsing
|
| 8 |
-
- DevEx: helpful messages when llmlingua unavailable
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
from unittest.mock import MagicMock, patch
|
| 12 |
-
|
| 13 |
-
import pytest
|
| 14 |
-
|
| 15 |
-
# Skip if fastapi not available
|
| 16 |
-
pytest.importorskip("fastapi")
|
| 17 |
-
|
| 18 |
-
from fastapi.testclient import TestClient
|
| 19 |
-
|
| 20 |
-
from headroom.proxy.server import (
|
| 21 |
-
HeadroomProxy,
|
| 22 |
-
ProxyConfig,
|
| 23 |
-
_get_llmlingua_banner_status,
|
| 24 |
-
create_app,
|
| 25 |
-
)
|
| 26 |
-
from headroom.transforms import _LLMLINGUA_AVAILABLE
|
| 27 |
-
|
| 28 |
-
# =============================================================================
|
| 29 |
-
# Test Fixtures
|
| 30 |
-
# =============================================================================
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
@pytest.fixture
|
| 34 |
-
def base_config():
|
| 35 |
-
"""Base config with optimization disabled for simpler tests."""
|
| 36 |
-
return ProxyConfig(
|
| 37 |
-
optimize=False,
|
| 38 |
-
cache_enabled=False,
|
| 39 |
-
rate_limit_enabled=False,
|
| 40 |
-
cost_tracking_enabled=False,
|
| 41 |
-
llmlingua_enabled=False, # Explicitly disable for base config
|
| 42 |
-
smart_routing=False, # Use legacy mode for these tests
|
| 43 |
-
)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
@pytest.fixture
|
| 47 |
-
def llmlingua_config():
|
| 48 |
-
"""Config with LLMLingua enabled (legacy mode for explicit status testing)."""
|
| 49 |
-
return ProxyConfig(
|
| 50 |
-
optimize=True,
|
| 51 |
-
cache_enabled=False,
|
| 52 |
-
rate_limit_enabled=False,
|
| 53 |
-
cost_tracking_enabled=False,
|
| 54 |
-
llmlingua_enabled=True,
|
| 55 |
-
llmlingua_device="cpu",
|
| 56 |
-
llmlingua_target_rate=0.4,
|
| 57 |
-
smart_routing=False, # Use legacy mode for explicit status testing
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
@pytest.fixture
|
| 62 |
-
def client(base_config):
|
| 63 |
-
"""Create test client with base config."""
|
| 64 |
-
app = create_app(base_config)
|
| 65 |
-
with TestClient(app) as client:
|
| 66 |
-
yield client
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
# =============================================================================
|
| 70 |
-
# TestProxyConfigLLMLingua
|
| 71 |
-
# =============================================================================
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
class TestProxyConfigLLMLingua:
|
| 75 |
-
"""Tests for LLMLingua settings in ProxyConfig."""
|
| 76 |
-
|
| 77 |
-
def test_default_llmlingua_enabled(self):
|
| 78 |
-
"""LLMLingua is enabled by default (with smart routing)."""
|
| 79 |
-
config = ProxyConfig()
|
| 80 |
-
|
| 81 |
-
# LLMLingua is now enabled by default with smart routing
|
| 82 |
-
assert config.llmlingua_enabled is True
|
| 83 |
-
assert config.llmlingua_device == "auto"
|
| 84 |
-
assert config.llmlingua_target_rate == 0.3
|
| 85 |
-
|
| 86 |
-
def test_llmlingua_can_be_enabled(self):
|
| 87 |
-
"""LLMLingua can be enabled via config."""
|
| 88 |
-
config = ProxyConfig(
|
| 89 |
-
llmlingua_enabled=True,
|
| 90 |
-
llmlingua_device="cuda",
|
| 91 |
-
llmlingua_target_rate=0.5,
|
| 92 |
-
)
|
| 93 |
-
|
| 94 |
-
assert config.llmlingua_enabled is True
|
| 95 |
-
assert config.llmlingua_device == "cuda"
|
| 96 |
-
assert config.llmlingua_target_rate == 0.5
|
| 97 |
-
|
| 98 |
-
def test_llmlingua_device_options(self):
|
| 99 |
-
"""LLMLingua device accepts valid options."""
|
| 100 |
-
for device in ["auto", "cuda", "cpu", "mps"]:
|
| 101 |
-
config = ProxyConfig(llmlingua_device=device)
|
| 102 |
-
assert config.llmlingua_device == device
|
| 103 |
-
|
| 104 |
-
def test_llmlingua_target_rate_range(self):
|
| 105 |
-
"""LLMLingua target rate accepts 0.0-1.0 range."""
|
| 106 |
-
# Low rate (aggressive compression)
|
| 107 |
-
config_low = ProxyConfig(llmlingua_target_rate=0.1)
|
| 108 |
-
assert config_low.llmlingua_target_rate == 0.1
|
| 109 |
-
|
| 110 |
-
# High rate (conservative compression)
|
| 111 |
-
config_high = ProxyConfig(llmlingua_target_rate=0.8)
|
| 112 |
-
assert config_high.llmlingua_target_rate == 0.8
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
# =============================================================================
|
| 116 |
-
# TestLLMLinguaSetup
|
| 117 |
-
# =============================================================================
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
class TestLLMLinguaSetup:
|
| 121 |
-
"""Tests for LLMLingua setup in HeadroomProxy."""
|
| 122 |
-
|
| 123 |
-
def test_setup_returns_disabled_when_not_enabled(self, base_config):
|
| 124 |
-
"""Setup returns 'disabled' when llmlingua not enabled and not available."""
|
| 125 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", False):
|
| 126 |
-
proxy = HeadroomProxy(base_config)
|
| 127 |
-
assert proxy._llmlingua_status == "disabled"
|
| 128 |
-
|
| 129 |
-
def test_setup_returns_available_when_installed_but_not_enabled(self, base_config):
|
| 130 |
-
"""Setup returns 'available' when llmlingua installed but not enabled."""
|
| 131 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", True):
|
| 132 |
-
proxy = HeadroomProxy(base_config)
|
| 133 |
-
assert proxy._llmlingua_status == "available"
|
| 134 |
-
|
| 135 |
-
def test_setup_returns_enabled_when_enabled_and_available(self, llmlingua_config):
|
| 136 |
-
"""Setup returns 'enabled' when llmlingua enabled and available."""
|
| 137 |
-
mock_compressor = MagicMock()
|
| 138 |
-
|
| 139 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", True):
|
| 140 |
-
with patch("headroom.proxy.server.LLMLinguaCompressor", mock_compressor):
|
| 141 |
-
with patch("headroom.proxy.server.LLMLinguaConfig"):
|
| 142 |
-
proxy = HeadroomProxy(llmlingua_config)
|
| 143 |
-
assert proxy._llmlingua_status == "enabled"
|
| 144 |
-
|
| 145 |
-
def test_setup_returns_unavailable_when_enabled_but_not_installed(self):
|
| 146 |
-
"""Setup returns 'unavailable' when enabled but llmlingua not installed."""
|
| 147 |
-
config = ProxyConfig(
|
| 148 |
-
llmlingua_enabled=True,
|
| 149 |
-
optimize=False,
|
| 150 |
-
cache_enabled=False,
|
| 151 |
-
rate_limit_enabled=False,
|
| 152 |
-
smart_routing=False, # Use legacy mode for explicit status testing
|
| 153 |
-
)
|
| 154 |
-
|
| 155 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", False):
|
| 156 |
-
proxy = HeadroomProxy(config)
|
| 157 |
-
assert proxy._llmlingua_status == "unavailable"
|
| 158 |
-
|
| 159 |
-
def test_llmlingua_compressor_added_to_pipeline(self, llmlingua_config):
|
| 160 |
-
"""LLMLinguaCompressor is added to pipeline when enabled."""
|
| 161 |
-
mock_compressor_class = MagicMock()
|
| 162 |
-
mock_compressor_instance = MagicMock()
|
| 163 |
-
mock_compressor_class.return_value = mock_compressor_instance
|
| 164 |
-
|
| 165 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", True):
|
| 166 |
-
with patch("headroom.proxy.server.LLMLinguaCompressor", mock_compressor_class):
|
| 167 |
-
with patch("headroom.proxy.server.LLMLinguaConfig") as mock_config:
|
| 168 |
-
HeadroomProxy(llmlingua_config)
|
| 169 |
-
|
| 170 |
-
# Verify LLMLinguaCompressor was instantiated
|
| 171 |
-
mock_compressor_class.assert_called_once()
|
| 172 |
-
|
| 173 |
-
# Verify config was passed with correct device and rate
|
| 174 |
-
call_args = mock_config.call_args
|
| 175 |
-
assert call_args.kwargs["device"] == "cpu"
|
| 176 |
-
assert call_args.kwargs["target_compression_rate"] == 0.4
|
| 177 |
-
|
| 178 |
-
def test_llmlingua_not_added_when_disabled(self, base_config):
|
| 179 |
-
"""LLMLinguaCompressor is NOT added when disabled."""
|
| 180 |
-
mock_compressor_class = MagicMock()
|
| 181 |
-
|
| 182 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", True):
|
| 183 |
-
with patch("headroom.proxy.server.LLMLinguaCompressor", mock_compressor_class):
|
| 184 |
-
HeadroomProxy(base_config)
|
| 185 |
-
|
| 186 |
-
# Should NOT be called when disabled
|
| 187 |
-
mock_compressor_class.assert_not_called()
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
# =============================================================================
|
| 191 |
-
# TestBannerStatus
|
| 192 |
-
# =============================================================================
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
class TestBannerStatus:
|
| 196 |
-
"""Tests for banner status helper function."""
|
| 197 |
-
|
| 198 |
-
def test_banner_disabled_when_not_available(self):
|
| 199 |
-
"""Banner shows DISABLED when llmlingua not available and not enabled."""
|
| 200 |
-
config = ProxyConfig(llmlingua_enabled=False, smart_routing=False)
|
| 201 |
-
|
| 202 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", False):
|
| 203 |
-
status = _get_llmlingua_banner_status(config)
|
| 204 |
-
assert status == "DISABLED"
|
| 205 |
-
|
| 206 |
-
def test_banner_available_hint_when_installed(self):
|
| 207 |
-
"""Banner shows availability hint when installed but not enabled."""
|
| 208 |
-
config = ProxyConfig(llmlingua_enabled=False, smart_routing=False)
|
| 209 |
-
|
| 210 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", True):
|
| 211 |
-
status = _get_llmlingua_banner_status(config)
|
| 212 |
-
# Now shows DISABLED with hint to enable
|
| 213 |
-
assert "DISABLED" in status
|
| 214 |
-
assert "--no-llmlingua" in status # Hint to remove the flag to enable
|
| 215 |
-
|
| 216 |
-
def test_banner_enabled_when_active(self):
|
| 217 |
-
"""Banner shows ENABLED with config when active."""
|
| 218 |
-
config = ProxyConfig(
|
| 219 |
-
llmlingua_enabled=True,
|
| 220 |
-
llmlingua_device="cuda",
|
| 221 |
-
llmlingua_target_rate=0.25,
|
| 222 |
-
)
|
| 223 |
-
|
| 224 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", True):
|
| 225 |
-
status = _get_llmlingua_banner_status(config)
|
| 226 |
-
assert "ENABLED" in status
|
| 227 |
-
assert "cuda" in status
|
| 228 |
-
assert "0.25" in status
|
| 229 |
-
|
| 230 |
-
def test_banner_shows_install_hint_when_requested_but_missing(self):
|
| 231 |
-
"""Banner shows install hint when enabled but not installed."""
|
| 232 |
-
config = ProxyConfig(llmlingua_enabled=True, smart_routing=False)
|
| 233 |
-
|
| 234 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", False):
|
| 235 |
-
status = _get_llmlingua_banner_status(config)
|
| 236 |
-
assert "NOT INSTALLED" in status
|
| 237 |
-
assert "pip install" in status
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
# =============================================================================
|
| 241 |
-
# TestHealthEndpointWithLLMLingua
|
| 242 |
-
# =============================================================================
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
class TestHealthEndpointWithLLMLingua:
|
| 246 |
-
"""Tests for health endpoint reflecting LLMLingua status."""
|
| 247 |
-
|
| 248 |
-
def test_health_returns_llmlingua_in_config(self, client):
|
| 249 |
-
"""Health endpoint works regardless of LLMLingua status."""
|
| 250 |
-
response = client.get("/health")
|
| 251 |
-
assert response.status_code == 200
|
| 252 |
-
|
| 253 |
-
data = response.json()
|
| 254 |
-
assert data["status"] == "healthy"
|
| 255 |
-
assert "config" in data
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
# =============================================================================
|
| 259 |
-
# TestStatsEndpointWithLLMLingua
|
| 260 |
-
# =============================================================================
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
class TestStatsEndpointWithLLMLingua:
|
| 264 |
-
"""Tests for stats endpoint with LLMLingua integration."""
|
| 265 |
-
|
| 266 |
-
def test_stats_endpoint_works(self, client):
|
| 267 |
-
"""Stats endpoint works with any LLMLingua configuration."""
|
| 268 |
-
response = client.get("/stats")
|
| 269 |
-
assert response.status_code == 200
|
| 270 |
-
|
| 271 |
-
data = response.json()
|
| 272 |
-
assert "requests" in data
|
| 273 |
-
assert "tokens" in data
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
# =============================================================================
|
| 277 |
-
# TestCLIArguments
|
| 278 |
-
# =============================================================================
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
class TestCLIArguments:
|
| 282 |
-
"""Tests for CLI argument parsing (without actually running server)."""
|
| 283 |
-
|
| 284 |
-
def test_llmlingua_flag_defaults(self):
|
| 285 |
-
"""Default CLI values for LLMLingua settings."""
|
| 286 |
-
import argparse
|
| 287 |
-
|
| 288 |
-
parser = argparse.ArgumentParser()
|
| 289 |
-
parser.add_argument("--llmlingua", action="store_true")
|
| 290 |
-
parser.add_argument("--llmlingua-device", default="auto")
|
| 291 |
-
parser.add_argument("--llmlingua-rate", type=float, default=0.3)
|
| 292 |
-
|
| 293 |
-
args = parser.parse_args([])
|
| 294 |
-
|
| 295 |
-
assert args.llmlingua is False
|
| 296 |
-
assert args.llmlingua_device == "auto"
|
| 297 |
-
assert args.llmlingua_rate == 0.3
|
| 298 |
-
|
| 299 |
-
def test_llmlingua_flag_enabled(self):
|
| 300 |
-
"""CLI --llmlingua flag enables LLMLingua."""
|
| 301 |
-
import argparse
|
| 302 |
-
|
| 303 |
-
parser = argparse.ArgumentParser()
|
| 304 |
-
parser.add_argument("--llmlingua", action="store_true")
|
| 305 |
-
parser.add_argument("--llmlingua-device", default="auto")
|
| 306 |
-
parser.add_argument("--llmlingua-rate", type=float, default=0.3)
|
| 307 |
-
|
| 308 |
-
args = parser.parse_args(["--llmlingua"])
|
| 309 |
-
|
| 310 |
-
assert args.llmlingua is True
|
| 311 |
-
|
| 312 |
-
def test_llmlingua_device_flag(self):
|
| 313 |
-
"""CLI --llmlingua-device flag sets device."""
|
| 314 |
-
import argparse
|
| 315 |
-
|
| 316 |
-
parser = argparse.ArgumentParser()
|
| 317 |
-
parser.add_argument("--llmlingua-device", default="auto")
|
| 318 |
-
|
| 319 |
-
args = parser.parse_args(["--llmlingua-device", "cuda"])
|
| 320 |
-
|
| 321 |
-
assert args.llmlingua_device == "cuda"
|
| 322 |
-
|
| 323 |
-
def test_llmlingua_rate_flag(self):
|
| 324 |
-
"""CLI --llmlingua-rate flag sets compression rate."""
|
| 325 |
-
import argparse
|
| 326 |
-
|
| 327 |
-
parser = argparse.ArgumentParser()
|
| 328 |
-
parser.add_argument("--llmlingua-rate", type=float, default=0.3)
|
| 329 |
-
|
| 330 |
-
args = parser.parse_args(["--llmlingua-rate", "0.5"])
|
| 331 |
-
|
| 332 |
-
assert args.llmlingua_rate == 0.5
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
# =============================================================================
|
| 336 |
-
# TestDevExMessages
|
| 337 |
-
# =============================================================================
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
class TestDevExMessages:
|
| 341 |
-
"""Tests for developer experience messages and hints."""
|
| 342 |
-
|
| 343 |
-
def test_warning_logged_when_enabled_but_unavailable(self, caplog):
|
| 344 |
-
"""Warning is logged when llmlingua enabled but not installed."""
|
| 345 |
-
import logging
|
| 346 |
-
|
| 347 |
-
config = ProxyConfig(
|
| 348 |
-
llmlingua_enabled=True,
|
| 349 |
-
optimize=False,
|
| 350 |
-
cache_enabled=False,
|
| 351 |
-
rate_limit_enabled=False,
|
| 352 |
-
smart_routing=False, # Use legacy mode for explicit status testing
|
| 353 |
-
)
|
| 354 |
-
|
| 355 |
-
with caplog.at_level(logging.WARNING):
|
| 356 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", False):
|
| 357 |
-
proxy = HeadroomProxy(config)
|
| 358 |
-
|
| 359 |
-
# Should have logged a warning about missing llmlingua
|
| 360 |
-
assert proxy._llmlingua_status == "unavailable"
|
| 361 |
-
assert any("llmlingua" in r.message.lower() for r in caplog.records)
|
| 362 |
-
assert any("pip install" in r.message for r in caplog.records)
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
# =============================================================================
|
| 366 |
-
# TestIntegrationWithActualLLMLingua
|
| 367 |
-
# =============================================================================
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
@pytest.mark.skipif(not _LLMLINGUA_AVAILABLE, reason="llmlingua not installed")
|
| 371 |
-
class TestIntegrationWithActualLLMLingua:
|
| 372 |
-
"""Integration tests that require actual llmlingua installation.
|
| 373 |
-
|
| 374 |
-
These tests verify the full integration path when llmlingua is installed.
|
| 375 |
-
"""
|
| 376 |
-
|
| 377 |
-
def test_proxy_starts_with_llmlingua_enabled(self):
|
| 378 |
-
"""Proxy starts successfully with LLMLingua enabled."""
|
| 379 |
-
config = ProxyConfig(
|
| 380 |
-
llmlingua_enabled=True,
|
| 381 |
-
llmlingua_device="cpu", # CPU for CI/test environments
|
| 382 |
-
llmlingua_target_rate=0.3,
|
| 383 |
-
optimize=True,
|
| 384 |
-
cache_enabled=False,
|
| 385 |
-
rate_limit_enabled=False,
|
| 386 |
-
smart_routing=False, # Use legacy mode for explicit status testing
|
| 387 |
-
)
|
| 388 |
-
|
| 389 |
-
# Should not raise
|
| 390 |
-
proxy = HeadroomProxy(config)
|
| 391 |
-
|
| 392 |
-
assert proxy._llmlingua_status == "enabled"
|
| 393 |
-
|
| 394 |
-
def test_app_creates_with_llmlingua(self):
|
| 395 |
-
"""FastAPI app creates successfully with LLMLingua enabled."""
|
| 396 |
-
config = ProxyConfig(
|
| 397 |
-
llmlingua_enabled=True,
|
| 398 |
-
llmlingua_device="cpu",
|
| 399 |
-
optimize=True,
|
| 400 |
-
cache_enabled=False,
|
| 401 |
-
rate_limit_enabled=False,
|
| 402 |
-
)
|
| 403 |
-
|
| 404 |
-
# Should not raise
|
| 405 |
-
app = create_app(config)
|
| 406 |
-
|
| 407 |
-
assert app is not None
|
| 408 |
-
|
| 409 |
-
def test_health_endpoint_with_llmlingua_enabled(self):
|
| 410 |
-
"""Health endpoint works with LLMLingua enabled."""
|
| 411 |
-
config = ProxyConfig(
|
| 412 |
-
llmlingua_enabled=True,
|
| 413 |
-
llmlingua_device="cpu",
|
| 414 |
-
optimize=True,
|
| 415 |
-
cache_enabled=False,
|
| 416 |
-
rate_limit_enabled=False,
|
| 417 |
-
)
|
| 418 |
-
|
| 419 |
-
app = create_app(config)
|
| 420 |
-
with TestClient(app) as client:
|
| 421 |
-
response = client.get("/health")
|
| 422 |
-
assert response.status_code == 200
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
# =============================================================================
|
| 426 |
-
# TestEdgeCases
|
| 427 |
-
# =============================================================================
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
class TestEdgeCases:
|
| 431 |
-
"""Edge cases for LLMLingua proxy integration."""
|
| 432 |
-
|
| 433 |
-
def test_multiple_proxy_instances_independent(self):
|
| 434 |
-
"""Multiple proxy instances have independent LLMLingua status."""
|
| 435 |
-
config_enabled = ProxyConfig(
|
| 436 |
-
llmlingua_enabled=True,
|
| 437 |
-
optimize=False,
|
| 438 |
-
cache_enabled=False,
|
| 439 |
-
rate_limit_enabled=False,
|
| 440 |
-
smart_routing=False, # Use legacy mode for explicit status testing
|
| 441 |
-
)
|
| 442 |
-
config_disabled = ProxyConfig(
|
| 443 |
-
llmlingua_enabled=False,
|
| 444 |
-
optimize=False,
|
| 445 |
-
cache_enabled=False,
|
| 446 |
-
rate_limit_enabled=False,
|
| 447 |
-
smart_routing=False, # Use legacy mode for explicit status testing
|
| 448 |
-
)
|
| 449 |
-
|
| 450 |
-
with patch("headroom.proxy.server._LLMLINGUA_AVAILABLE", True):
|
| 451 |
-
with patch("headroom.proxy.server.LLMLinguaCompressor"):
|
| 452 |
-
with patch("headroom.proxy.server.LLMLinguaConfig"):
|
| 453 |
-
proxy_enabled = HeadroomProxy(config_enabled)
|
| 454 |
-
proxy_disabled = HeadroomProxy(config_disabled)
|
| 455 |
-
|
| 456 |
-
assert proxy_enabled._llmlingua_status == "enabled"
|
| 457 |
-
assert proxy_disabled._llmlingua_status == "available"
|
| 458 |
-
|
| 459 |
-
def test_config_immutable_after_proxy_creation(self, base_config):
|
| 460 |
-
"""Config values are captured at proxy creation time."""
|
| 461 |
-
proxy = HeadroomProxy(base_config)
|
| 462 |
-
|
| 463 |
-
# Modifying config after creation doesn't affect proxy
|
| 464 |
-
# (ProxyConfig is a dataclass, so this tests the pattern)
|
| 465 |
-
original_status = proxy._llmlingua_status
|
| 466 |
-
|
| 467 |
-
# Status should remain unchanged
|
| 468 |
-
assert proxy._llmlingua_status == original_status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_transforms/test_code_compressor.py
CHANGED
|
@@ -499,14 +499,14 @@ class TestFallbackCompression:
|
|
| 499 |
|
| 500 |
# Should still return a result (fallback compression)
|
| 501 |
assert result is not None
|
| 502 |
-
#
|
| 503 |
-
# If
|
| 504 |
-
# If
|
| 505 |
|
| 506 |
def test_fallback_preserves_structure(self, default_config):
|
| 507 |
"""Fallback compression preserves basic structure when no compressor available.
|
| 508 |
|
| 509 |
-
When both tree-sitter and
|
| 510 |
returns the original code unchanged - preserving all structure.
|
| 511 |
"""
|
| 512 |
with (
|
|
@@ -515,7 +515,7 @@ class TestFallbackCompression:
|
|
| 515 |
return_value=False,
|
| 516 |
),
|
| 517 |
patch(
|
| 518 |
-
"headroom.transforms.
|
| 519 |
return_value=False,
|
| 520 |
),
|
| 521 |
):
|
|
|
|
| 499 |
|
| 500 |
# Should still return a result (fallback compression)
|
| 501 |
assert result is not None
|
| 502 |
+
# Kompress fallback does NOT guarantee syntax validity
|
| 503 |
+
# If Kompress is unavailable, returns original (valid)
|
| 504 |
+
# If Kompress IS available, syntax_valid=False (cannot guarantee)
|
| 505 |
|
| 506 |
def test_fallback_preserves_structure(self, default_config):
|
| 507 |
"""Fallback compression preserves basic structure when no compressor available.
|
| 508 |
|
| 509 |
+
When both tree-sitter and Kompress are unavailable, the fallback
|
| 510 |
returns the original code unchanged - preserving all structure.
|
| 511 |
"""
|
| 512 |
with (
|
|
|
|
| 515 |
return_value=False,
|
| 516 |
),
|
| 517 |
patch(
|
| 518 |
+
"headroom.transforms.kompress_compressor.is_kompress_available",
|
| 519 |
return_value=False,
|
| 520 |
),
|
| 521 |
):
|
tests/test_transforms/test_content_router.py
CHANGED
|
@@ -156,7 +156,6 @@ class TestContentRouterConfig:
|
|
| 156 |
|
| 157 |
assert config.enable_code_aware is True
|
| 158 |
assert config.enable_kompress is True
|
| 159 |
-
assert config.enable_llmlingua is True
|
| 160 |
assert config.enable_smart_crusher is True
|
| 161 |
assert config.enable_search_compressor is True
|
| 162 |
assert config.enable_log_compressor is True
|
|
@@ -168,13 +167,11 @@ class TestContentRouterConfig:
|
|
| 168 |
config = ContentRouterConfig(
|
| 169 |
min_section_tokens=50,
|
| 170 |
enable_code_aware=False,
|
| 171 |
-
enable_llmlingua=False,
|
| 172 |
fallback_strategy=CompressionStrategy.TEXT,
|
| 173 |
)
|
| 174 |
|
| 175 |
assert config.min_section_tokens == 50
|
| 176 |
assert config.enable_code_aware is False
|
| 177 |
-
assert config.enable_llmlingua is False
|
| 178 |
assert config.fallback_strategy == CompressionStrategy.TEXT
|
| 179 |
|
| 180 |
def test_all_strategies_in_enum(self):
|
|
@@ -184,7 +181,6 @@ class TestContentRouterConfig:
|
|
| 184 |
"SMART_CRUSHER",
|
| 185 |
"SEARCH",
|
| 186 |
"LOG",
|
| 187 |
-
"LLMLINGUA",
|
| 188 |
"TEXT",
|
| 189 |
"MIXED",
|
| 190 |
"PASSTHROUGH",
|
|
|
|
| 156 |
|
| 157 |
assert config.enable_code_aware is True
|
| 158 |
assert config.enable_kompress is True
|
|
|
|
| 159 |
assert config.enable_smart_crusher is True
|
| 160 |
assert config.enable_search_compressor is True
|
| 161 |
assert config.enable_log_compressor is True
|
|
|
|
| 167 |
config = ContentRouterConfig(
|
| 168 |
min_section_tokens=50,
|
| 169 |
enable_code_aware=False,
|
|
|
|
| 170 |
fallback_strategy=CompressionStrategy.TEXT,
|
| 171 |
)
|
| 172 |
|
| 173 |
assert config.min_section_tokens == 50
|
| 174 |
assert config.enable_code_aware is False
|
|
|
|
| 175 |
assert config.fallback_strategy == CompressionStrategy.TEXT
|
| 176 |
|
| 177 |
def test_all_strategies_in_enum(self):
|
|
|
|
| 181 |
"SMART_CRUSHER",
|
| 182 |
"SEARCH",
|
| 183 |
"LOG",
|
|
|
|
| 184 |
"TEXT",
|
| 185 |
"MIXED",
|
| 186 |
"PASSTHROUGH",
|
tests/test_transforms/test_llmlingua_compressor.py
DELETED
|
@@ -1,941 +0,0 @@
|
|
| 1 |
-
"""Tests for LLMLingua-2 compressor integration.
|
| 2 |
-
|
| 3 |
-
Comprehensive tests covering:
|
| 4 |
-
- LLMLinguaConfig: Configuration validation and defaults
|
| 5 |
-
- LLMLinguaCompressor: Core compression functionality
|
| 6 |
-
- Transform interface: apply(), should_apply() methods
|
| 7 |
-
- Content type detection: JSON, code, plain text
|
| 8 |
-
- CCR integration: Reversible compression storage
|
| 9 |
-
- Edge cases: Empty content, unavailable dependency, fallbacks
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
import json
|
| 13 |
-
from unittest.mock import MagicMock, patch
|
| 14 |
-
|
| 15 |
-
import pytest
|
| 16 |
-
|
| 17 |
-
from headroom.transforms.llmlingua_compressor import (
|
| 18 |
-
LLMLinguaCompressor,
|
| 19 |
-
LLMLinguaConfig,
|
| 20 |
-
LLMLinguaResult,
|
| 21 |
-
compress_with_llmlingua,
|
| 22 |
-
is_llmlingua_model_loaded,
|
| 23 |
-
unload_llmlingua_model,
|
| 24 |
-
)
|
| 25 |
-
|
| 26 |
-
# Try to import for availability check
|
| 27 |
-
try:
|
| 28 |
-
import llmlingua # noqa: F401
|
| 29 |
-
|
| 30 |
-
LLMLINGUA_INSTALLED = True
|
| 31 |
-
except ImportError:
|
| 32 |
-
LLMLINGUA_INSTALLED = False
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
# =============================================================================
|
| 36 |
-
# Test Fixtures
|
| 37 |
-
# =============================================================================
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
@pytest.fixture
|
| 41 |
-
def default_config():
|
| 42 |
-
"""Default LLMLinguaConfig for testing."""
|
| 43 |
-
return LLMLinguaConfig(
|
| 44 |
-
min_tokens_for_compression=10, # Low threshold for tests
|
| 45 |
-
enable_ccr=False, # Disable CCR for unit tests
|
| 46 |
-
)
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
@pytest.fixture
|
| 50 |
-
def compressor(default_config):
|
| 51 |
-
"""LLMLinguaCompressor instance with default config."""
|
| 52 |
-
return LLMLinguaCompressor(default_config)
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
@pytest.fixture
|
| 56 |
-
def mock_llmlingua():
|
| 57 |
-
"""Mock the llmlingua module and PromptCompressor."""
|
| 58 |
-
mock_compressor = MagicMock()
|
| 59 |
-
mock_compressor._model_name = "test-model"
|
| 60 |
-
|
| 61 |
-
# Default compress_prompt return value
|
| 62 |
-
mock_compressor.compress_prompt.return_value = {
|
| 63 |
-
"compressed_prompt": "compressed content here",
|
| 64 |
-
"origin_tokens": 100,
|
| 65 |
-
"compressed_tokens": 30,
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
with patch(
|
| 69 |
-
"headroom.transforms.llmlingua_compressor._check_llmlingua_available",
|
| 70 |
-
return_value=True,
|
| 71 |
-
):
|
| 72 |
-
with patch(
|
| 73 |
-
"headroom.transforms.llmlingua_compressor._get_llmlingua_compressor",
|
| 74 |
-
return_value=mock_compressor,
|
| 75 |
-
):
|
| 76 |
-
yield mock_compressor
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
@pytest.fixture
|
| 80 |
-
def tokenizer():
|
| 81 |
-
"""Get a tokenizer for Transform interface tests."""
|
| 82 |
-
from headroom.providers import OpenAIProvider
|
| 83 |
-
from headroom.tokenizer import Tokenizer
|
| 84 |
-
|
| 85 |
-
provider = OpenAIProvider()
|
| 86 |
-
token_counter = provider.get_token_counter("gpt-4o")
|
| 87 |
-
return Tokenizer(token_counter, "gpt-4o")
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
# =============================================================================
|
| 91 |
-
# Test Data Generators
|
| 92 |
-
# =============================================================================
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def generate_long_text(n_words: int = 500) -> str:
|
| 96 |
-
"""Generate long text content for compression testing."""
|
| 97 |
-
words = ["the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog"]
|
| 98 |
-
return " ".join(words[i % len(words)] for i in range(n_words))
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def generate_long_json(n_items: int = 50) -> str:
|
| 102 |
-
"""Generate long JSON content for compression testing."""
|
| 103 |
-
items = [
|
| 104 |
-
{
|
| 105 |
-
"id": i,
|
| 106 |
-
"name": f"Item {i}",
|
| 107 |
-
"description": f"This is a detailed description for item number {i}",
|
| 108 |
-
"value": i * 10,
|
| 109 |
-
"active": i % 2 == 0,
|
| 110 |
-
}
|
| 111 |
-
for i in range(n_items)
|
| 112 |
-
]
|
| 113 |
-
return json.dumps(items)
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def generate_long_code(n_functions: int = 20) -> str:
|
| 117 |
-
"""Generate Python code content for compression testing."""
|
| 118 |
-
lines = ['"""Module with many functions."""', "", "import os", "from typing import Any", ""]
|
| 119 |
-
for i in range(n_functions):
|
| 120 |
-
lines.extend(
|
| 121 |
-
[
|
| 122 |
-
f"def function_{i}(arg: Any) -> str:",
|
| 123 |
-
f' """Process argument {i}."""',
|
| 124 |
-
" result = str(arg)",
|
| 125 |
-
f' return f"Function {i}: {{result}}"',
|
| 126 |
-
"",
|
| 127 |
-
]
|
| 128 |
-
)
|
| 129 |
-
return "\n".join(lines)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
# =============================================================================
|
| 133 |
-
# TestLLMLinguaConfig
|
| 134 |
-
# =============================================================================
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
class TestLLMLinguaConfig:
|
| 138 |
-
"""Tests for LLMLinguaConfig dataclass."""
|
| 139 |
-
|
| 140 |
-
def test_default_values(self):
|
| 141 |
-
"""Default config values are sensible."""
|
| 142 |
-
config = LLMLinguaConfig()
|
| 143 |
-
|
| 144 |
-
assert config.model_name == "microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank"
|
| 145 |
-
assert config.device == "auto"
|
| 146 |
-
assert config.target_compression_rate == 0.3
|
| 147 |
-
assert config.min_tokens_for_compression == 100
|
| 148 |
-
assert config.enable_ccr is True
|
| 149 |
-
assert config.drop_consecutive is True
|
| 150 |
-
|
| 151 |
-
def test_custom_values(self):
|
| 152 |
-
"""Custom config values are applied."""
|
| 153 |
-
config = LLMLinguaConfig(
|
| 154 |
-
model_name="custom/model",
|
| 155 |
-
device="cuda",
|
| 156 |
-
target_compression_rate=0.5,
|
| 157 |
-
min_tokens_for_compression=50,
|
| 158 |
-
force_tokens=["important", "keep"],
|
| 159 |
-
)
|
| 160 |
-
|
| 161 |
-
assert config.model_name == "custom/model"
|
| 162 |
-
assert config.device == "cuda"
|
| 163 |
-
assert config.target_compression_rate == 0.5
|
| 164 |
-
assert config.min_tokens_for_compression == 50
|
| 165 |
-
assert "important" in config.force_tokens
|
| 166 |
-
|
| 167 |
-
def test_content_type_rates(self):
|
| 168 |
-
"""Different content types have appropriate compression rates."""
|
| 169 |
-
config = LLMLinguaConfig()
|
| 170 |
-
|
| 171 |
-
# Code and text are equally conservative for accuracy
|
| 172 |
-
assert config.code_compression_rate >= config.text_compression_rate
|
| 173 |
-
# JSON can be slightly more aggressive since structure is preserved
|
| 174 |
-
assert config.json_compression_rate <= config.code_compression_rate
|
| 175 |
-
assert config.json_compression_rate <= config.text_compression_rate
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
# =============================================================================
|
| 179 |
-
# TestLLMLinguaResult
|
| 180 |
-
# =============================================================================
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
class TestLLMLinguaResult:
|
| 184 |
-
"""Tests for LLMLinguaResult dataclass."""
|
| 185 |
-
|
| 186 |
-
def test_tokens_saved(self):
|
| 187 |
-
"""tokens_saved property calculates correctly."""
|
| 188 |
-
result = LLMLinguaResult(
|
| 189 |
-
compressed="short",
|
| 190 |
-
original="long content here",
|
| 191 |
-
original_tokens=100,
|
| 192 |
-
compressed_tokens=30,
|
| 193 |
-
compression_ratio=0.3,
|
| 194 |
-
)
|
| 195 |
-
|
| 196 |
-
assert result.tokens_saved == 70
|
| 197 |
-
|
| 198 |
-
def test_tokens_saved_no_negative(self):
|
| 199 |
-
"""tokens_saved never returns negative."""
|
| 200 |
-
result = LLMLinguaResult(
|
| 201 |
-
compressed="expanded content",
|
| 202 |
-
original="short",
|
| 203 |
-
original_tokens=10,
|
| 204 |
-
compressed_tokens=20, # Expanded (unusual case)
|
| 205 |
-
compression_ratio=2.0,
|
| 206 |
-
)
|
| 207 |
-
|
| 208 |
-
assert result.tokens_saved == 0
|
| 209 |
-
|
| 210 |
-
def test_savings_percentage(self):
|
| 211 |
-
"""savings_percentage property calculates correctly."""
|
| 212 |
-
result = LLMLinguaResult(
|
| 213 |
-
compressed="short",
|
| 214 |
-
original="long content",
|
| 215 |
-
original_tokens=100,
|
| 216 |
-
compressed_tokens=25,
|
| 217 |
-
compression_ratio=0.25,
|
| 218 |
-
)
|
| 219 |
-
|
| 220 |
-
assert result.savings_percentage == 75.0
|
| 221 |
-
|
| 222 |
-
def test_savings_percentage_zero_original(self):
|
| 223 |
-
"""savings_percentage handles zero original tokens."""
|
| 224 |
-
result = LLMLinguaResult(
|
| 225 |
-
compressed="",
|
| 226 |
-
original="",
|
| 227 |
-
original_tokens=0,
|
| 228 |
-
compressed_tokens=0,
|
| 229 |
-
compression_ratio=1.0,
|
| 230 |
-
)
|
| 231 |
-
|
| 232 |
-
assert result.savings_percentage == 0.0
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
# =============================================================================
|
| 236 |
-
# TestLLMLinguaCompressor
|
| 237 |
-
# =============================================================================
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
class TestLLMLinguaCompressor:
|
| 241 |
-
"""Tests for LLMLinguaCompressor core functionality."""
|
| 242 |
-
|
| 243 |
-
def test_init_with_default_config(self):
|
| 244 |
-
"""Compressor initializes with default config."""
|
| 245 |
-
compressor = LLMLinguaCompressor()
|
| 246 |
-
|
| 247 |
-
assert compressor.config is not None
|
| 248 |
-
assert compressor.config.model_name is not None
|
| 249 |
-
|
| 250 |
-
def test_init_with_custom_config(self, default_config):
|
| 251 |
-
"""Compressor initializes with custom config."""
|
| 252 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 253 |
-
|
| 254 |
-
assert compressor.config == default_config
|
| 255 |
-
|
| 256 |
-
def test_compress_returns_result_when_unavailable(self, compressor):
|
| 257 |
-
"""Compress returns passthrough result when llmlingua unavailable."""
|
| 258 |
-
with patch(
|
| 259 |
-
"headroom.transforms.llmlingua_compressor._check_llmlingua_available",
|
| 260 |
-
return_value=False,
|
| 261 |
-
):
|
| 262 |
-
content = generate_long_text(100)
|
| 263 |
-
result = compressor.compress(content)
|
| 264 |
-
|
| 265 |
-
# Should return unchanged content
|
| 266 |
-
assert result.compressed == content
|
| 267 |
-
assert result.compression_ratio == 1.0
|
| 268 |
-
|
| 269 |
-
def test_compress_skips_small_content(self, compressor):
|
| 270 |
-
"""Small content is not compressed."""
|
| 271 |
-
small_content = "short text"
|
| 272 |
-
result = compressor.compress(small_content)
|
| 273 |
-
|
| 274 |
-
assert result.compressed == small_content
|
| 275 |
-
assert result.compression_ratio == 1.0
|
| 276 |
-
|
| 277 |
-
def test_compress_with_llmlingua(self, default_config, mock_llmlingua):
|
| 278 |
-
"""Compression uses llmlingua when available."""
|
| 279 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 280 |
-
content = generate_long_text(200)
|
| 281 |
-
|
| 282 |
-
result = compressor.compress(content)
|
| 283 |
-
|
| 284 |
-
# Should have called compress_prompt
|
| 285 |
-
mock_llmlingua.compress_prompt.assert_called_once()
|
| 286 |
-
assert result.compressed == "compressed content here"
|
| 287 |
-
assert result.compression_ratio < 1.0
|
| 288 |
-
|
| 289 |
-
def test_compress_with_context(self, default_config, mock_llmlingua):
|
| 290 |
-
"""Context words are used as force tokens."""
|
| 291 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 292 |
-
content = generate_long_text(200)
|
| 293 |
-
context = "important keywords here"
|
| 294 |
-
|
| 295 |
-
compressor.compress(content, context=context)
|
| 296 |
-
|
| 297 |
-
# Check force_tokens includes context words
|
| 298 |
-
call_args = mock_llmlingua.compress_prompt.call_args
|
| 299 |
-
force_tokens = call_args.kwargs.get("force_tokens", [])
|
| 300 |
-
# Should include context words longer than 3 chars
|
| 301 |
-
assert "important" in force_tokens or "keywords" in force_tokens
|
| 302 |
-
|
| 303 |
-
def test_compress_handles_exception(self, default_config, mock_llmlingua):
|
| 304 |
-
"""Exceptions from llmlingua are handled gracefully."""
|
| 305 |
-
mock_llmlingua.compress_prompt.side_effect = RuntimeError("Model error")
|
| 306 |
-
|
| 307 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 308 |
-
content = generate_long_text(200)
|
| 309 |
-
|
| 310 |
-
result = compressor.compress(content)
|
| 311 |
-
|
| 312 |
-
# Should return original content on error
|
| 313 |
-
assert result.compressed == content
|
| 314 |
-
assert result.compression_ratio == 1.0
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
# =============================================================================
|
| 318 |
-
# TestContentTypeDetection
|
| 319 |
-
# =============================================================================
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
class TestContentTypeDetection:
|
| 323 |
-
"""Tests for content type auto-detection."""
|
| 324 |
-
|
| 325 |
-
def test_detect_json_content(self, default_config, mock_llmlingua):
|
| 326 |
-
"""JSON content is detected and uses JSON compression rate."""
|
| 327 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 328 |
-
|
| 329 |
-
rate = compressor._get_compression_rate(generate_long_json(50), None)
|
| 330 |
-
|
| 331 |
-
assert rate == default_config.json_compression_rate
|
| 332 |
-
|
| 333 |
-
def test_detect_code_content(self, default_config, mock_llmlingua):
|
| 334 |
-
"""Code content is detected and uses code compression rate."""
|
| 335 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 336 |
-
code = generate_long_code(20)
|
| 337 |
-
|
| 338 |
-
rate = compressor._get_compression_rate(code, None)
|
| 339 |
-
|
| 340 |
-
assert rate == default_config.code_compression_rate
|
| 341 |
-
|
| 342 |
-
def test_detect_plain_text(self, default_config, mock_llmlingua):
|
| 343 |
-
"""Plain text uses text compression rate."""
|
| 344 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 345 |
-
text = generate_long_text(200)
|
| 346 |
-
|
| 347 |
-
rate = compressor._get_compression_rate(text, None)
|
| 348 |
-
|
| 349 |
-
assert rate == default_config.text_compression_rate
|
| 350 |
-
|
| 351 |
-
def test_explicit_content_type(self, default_config, mock_llmlingua):
|
| 352 |
-
"""Explicit content_type overrides detection."""
|
| 353 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 354 |
-
# JSON-looking content but marked as text
|
| 355 |
-
json_content = generate_long_json(50)
|
| 356 |
-
|
| 357 |
-
rate = compressor._get_compression_rate(json_content, content_type="text")
|
| 358 |
-
|
| 359 |
-
assert rate == default_config.text_compression_rate
|
| 360 |
-
|
| 361 |
-
def test_looks_like_json_detection(self, default_config):
|
| 362 |
-
"""JSON detection works for arrays and objects."""
|
| 363 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 364 |
-
|
| 365 |
-
assert compressor._looks_like_json('[{"key": "value"}]')
|
| 366 |
-
assert compressor._looks_like_json('{"key": "value"}')
|
| 367 |
-
assert not compressor._looks_like_json("plain text")
|
| 368 |
-
assert not compressor._looks_like_json("def function():")
|
| 369 |
-
|
| 370 |
-
def test_looks_like_code_detection(self, default_config):
|
| 371 |
-
"""Code detection works for common patterns."""
|
| 372 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 373 |
-
|
| 374 |
-
assert compressor._looks_like_code("def function():")
|
| 375 |
-
assert compressor._looks_like_code("class MyClass:")
|
| 376 |
-
assert compressor._looks_like_code("import os")
|
| 377 |
-
assert compressor._looks_like_code("function test() {")
|
| 378 |
-
assert compressor._looks_like_code("const x = 5")
|
| 379 |
-
assert not compressor._looks_like_code("plain text content")
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
# =============================================================================
|
| 383 |
-
# TestTransformInterface
|
| 384 |
-
# =============================================================================
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
class TestTransformInterface:
|
| 388 |
-
"""Tests for Transform interface (apply, should_apply)."""
|
| 389 |
-
|
| 390 |
-
def test_should_apply_returns_false_when_unavailable(self, compressor, tokenizer):
|
| 391 |
-
"""should_apply returns False when llmlingua unavailable."""
|
| 392 |
-
messages = [{"role": "user", "content": generate_long_text(200)}]
|
| 393 |
-
|
| 394 |
-
with patch(
|
| 395 |
-
"headroom.transforms.llmlingua_compressor._check_llmlingua_available",
|
| 396 |
-
return_value=False,
|
| 397 |
-
):
|
| 398 |
-
assert not compressor.should_apply(messages, tokenizer)
|
| 399 |
-
|
| 400 |
-
def test_should_apply_returns_false_for_small_content(self, default_config, tokenizer):
|
| 401 |
-
"""should_apply returns False for small content."""
|
| 402 |
-
config = LLMLinguaConfig(min_tokens_for_compression=1000)
|
| 403 |
-
compressor = LLMLinguaCompressor(config)
|
| 404 |
-
messages = [{"role": "user", "content": "small"}]
|
| 405 |
-
|
| 406 |
-
with patch(
|
| 407 |
-
"headroom.transforms.llmlingua_compressor._check_llmlingua_available",
|
| 408 |
-
return_value=True,
|
| 409 |
-
):
|
| 410 |
-
assert not compressor.should_apply(messages, tokenizer)
|
| 411 |
-
|
| 412 |
-
def test_should_apply_returns_true_for_large_content(self, default_config, tokenizer):
|
| 413 |
-
"""should_apply returns True for large content."""
|
| 414 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 415 |
-
messages = [{"role": "user", "content": generate_long_text(500)}]
|
| 416 |
-
|
| 417 |
-
with patch(
|
| 418 |
-
"headroom.transforms.llmlingua_compressor._check_llmlingua_available",
|
| 419 |
-
return_value=True,
|
| 420 |
-
):
|
| 421 |
-
assert compressor.should_apply(messages, tokenizer)
|
| 422 |
-
|
| 423 |
-
def test_apply_compresses_tool_messages(self, default_config, tokenizer, mock_llmlingua):
|
| 424 |
-
"""apply() compresses tool message content."""
|
| 425 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 426 |
-
tool_content = generate_long_json(100)
|
| 427 |
-
messages = [
|
| 428 |
-
{"role": "user", "content": "Get data"},
|
| 429 |
-
{"role": "tool", "tool_call_id": "call_1", "content": tool_content},
|
| 430 |
-
]
|
| 431 |
-
|
| 432 |
-
result = compressor.apply(messages, tokenizer)
|
| 433 |
-
|
| 434 |
-
# Tool content should be compressed
|
| 435 |
-
assert result.messages[1]["content"] != tool_content
|
| 436 |
-
assert "compressed content here" in result.messages[1]["content"]
|
| 437 |
-
assert len(result.transforms_applied) > 0
|
| 438 |
-
|
| 439 |
-
def test_apply_compresses_long_assistant_messages(
|
| 440 |
-
self, default_config, tokenizer, mock_llmlingua
|
| 441 |
-
):
|
| 442 |
-
"""apply() compresses long assistant messages."""
|
| 443 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 444 |
-
long_content = generate_long_text(1000)
|
| 445 |
-
messages = [
|
| 446 |
-
{"role": "user", "content": "Tell me a story"},
|
| 447 |
-
{"role": "assistant", "content": long_content},
|
| 448 |
-
]
|
| 449 |
-
|
| 450 |
-
result = compressor.apply(messages, tokenizer)
|
| 451 |
-
|
| 452 |
-
# Assistant content should be compressed (>500 chars)
|
| 453 |
-
assert result.messages[1]["content"] != long_content
|
| 454 |
-
|
| 455 |
-
def test_apply_passes_through_short_messages(self, default_config, tokenizer, mock_llmlingua):
|
| 456 |
-
"""apply() passes through short messages unchanged."""
|
| 457 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 458 |
-
messages = [
|
| 459 |
-
{"role": "user", "content": "Hello"},
|
| 460 |
-
{"role": "assistant", "content": "Hi there!"},
|
| 461 |
-
]
|
| 462 |
-
|
| 463 |
-
result = compressor.apply(messages, tokenizer)
|
| 464 |
-
|
| 465 |
-
# Short messages unchanged
|
| 466 |
-
assert result.messages[0]["content"] == "Hello"
|
| 467 |
-
assert result.messages[1]["content"] == "Hi there!"
|
| 468 |
-
|
| 469 |
-
def test_apply_tracks_transform_metadata(self, default_config, tokenizer, mock_llmlingua):
|
| 470 |
-
"""apply() returns proper TransformResult metadata."""
|
| 471 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 472 |
-
messages = [
|
| 473 |
-
{"role": "tool", "tool_call_id": "call_1", "content": generate_long_json(100)},
|
| 474 |
-
]
|
| 475 |
-
|
| 476 |
-
result = compressor.apply(messages, tokenizer)
|
| 477 |
-
|
| 478 |
-
assert result.tokens_before > 0
|
| 479 |
-
assert result.tokens_after > 0
|
| 480 |
-
assert len(result.transforms_applied) > 0
|
| 481 |
-
assert "llmlingua" in result.transforms_applied[0]
|
| 482 |
-
|
| 483 |
-
def test_apply_adds_warning_when_unavailable(self, default_config, tokenizer):
|
| 484 |
-
"""apply() adds warning when llmlingua unavailable."""
|
| 485 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 486 |
-
messages = [{"role": "user", "content": "test"}]
|
| 487 |
-
|
| 488 |
-
with patch(
|
| 489 |
-
"headroom.transforms.llmlingua_compressor._check_llmlingua_available",
|
| 490 |
-
return_value=False,
|
| 491 |
-
):
|
| 492 |
-
result = compressor.apply(messages, tokenizer)
|
| 493 |
-
|
| 494 |
-
assert len(result.warnings) > 0
|
| 495 |
-
assert "llmlingua" in result.warnings[0].lower()
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
# =============================================================================
|
| 499 |
-
# TestDeviceResolution
|
| 500 |
-
# =============================================================================
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
class TestDeviceResolution:
|
| 504 |
-
"""Tests for device resolution logic."""
|
| 505 |
-
|
| 506 |
-
def test_resolve_explicit_device(self, default_config):
|
| 507 |
-
"""Explicit device is returned unchanged."""
|
| 508 |
-
config = LLMLinguaConfig(device="cuda")
|
| 509 |
-
compressor = LLMLinguaCompressor(config)
|
| 510 |
-
|
| 511 |
-
assert compressor._resolve_device() == "cuda"
|
| 512 |
-
|
| 513 |
-
def test_resolve_auto_to_cpu_no_torch(self, default_config):
|
| 514 |
-
"""Auto resolves to CPU when torch unavailable."""
|
| 515 |
-
config = LLMLinguaConfig(device="auto")
|
| 516 |
-
compressor = LLMLinguaCompressor(config)
|
| 517 |
-
|
| 518 |
-
with patch.dict("sys.modules", {"torch": None}):
|
| 519 |
-
with patch(
|
| 520 |
-
"headroom.transforms.llmlingua_compressor.LLMLinguaCompressor._resolve_device"
|
| 521 |
-
) as mock_resolve:
|
| 522 |
-
mock_resolve.return_value = "cpu"
|
| 523 |
-
assert compressor._resolve_device() == "cpu"
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
# =============================================================================
|
| 527 |
-
# TestCCRIntegration
|
| 528 |
-
# =============================================================================
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
class TestCCRIntegration:
|
| 532 |
-
"""Tests for CCR (Compress-Cache-Retrieve) integration."""
|
| 533 |
-
|
| 534 |
-
def test_ccr_stores_original(self, mock_llmlingua):
|
| 535 |
-
"""Compressed content is stored in CCR when enabled."""
|
| 536 |
-
config = LLMLinguaConfig(
|
| 537 |
-
enable_ccr=True,
|
| 538 |
-
min_tokens_for_compression=10,
|
| 539 |
-
)
|
| 540 |
-
compressor = LLMLinguaCompressor(config)
|
| 541 |
-
content = generate_long_text(200)
|
| 542 |
-
|
| 543 |
-
with patch(
|
| 544 |
-
"headroom.transforms.llmlingua_compressor.LLMLinguaCompressor._store_in_ccr"
|
| 545 |
-
) as mock_store:
|
| 546 |
-
mock_store.return_value = "hash123"
|
| 547 |
-
|
| 548 |
-
result = compressor.compress(content)
|
| 549 |
-
|
| 550 |
-
mock_store.assert_called_once()
|
| 551 |
-
assert result.cache_key == "hash123"
|
| 552 |
-
|
| 553 |
-
def test_ccr_skipped_when_disabled(self, mock_llmlingua):
|
| 554 |
-
"""CCR is not used when disabled in config."""
|
| 555 |
-
config = LLMLinguaConfig(
|
| 556 |
-
enable_ccr=False,
|
| 557 |
-
min_tokens_for_compression=10,
|
| 558 |
-
)
|
| 559 |
-
compressor = LLMLinguaCompressor(config)
|
| 560 |
-
content = generate_long_text(200)
|
| 561 |
-
|
| 562 |
-
with patch(
|
| 563 |
-
"headroom.transforms.llmlingua_compressor.LLMLinguaCompressor._store_in_ccr"
|
| 564 |
-
) as mock_store:
|
| 565 |
-
result = compressor.compress(content)
|
| 566 |
-
|
| 567 |
-
mock_store.assert_not_called()
|
| 568 |
-
assert result.cache_key is None
|
| 569 |
-
|
| 570 |
-
def test_ccr_handles_storage_error(self, mock_llmlingua):
|
| 571 |
-
"""CCR storage errors are handled gracefully."""
|
| 572 |
-
config = LLMLinguaConfig(
|
| 573 |
-
enable_ccr=True,
|
| 574 |
-
min_tokens_for_compression=10,
|
| 575 |
-
)
|
| 576 |
-
compressor = LLMLinguaCompressor(config)
|
| 577 |
-
content = generate_long_text(200)
|
| 578 |
-
|
| 579 |
-
with patch(
|
| 580 |
-
"headroom.transforms.llmlingua_compressor.LLMLinguaCompressor._store_in_ccr"
|
| 581 |
-
) as mock_store:
|
| 582 |
-
# Return None to simulate storage failure (internal error handling)
|
| 583 |
-
mock_store.return_value = None
|
| 584 |
-
|
| 585 |
-
# Should not raise
|
| 586 |
-
result = compressor.compress(content)
|
| 587 |
-
|
| 588 |
-
# Storage failed, so cache_key should be None
|
| 589 |
-
assert result.cache_key is None
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
# =============================================================================
|
| 593 |
-
# TestConvenienceFunction
|
| 594 |
-
# =============================================================================
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
class TestConvenienceFunction:
|
| 598 |
-
"""Tests for compress_with_llmlingua convenience function."""
|
| 599 |
-
|
| 600 |
-
def test_compress_with_llmlingua_basic(self, mock_llmlingua):
|
| 601 |
-
"""compress_with_llmlingua works with default settings."""
|
| 602 |
-
content = generate_long_text(200)
|
| 603 |
-
|
| 604 |
-
# Disable CCR for this test to avoid hash suffix
|
| 605 |
-
with patch(
|
| 606 |
-
"headroom.transforms.llmlingua_compressor.LLMLinguaCompressor._store_in_ccr"
|
| 607 |
-
) as mock_store:
|
| 608 |
-
mock_store.return_value = None
|
| 609 |
-
result = compress_with_llmlingua(content)
|
| 610 |
-
|
| 611 |
-
# Should contain the compressed content
|
| 612 |
-
assert "compressed content here" in result
|
| 613 |
-
|
| 614 |
-
def test_compress_with_llmlingua_custom_rate(self, mock_llmlingua):
|
| 615 |
-
"""compress_with_llmlingua accepts custom compression rate."""
|
| 616 |
-
content = generate_long_text(200)
|
| 617 |
-
|
| 618 |
-
compress_with_llmlingua(content, compression_rate=0.5)
|
| 619 |
-
|
| 620 |
-
# Verify compress_prompt was called
|
| 621 |
-
mock_llmlingua.compress_prompt.assert_called()
|
| 622 |
-
|
| 623 |
-
def test_compress_with_llmlingua_with_context(self, mock_llmlingua):
|
| 624 |
-
"""compress_with_llmlingua passes context."""
|
| 625 |
-
content = generate_long_text(200)
|
| 626 |
-
context = "important keywords"
|
| 627 |
-
|
| 628 |
-
compress_with_llmlingua(content, context=context)
|
| 629 |
-
|
| 630 |
-
call_args = mock_llmlingua.compress_prompt.call_args
|
| 631 |
-
force_tokens = call_args.kwargs.get("force_tokens", [])
|
| 632 |
-
# Context words should be in force_tokens
|
| 633 |
-
assert any("important" in str(t) for t in force_tokens) or len(force_tokens) > 0
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
# =============================================================================
|
| 637 |
-
# TestEdgeCases
|
| 638 |
-
# =============================================================================
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
class TestEdgeCases:
|
| 642 |
-
"""Edge case tests for LLMLingua compressor."""
|
| 643 |
-
|
| 644 |
-
def test_empty_content(self, compressor):
|
| 645 |
-
"""Empty content is handled gracefully."""
|
| 646 |
-
result = compressor.compress("")
|
| 647 |
-
|
| 648 |
-
assert result.compressed == ""
|
| 649 |
-
assert result.compression_ratio == 1.0
|
| 650 |
-
|
| 651 |
-
def test_whitespace_only_content(self, compressor):
|
| 652 |
-
"""Whitespace-only content is handled gracefully."""
|
| 653 |
-
result = compressor.compress(" \n\t\n ")
|
| 654 |
-
|
| 655 |
-
assert result.compression_ratio == 1.0
|
| 656 |
-
|
| 657 |
-
def test_unicode_content(self, default_config, mock_llmlingua):
|
| 658 |
-
"""Unicode content is handled correctly."""
|
| 659 |
-
mock_llmlingua.compress_prompt.return_value = {
|
| 660 |
-
"compressed_prompt": "compressed \u4e2d\u6587 content",
|
| 661 |
-
"origin_tokens": 100,
|
| 662 |
-
"compressed_tokens": 30,
|
| 663 |
-
}
|
| 664 |
-
|
| 665 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 666 |
-
content = "\u4e2d\u6587 \u65e5\u672c\u8a9e " * 100 # Chinese/Japanese text
|
| 667 |
-
|
| 668 |
-
result = compressor.compress(content)
|
| 669 |
-
|
| 670 |
-
assert "\u4e2d\u6587" in result.compressed
|
| 671 |
-
|
| 672 |
-
def test_very_long_content(self, default_config, mock_llmlingua):
|
| 673 |
-
"""Very long content is compressed."""
|
| 674 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 675 |
-
content = generate_long_text(10000)
|
| 676 |
-
|
| 677 |
-
compressor.compress(content)
|
| 678 |
-
|
| 679 |
-
mock_llmlingua.compress_prompt.assert_called_once()
|
| 680 |
-
|
| 681 |
-
def test_mixed_content_types(self, default_config, mock_llmlingua):
|
| 682 |
-
"""Mixed content (JSON with text) is handled."""
|
| 683 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 684 |
-
# JSON-like but with extra text
|
| 685 |
-
content = 'Some preamble text\n{"key": "value"}\nMore text after'
|
| 686 |
-
|
| 687 |
-
# Should not crash
|
| 688 |
-
result = compressor.compress(content)
|
| 689 |
-
assert result is not None
|
| 690 |
-
|
| 691 |
-
def test_malformed_json_content(self, default_config, mock_llmlingua):
|
| 692 |
-
"""Malformed JSON is treated as text."""
|
| 693 |
-
compressor = LLMLinguaCompressor(default_config)
|
| 694 |
-
content = "{malformed: json, missing quotes" * 50
|
| 695 |
-
|
| 696 |
-
rate = compressor._get_compression_rate(content, None)
|
| 697 |
-
|
| 698 |
-
# Should not detect as JSON
|
| 699 |
-
assert rate == default_config.text_compression_rate
|
| 700 |
-
|
| 701 |
-
def test_force_tokens_list_handling(self, default_config, mock_llmlingua):
|
| 702 |
-
"""Force tokens list is properly passed."""
|
| 703 |
-
config = LLMLinguaConfig(
|
| 704 |
-
force_tokens=["keep", "these", "tokens"],
|
| 705 |
-
min_tokens_for_compression=10,
|
| 706 |
-
)
|
| 707 |
-
compressor = LLMLinguaCompressor(config)
|
| 708 |
-
content = generate_long_text(200)
|
| 709 |
-
|
| 710 |
-
compressor.compress(content)
|
| 711 |
-
|
| 712 |
-
call_args = mock_llmlingua.compress_prompt.call_args
|
| 713 |
-
force_tokens = call_args.kwargs.get("force_tokens", [])
|
| 714 |
-
assert "keep" in force_tokens
|
| 715 |
-
assert "these" in force_tokens
|
| 716 |
-
assert "tokens" in force_tokens
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
# =============================================================================
|
| 720 |
-
# Integration Tests (only run if llmlingua is installed)
|
| 721 |
-
# =============================================================================
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
@pytest.mark.skipif(not LLMLINGUA_INSTALLED, reason="llmlingua not installed")
|
| 725 |
-
class TestLLMLinguaIntegration:
|
| 726 |
-
"""Integration tests that require actual llmlingua installation.
|
| 727 |
-
|
| 728 |
-
These tests verify the actual compression behavior and should be run
|
| 729 |
-
in environments where llmlingua is installed.
|
| 730 |
-
"""
|
| 731 |
-
|
| 732 |
-
def test_actual_compression(self):
|
| 733 |
-
"""Test actual compression with real llmlingua."""
|
| 734 |
-
config = LLMLinguaConfig(
|
| 735 |
-
target_compression_rate=0.3,
|
| 736 |
-
min_tokens_for_compression=50,
|
| 737 |
-
enable_ccr=False,
|
| 738 |
-
)
|
| 739 |
-
compressor = LLMLinguaCompressor(config)
|
| 740 |
-
content = generate_long_text(500)
|
| 741 |
-
|
| 742 |
-
result = compressor.compress(content)
|
| 743 |
-
|
| 744 |
-
# Should achieve actual compression
|
| 745 |
-
assert result.compression_ratio < 1.0
|
| 746 |
-
assert result.tokens_saved > 0
|
| 747 |
-
assert len(result.compressed) < len(content)
|
| 748 |
-
|
| 749 |
-
def test_actual_json_compression(self):
|
| 750 |
-
"""Test JSON content compression with real llmlingua."""
|
| 751 |
-
config = LLMLinguaConfig(
|
| 752 |
-
target_compression_rate=0.35,
|
| 753 |
-
min_tokens_for_compression=50,
|
| 754 |
-
enable_ccr=False,
|
| 755 |
-
)
|
| 756 |
-
compressor = LLMLinguaCompressor(config)
|
| 757 |
-
content = generate_long_json(50)
|
| 758 |
-
|
| 759 |
-
result = compressor.compress(content, content_type="json")
|
| 760 |
-
|
| 761 |
-
assert result.compression_ratio < 1.0
|
| 762 |
-
|
| 763 |
-
def test_actual_code_compression(self):
|
| 764 |
-
"""Test code content compression with real llmlingua."""
|
| 765 |
-
config = LLMLinguaConfig(
|
| 766 |
-
target_compression_rate=0.4,
|
| 767 |
-
min_tokens_for_compression=50,
|
| 768 |
-
enable_ccr=False,
|
| 769 |
-
)
|
| 770 |
-
compressor = LLMLinguaCompressor(config)
|
| 771 |
-
content = generate_long_code(30)
|
| 772 |
-
|
| 773 |
-
result = compressor.compress(content, content_type="code")
|
| 774 |
-
|
| 775 |
-
assert result.compression_ratio < 1.0
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
# =============================================================================
|
| 779 |
-
# TestMemoryManagement
|
| 780 |
-
# =============================================================================
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
class TestMemoryManagement:
|
| 784 |
-
"""Tests for memory management functions (unload_llmlingua_model, is_llmlingua_model_loaded)."""
|
| 785 |
-
|
| 786 |
-
def test_is_model_loaded_returns_false_initially(self):
|
| 787 |
-
"""is_llmlingua_model_loaded returns False when no model loaded."""
|
| 788 |
-
# Ensure model is unloaded
|
| 789 |
-
with patch(
|
| 790 |
-
"headroom.transforms.llmlingua_compressor._llmlingua_instance",
|
| 791 |
-
None,
|
| 792 |
-
):
|
| 793 |
-
assert is_llmlingua_model_loaded() is False
|
| 794 |
-
|
| 795 |
-
def test_is_model_loaded_returns_true_when_loaded(self):
|
| 796 |
-
"""is_llmlingua_model_loaded returns True when model is loaded."""
|
| 797 |
-
mock_instance = MagicMock()
|
| 798 |
-
|
| 799 |
-
with patch(
|
| 800 |
-
"headroom.transforms.llmlingua_compressor._llmlingua_instance",
|
| 801 |
-
mock_instance,
|
| 802 |
-
):
|
| 803 |
-
assert is_llmlingua_model_loaded() is True
|
| 804 |
-
|
| 805 |
-
def test_unload_returns_false_when_no_model(self):
|
| 806 |
-
"""unload_llmlingua_model returns False when no model loaded."""
|
| 807 |
-
import headroom.transforms.llmlingua_compressor as module
|
| 808 |
-
|
| 809 |
-
# Save original
|
| 810 |
-
original = module._llmlingua_instance
|
| 811 |
-
|
| 812 |
-
try:
|
| 813 |
-
module._llmlingua_instance = None
|
| 814 |
-
result = unload_llmlingua_model()
|
| 815 |
-
assert result is False
|
| 816 |
-
finally:
|
| 817 |
-
module._llmlingua_instance = original
|
| 818 |
-
|
| 819 |
-
def test_unload_clears_instance(self):
|
| 820 |
-
"""unload_llmlingua_model clears the global instance."""
|
| 821 |
-
import headroom.transforms.llmlingua_compressor as module
|
| 822 |
-
|
| 823 |
-
# Save original
|
| 824 |
-
original = module._llmlingua_instance
|
| 825 |
-
|
| 826 |
-
try:
|
| 827 |
-
# Set a mock instance
|
| 828 |
-
mock_instance = MagicMock()
|
| 829 |
-
mock_instance._model_name = "test-model"
|
| 830 |
-
module._llmlingua_instance = mock_instance
|
| 831 |
-
|
| 832 |
-
# Unload
|
| 833 |
-
result = unload_llmlingua_model()
|
| 834 |
-
|
| 835 |
-
assert result is True
|
| 836 |
-
assert module._llmlingua_instance is None
|
| 837 |
-
finally:
|
| 838 |
-
module._llmlingua_instance = original
|
| 839 |
-
|
| 840 |
-
def test_unload_clears_cuda_cache(self):
|
| 841 |
-
"""unload_llmlingua_model attempts to clear CUDA cache."""
|
| 842 |
-
import headroom.transforms.llmlingua_compressor as module
|
| 843 |
-
|
| 844 |
-
original = module._llmlingua_instance
|
| 845 |
-
|
| 846 |
-
try:
|
| 847 |
-
mock_instance = MagicMock()
|
| 848 |
-
mock_instance._model_name = "test-model"
|
| 849 |
-
module._llmlingua_instance = mock_instance
|
| 850 |
-
|
| 851 |
-
mock_torch = MagicMock()
|
| 852 |
-
mock_torch.cuda.is_available.return_value = True
|
| 853 |
-
|
| 854 |
-
with patch.dict("sys.modules", {"torch": mock_torch}):
|
| 855 |
-
with patch(
|
| 856 |
-
"headroom.transforms.llmlingua_compressor.torch",
|
| 857 |
-
mock_torch,
|
| 858 |
-
create=True,
|
| 859 |
-
):
|
| 860 |
-
result = unload_llmlingua_model()
|
| 861 |
-
|
| 862 |
-
assert result is True
|
| 863 |
-
finally:
|
| 864 |
-
module._llmlingua_instance = original
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
# =============================================================================
|
| 868 |
-
# TestThreadSafety
|
| 869 |
-
# =============================================================================
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
class TestThreadSafety:
|
| 873 |
-
"""Tests for thread safety of model loading."""
|
| 874 |
-
|
| 875 |
-
def test_lock_exists(self):
|
| 876 |
-
"""Verify thread lock is available."""
|
| 877 |
-
import headroom.transforms.llmlingua_compressor as module
|
| 878 |
-
|
| 879 |
-
assert hasattr(module, "_llmlingua_lock")
|
| 880 |
-
import threading
|
| 881 |
-
|
| 882 |
-
assert isinstance(module._llmlingua_lock, type(threading.Lock()))
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
# =============================================================================
|
| 886 |
-
# TestErrorMessages
|
| 887 |
-
# =============================================================================
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
class TestErrorMessages:
|
| 891 |
-
"""Tests for improved error messages."""
|
| 892 |
-
|
| 893 |
-
def test_import_error_message_includes_install_hint(self):
|
| 894 |
-
"""ImportError includes installation instructions."""
|
| 895 |
-
with patch(
|
| 896 |
-
"headroom.transforms.llmlingua_compressor._check_llmlingua_available",
|
| 897 |
-
return_value=False,
|
| 898 |
-
):
|
| 899 |
-
from headroom.transforms.llmlingua_compressor import _get_llmlingua_compressor
|
| 900 |
-
|
| 901 |
-
with pytest.raises(ImportError) as exc_info:
|
| 902 |
-
_get_llmlingua_compressor("test-model", "cpu")
|
| 903 |
-
|
| 904 |
-
error_msg = str(exc_info.value)
|
| 905 |
-
assert "pip install headroom-ai[llmlingua]" in error_msg
|
| 906 |
-
assert "2GB" in error_msg or "disk space" in error_msg.lower()
|
| 907 |
-
|
| 908 |
-
def test_oom_error_provides_helpful_suggestions(self):
|
| 909 |
-
"""Out of memory error provides helpful suggestions."""
|
| 910 |
-
import headroom.transforms.llmlingua_compressor as module
|
| 911 |
-
|
| 912 |
-
# Save original state
|
| 913 |
-
original_instance = module._llmlingua_instance
|
| 914 |
-
original_available = module._llmlingua_available
|
| 915 |
-
|
| 916 |
-
try:
|
| 917 |
-
module._llmlingua_instance = None
|
| 918 |
-
module._llmlingua_available = True
|
| 919 |
-
|
| 920 |
-
# Create a mock that raises OOM when called
|
| 921 |
-
mock_prompt_compressor_class = MagicMock()
|
| 922 |
-
mock_prompt_compressor_class.side_effect = RuntimeError("CUDA out of memory")
|
| 923 |
-
|
| 924 |
-
with patch.dict("sys.modules", {"llmlingua": MagicMock()}):
|
| 925 |
-
with patch(
|
| 926 |
-
"llmlingua.PromptCompressor",
|
| 927 |
-
mock_prompt_compressor_class,
|
| 928 |
-
):
|
| 929 |
-
from headroom.transforms.llmlingua_compressor import (
|
| 930 |
-
_get_llmlingua_compressor,
|
| 931 |
-
)
|
| 932 |
-
|
| 933 |
-
with pytest.raises(RuntimeError) as exc_info:
|
| 934 |
-
_get_llmlingua_compressor("test-model", "cuda")
|
| 935 |
-
|
| 936 |
-
error_msg = str(exc_info.value)
|
| 937 |
-
# Should include helpful suggestions
|
| 938 |
-
assert "cpu" in error_msg.lower() or "memory" in error_msg.lower()
|
| 939 |
-
finally:
|
| 940 |
-
module._llmlingua_instance = original_instance
|
| 941 |
-
module._llmlingua_available = original_available
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|