Skip to content

feat: add litellm.compress() — BM25-based prompt compression with retrieval tool - #25637

Merged
krrish-berri-2 merged 11 commits into
litellm_dev_04_13_2026_p1from
claude/zealous-black
Apr 13, 2026
Merged

feat: add litellm.compress() — BM25-based prompt compression with retrieval tool#25637
krrish-berri-2 merged 11 commits into
litellm_dev_04_13_2026_p1from
claude/zealous-black

Conversation

@krrish-berri-2

@krrish-berri-2 krrish-berri-2 commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds litellm.compress() — a BM25-based prompt compression utility that reduces context size for LLM calls while preserving quality through intelligent message ranking and a retrieval tool for on-demand content recovery.

How it works

  1. Score each message's relevance to the last user message using BM25 (with optional embedding hybrid scoring)
  2. Rank messages and fill a token budget from highest-scoring down
  3. Truncate overflow messages using line-based splitting (preserves code structure)
  4. Stub remaining low-relevance messages with compact pointers
  5. Inject a litellm_content_retrieve tool so the model can recover any stubbed content on demand

Key design decisions

  • Line-based truncation instead of word-based — preserves function boundaries and indentation in code
  • Multiple message truncation — distributes budget across several high-scoring messages rather than fully stubbing all but one
  • BM25 prefix expansion — query terms like "cook" match "cooking", "auth" matches "authentication" without requiring a stemmer
  • Protected messages — system messages, last user message, and last assistant message are never compressed
  • 70% default target — compresses to 70% of trigger threshold, balancing quality and savings

SWE-bench performance (Claude Opus, 5 problems, trigger=10k)

Metric Baseline Compressed Delta
File overlap 1.000 1.000 +0.000
Exact file match 100% 100% +0.0%
Hunk overlap 0.582 0.361 -0.221
Content similarity 0.367 0.373 +0.006
Avg prompt tokens 30,828 6,890 -77.7%
Avg cost/problem $0.488 $0.136 -72.0%

File-level targeting fully preserved. Content similarity matches baseline. 72% cost savings.

New files

  • litellm/compression/ — core compression module (compress, BM25, embedding scorer, stubbing, retrieval tool, content detection)
  • litellm/types/compression.pyCompressedResult TypedDict
  • tests/test_litellm/test_compression.py — 23+ unit tests
  • tests/eval_swe_bench.py — SWE-bench eval harness with proxy metrics (file overlap, hunk overlap, content similarity)
  • scripts/eval_compression.py — HumanEval-style eval harness
  • docs/my-website/docs/completion/prompt_compression.md — docs with performance results

Test plan

  • Unit tests pass: pytest tests/test_litellm/test_compression.py
  • BM25 routing correctness: parametrized test verifies relevant content kept for different queries
  • SWE-bench eval: file overlap preserved at 1.000 with 72% cost savings on Opus
  • Retrieval tool loop: model can recover stubbed content via tool calls

krrish-berri-2 and others added 4 commits April 13, 2026 08:30
Adds a compress() utility that reduces context size for LLM calls using
BM25 relevance scoring (with optional semantic embeddings via
litellm.embedding()). Messages below a token threshold pass through
unchanged; messages above are scored, ranked, and the lowest-relevance
ones replaced with stubs. Originals are cached and a retrieval tool is
injected so the model can recover dropped content on demand.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ng them

When a relevant message was too large to fit in the token budget it was
replaced with a stub, leaving the LLM with no real content to work with.
Now the highest-scoring overflow message is truncated (first 70% + last 30%
of words) to fill the remaining budget, so the LLM always receives actual
content rather than just a retrieval pointer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…kens

"cook" now matches "cooking", "auth" matches "authentication", etc.
Without this, short query terms scored 0 against longer inflected forms
in documents, causing the wrong message to be kept.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ess()

- test_simple_compression: parametrized test verifying BM25 routes the
  right message based on query ("How to cook?" keeps cooking, "Fix auth"
  keeps auth content)
- eval_compression.py: end-to-end eval harness comparing baseline vs
  compressed model performance on HumanEval-style coding problems

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel

vercel Bot commented Apr 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Apr 13, 2026 7:09pm

Request Review

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@codspeed-hq

codspeed-hq Bot commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing claude/zealous-black (6e22974) with main (d319cd8)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces litellm.compress(), a standalone BM25-based prompt compression utility that scores messages for relevance to the last user query, fills a token budget from highest-scoring messages down (keeping, truncating, or stubbing each), and injects a litellm_content_retrieve tool so the model can recover compressed content on demand. The core compression logic is clean and well-tested; the prior review rounds identified the remaining P1 concerns (dict-style access on Pydantic v2 objects in embedding_scorer.py, token-budget/final-count method mismatch, and inline import in compress.py) that still need to be addressed before this is production-ready for the embedding path.

Confidence Score: 4/5

Safe to merge for BM25-only usage; embedding path has an unresolved P1 (dict-style access on Pydantic v2 objects) from prior rounds that still needs fixing.

Prior review rounds flagged three P1 issues (Pydantic v2 dict access in embedding_scorer, token budget/final count method mismatch, inline import) that remain unaddressed. The embedding path will raise TypeError in production. BM25-only compression is solid and well-tested.

litellm/compression/scoring/embedding_scorer.py (dict-style access bug), litellm/compression/compress.py (token counting inconsistency + inline import)

Important Files Changed

Filename Overview
litellm/compression/compress.py Main orchestrator for compress(); uses text= for budget tracking but messages= for final count — prior comment flagged this discrepancy; also has inline import and docstring mismatch noted above.
litellm/compression/scoring/bm25.py Pure-Python BM25 scorer with prefix expansion; logic is correct; if term not in idf guard is unreachable dead code (all query terms are guaranteed present in idf), but benign.
litellm/compression/scoring/embedding_scorer.py Uses dict-style item["embedding"] on Pydantic v2 model objects (TypeError at runtime); inline import litellm is justified to avoid circular import; both issues previously flagged.
tests/test_litellm/test_compression.py 23 unit tests covering BM25, stubbing, detection, and end-to-end flow; _MockResponse uses plain dicts so the dict-style-access bug in embedding_scorer is not exercised, and test_compress_default_target has a stale comment.
tests/eval_swe_bench.py SWE-bench eval harness placed in tests/; makes live litellm.completion() calls requiring real API keys — breaks CI for contributors without credentials (previously flagged).
litellm/compression/message_stubbing.py Stub and truncation helpers; line-based truncation logic is clear and correct; duplicate-key handling terminates safely.
litellm/compression/retrieval_tool.py Builds the OpenAI-format litellm_content_retrieve tool definition; straightforward, no issues.
litellm/compression/content_detection.py Heuristic code/JSON/text classifier; lightweight and self-contained with no external dependencies.
litellm/types/compression.py CompressedResult TypedDict; clean, minimal, correct.
litellm/init.py Adds from .compression import compress — clean, no circular import issues at module load time.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[litellm.compress called] --> B{tokens <= trigger?}
    B -- yes --> C[Return messages unchanged]
    B -- no --> D[Extract last user message as query]
    D --> E[bm25_score_messages]
    E --> F{embedding_model set?}
    F -- yes --> G[embedding_score_messages]
    G --> H[_combine_scores weighted avg]
    F -- no --> I[Use BM25 scores]
    H --> J[Rank indices by score desc]
    I --> J
    J --> K[_get_protected_indices]
    K --> L[Fill token budget from ranked list]
    L --> M{Fits in budget?}
    M -- yes --> N[Keep message as-is]
    M -- no, remaining >= 100 --> O[truncate_message]
    M -- no, remaining < 100 --> P[Skip candidate]
    N --> Q[Build compressed_messages and cache]
    O --> Q
    P --> L
    Q --> R{cache non-empty?}
    R -- yes --> S[build_retrieval_tool]
    R -- no --> T[tools is empty list]
    S --> U[Return CompressedResult]
    T --> U
Loading

Reviews (4): Last reviewed commit: "Revert "feat: add compression_intercepti..." | Re-trigger Greptile

Comment on lines +1 to +10
"""
Unit tests for litellm.compress().
"""

import os

import pytest

import litellm
from litellm.compression.scoring.bm25 import bm25_score_messages

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unit tests not in tests/test_litellm/

These tests live at tests/test_compression.py, but make test-unit (the CI gate) only discovers tests under tests/test_litellm/. Every test in this file will be invisible to the required CI check, so the checklist item "My PR passes all unit tests on make test-unit" is vacuously true here. Per CLAUDE.md, unit tests belong in tests/test_litellm/.

Context Used: CLAUDE.md (source)

Comment on lines +756 to +773
def call_llm(model: str, messages: list[dict]) -> dict:
"""Call model via litellm. Returns dict with response text and usage."""
t0 = time.time()
resp = litellm.completion(
model=model, messages=messages, temperature=0.0, max_tokens=2048
)
latency_ms = (time.time() - t0) * 1000

text = resp.choices[0].message.content or ""
usage = resp.usage

return {
"text": text,
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"total_tokens": usage.total_tokens,
"latency_ms": latency_ms,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Real network calls in tests/ directory

call_llm makes a live litellm.completion() call that requires actual API credentials and hits external endpoints. Only mock tests are permitted in the tests/ directory — real network calls break CI for contributors without credentials. Per the project rule, this file should either be moved out of tests/ (e.g., a top-level eval/ directory) or all LLM calls must be mocked.

Rule Used: What: prevent any tests from being added here that... (source)

Comment on lines +139 to +146
if embedding_model:
from litellm.compression.scoring.embedding_scorer import (
embedding_score_messages,
)

emb_scores = embedding_score_messages(
query, messages, model=embedding_model, cache=compression_cache
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inline import inside function body

Per CLAUDE.md, imports should be at module level, not inside functions. The import of embedding_score_messages is conditional on embedding_model being truthy, but this isn't a circular-import situation — litellm.compression.scoring.embedding_scorer doesn't import back into compress.py. Moving this to the top of the file is straightforward.

Suggested change
if embedding_model:
from litellm.compression.scoring.embedding_scorer import (
embedding_score_messages,
)
emb_scores = embedding_score_messages(
query, messages, model=embedding_model, cache=compression_cache
)
from litellm.compression.scoring.embedding_scorer import embedding_score_messages

(Add to the module-level import block at the top of the file and remove the inline import here.)

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread litellm/compression/compress.py Outdated
Comment on lines +162 to +196
# Count tokens for protected messages
current_tokens = 0
for i in kept_indices:
current_tokens += token_counter(
model=model, text=messages[i].get("content", "") or ""
)

# Fill token budget from highest-scoring messages.
# For each candidate (ranked by relevance):
# - If it fits entirely → keep it as-is.
# - If it doesn't fit but there's meaningful remaining budget → truncate it
# to fill that budget (so the LLM always has real content to work with).
# - Otherwise → stub it (pointer only, content goes to cache).
# We only truncate one message (the highest-scoring one that overflows) so
# the budget is consumed and the rest are stubbed cleanly.
truncated_overrides: Dict[int, dict] = {} # idx -> truncated message dict

for idx in ranked_indices:
if idx in kept_indices:
continue
msg_content = messages[idx].get("content", "") or ""
msg_tokens = token_counter(model=model, text=msg_content)
remaining = compression_target - current_tokens

if current_tokens + msg_tokens <= compression_target:
# Fits entirely
kept_indices.add(idx)
current_tokens += msg_tokens
elif remaining >= 100 and idx not in truncated_overrides:
# Too large to fit whole, but we have budget — truncate it.
# Only do this once (the highest-scoring overflow message).
truncated = truncate_message(messages[idx], remaining)
truncated_overrides[idx] = truncated
kept_indices.add(idx)
current_tokens = compression_target # budget consumed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Token budget uses text= but final count uses messages=

The budget accumulation calls token_counter(model=model, text=...) which counts only raw content tokens, while the final compressed count uses token_counter(model=model, messages=...) which adds per-message overhead (~4 tokens/message for OpenAI models). For small compression_target values (e.g. < 1000), the accumulated overhead across many messages can cause the actual compressed_tokens to meaningfully exceed compression_target. Both loops should use the same counting method for consistency.

@codecov

codecov Bot commented Apr 13, 2026

Copy link
Copy Markdown

krrish-berri-2 and others added 4 commits April 13, 2026 09:12
Uses princeton-nlp/SWE-bench_Lite_bm25_27K which bundles ~27k tokens of
BM25-retrieved repo context per problem — large enough to meaningfully
stress litellm.compress() without Docker or GitHub API calls.

Proxy eval metrics (no test runner needed):
  - has_diff: model produced a valid unified diff
  - file_overlap: fraction of gold-patch files in generated patch
  - exact_file_match: generated patch touches exactly the right files

Run: python tests/eval_swe_bench.py --model gpt-4o --problems 10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add HuggingFace API fallback so the SWE-bench loader doesn't need
  the `datasets` library (avoids pyarrow/numpy binary compat issues)
- Insert repo root into sys.path so compression module resolves
  from worktrees
- Use direct import of litellm_compress to avoid __getattr__ issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…get, 70% default target

- Switch truncate_message from word-based to line-based splitting to
  preserve code structure (function boundaries, indentation)
- Allow multiple messages to be truncated instead of burning entire
  budget on one overflow message
- Raise default compression target from 50% to 70% of trigger for
  better quality/cost tradeoff
- Add --compression-target CLI arg to SWE-bench eval harness
- Move tests to canonical locations (tests/test_litellm/, scripts/)
- Add docs page and sidebar entries for compress()

Eval results (5 problems, Opus, trigger=10k):
  Hunk overlap delta improved from -0.417 to -0.221
  Content similarity now matches baseline (+0.006)
  Cost savings: 72%

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Include benchmark table from Opus eval (5 problems, trigger=10k)
showing 72% cost savings with file-level quality fully preserved.
Add metric explanations and eval runner examples.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@krrish-berri-2 krrish-berri-2 changed the title Prompt Compression - add basic prompt compression feat: add litellm.compress() — BM25-based prompt compression with retrieval tool Apr 13, 2026
The exact line-number matching was too brittle — LLM-generated patches
often target the right code region but with slightly offset line numbers.
Switch to hunk-level overlap with a 10-line tolerance window so nearby
edits count as matches. This better reflects actual patch quality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a proxy callback that automatically compresses incoming /v1/messages
payloads above a configurable token threshold, runs the retrieval tool
loop server-side, and returns the final response. This brings compress()
support to proxy deployments (e.g. Claude Code via /v1/messages).

- New callback: litellm/integrations/compression_interception/
- Proxy config: compression_interception_params in litellm_settings
- Support for input_type param in compress() (openai vs anthropic)
- Docs: proxy setup instructions with YAML config example
- Tests: 139-line unit test suite for the interception handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread litellm/integrations/compression_interception/handler.py Fixed
Comment thread litellm/integrations/compression_interception/handler.py Fixed
Comment thread litellm/integrations/compression_interception/handler.py Fixed
Comment thread litellm/proxy/common_utils/callback_utils.py Fixed
response = litellm.embedding(**kwargs)

# Extract embedding vectors
embeddings = [item["embedding"] for item in response.data]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dict-style access on Pydantic v2 model objects

response.data returns a list of EmbeddingObject instances (Pydantic v2 models). Pydantic v2 models do not expose __getitem__ by default, so item["embedding"] raises TypeError. Use attribute access instead:

Suggested change
embeddings = [item["embedding"] for item in response.data]
embeddings = [item.embedding for item in response.data]

Comment on lines +277 to +306
kwargs_for_followup = {
k: v
for k, v in kwargs.items()
if not k.startswith("_compression_interception")
and not k.startswith("_websearch_interception")
and k != "litellm_logging_obj"
}
optional_params_without_max_tokens = {
k: v
for k, v in anthropic_messages_optional_request_params.items()
if k != "max_tokens"
}

max_tokens = anthropic_messages_optional_request_params.get(
"max_tokens", kwargs.get("max_tokens", 1024)
)
full_model_name = model
if logging_obj is not None:
agentic_params = logging_obj.model_call_details.get(
"agentic_loop_params", {}
)
full_model_name = agentic_params.get("model", model)

return await anthropic_messages.acreate(
max_tokens=max_tokens,
messages=follow_up_messages,
model=full_model_name,
**optional_params_without_max_tokens,
**kwargs_for_followup,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Potential TypeError from duplicate keyword arguments in acreate

kwargs_for_followup is constructed from kwargs with only _compression_interception*, _websearch_interception*, and litellm_logging_obj stripped out. In typical LiteLLM proxy usage kwargs also carries model, messages, and max_tokens. Those three are also passed as explicit keyword arguments to acreate, so Python raises TypeError: acreate() got multiple values for keyword argument 'messages' (and similarly for model and max_tokens).

The existing unit test avoids this by passing kwargs={}, so the path is untested with a realistic payload. At minimum, filter those keys out of kwargs_for_followup:

kwargs_for_followup = {
    k: v
    for k, v in kwargs.items()
    if not k.startswith("_compression_interception")
    and not k.startswith("_websearch_interception")
    and k not in ("litellm_logging_obj", "model", "messages", "max_tokens", "tools", "stream")
}

@krrish-berri-2
krrish-berri-2 changed the base branch from main to litellm_dev_04_13_2026_p1 April 13, 2026 19:23
@krrish-berri-2
krrish-berri-2 merged commit 26c7412 into litellm_dev_04_13_2026_p1 Apr 13, 2026
50 of 51 checks passed
@krrish-berri-2
krrish-berri-2 deleted the claude/zealous-black branch April 13, 2026 19:23
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…rieval tool (BerriAI#25637)

* feat: add litellm.compress() for BM25-based context compression

Adds a compress() utility that reduces context size for LLM calls using
BM25 relevance scoring (with optional semantic embeddings via
litellm.embedding()). Messages below a token threshold pass through
unchanged; messages above are scored, ranked, and the lowest-relevance
ones replaced with stubs. Originals are cached and a retrieval tool is
injected so the model can recover dropped content on demand.


* fix(compress): truncate high-scoring messages instead of fully stubbing them

When a relevant message was too large to fit in the token budget it was
replaced with a stub, leaving the LLM with no real content to work with.
Now the highest-scoring overflow message is truncated (first 70% + last 30%
of words) to fill the remaining budget, so the LLM always receives actual
content rather than just a retrieval pointer.


* fix(bm25): add prefix expansion so query terms match inflected doc tokens

"cook" now matches "cooking", "auth" matches "authentication", etc.
Without this, short query terms scored 0 against longer inflected forms
in documents, causing the wrong message to be kept.


* test: add routing correctness test and eval harness for litellm.compress()

- test_simple_compression: parametrized test verifying BM25 routes the
  right message based on query ("How to cook?" keeps cooking, "Fix auth"
  keeps auth content)
- eval_compression.py: end-to-end eval harness comparing baseline vs
  compressed model performance on HumanEval-style coding problems


* feat(eval): add SWE-bench Lite compression eval harness

Uses princeton-nlp/SWE-bench_Lite_bm25_27K which bundles ~27k tokens of
BM25-retrieved repo context per problem — large enough to meaningfully
stress litellm.compress() without Docker or GitHub API calls.

Proxy eval metrics (no test runner needed):
  - has_diff: model produced a valid unified diff
  - file_overlap: fraction of gold-patch files in generated patch
  - exact_file_match: generated patch touches exactly the right files

Run: python tests/eval_swe_bench.py --model gpt-4o --problems 10


* fix(eval): robust dataset loading + sys.path fix for worktree imports

- Add HuggingFace API fallback so the SWE-bench loader doesn't need
  the `datasets` library (avoids pyarrow/numpy binary compat issues)
- Insert repo root into sys.path so compression module resolves
  from worktrees
- Use direct import of litellm_compress to avoid __getattr__ issues


* improve compression quality: line-based truncation, multi-message budget, 70% default target

- Switch truncate_message from word-based to line-based splitting to
  preserve code structure (function boundaries, indentation)
- Allow multiple messages to be truncated instead of burning entire
  budget on one overflow message
- Raise default compression target from 50% to 70% of trigger for
  better quality/cost tradeoff
- Add --compression-target CLI arg to SWE-bench eval harness
- Move tests to canonical locations (tests/test_litellm/, scripts/)
- Add docs page and sidebar entries for compress()

Eval results (5 problems, Opus, trigger=10k):
  Hunk overlap delta improved from -0.417 to -0.221
  Content similarity now matches baseline (+0.006)
  Cost savings: 72%


* docs: add SWE-bench performance results to compress() docs

Include benchmark table from Opus eval (5 problems, trigger=10k)
showing 72% cost savings with file-level quality fully preserved.
Add metric explanations and eval runner examples.


* fix(eval): use tolerance-based hunk overlap metric

The exact line-number matching was too brittle — LLM-generated patches
often target the right code region but with slightly offset line numbers.
Switch to hunk-level overlap with a 10-line tolerance window so nearby
edits count as matches. This better reflects actual patch quality.


* feat: add compression_interception callback for LiteLLM Proxy

Add a proxy callback that automatically compresses incoming /v1/messages
payloads above a configurable token threshold, runs the retrieval tool
loop server-side, and returns the final response. This brings compress()
support to proxy deployments (e.g. Claude Code via /v1/messages).

- New callback: litellm/integrations/compression_interception/
- Proxy config: compression_interception_params in litellm_settings
- Support for input_type param in compress() (openai vs anthropic)
- Docs: proxy setup instructions with YAML config example
- Tests: 139-line unit test suite for the interception handler


* Revert "feat: add compression_interception callback for LiteLLM Proxy"

This reverts commit 72bd5cb.

---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants