fix(guardrails/headroom): stop compressing the turn the model must act on - #35294
Conversation
Greptile SummaryThe PR prevents Headroom from compressing protected prompt turns and preserves message boundaries and assistant text during compression write-back and retrieval follow-ups
Confidence Score: 5/5The PR appears safe to merge No blocking failure remains
|
| Filename | Overview |
|---|---|
| litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py | Withholds protected turns from compression, restores compressed history positionally, validates response cardinality, and preserves assistant text in retrieval follow-ups |
| litellm/llms/anthropic/chat/guardrail_translation/handler.py | Converts structured-message write-back by tool-exchange ownership instead of merging the complete message array |
| litellm/litellm_core_utils/prompt_templates/factory.py | Adds ordered grouping of assistant tool calls with their contiguous owned tool-result rows |
| litellm/compression/compress.py | Exposes the existing protected-index policy for reuse by compression guardrails |
| litellm/proxy/guardrails/guardrail_hooks/content_text.py | Centralizes assistant-text extraction for chat completions, Anthropic Messages, and Responses API shapes |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py | Adds regression coverage for protected turns, history compression, fail policy, and retrieval follow-up reconstruction |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/test_structured_messages_writeback.py | Covers Anthropic write-back boundaries and preservation of real tool results under parameter sanitization |
Reviews (2): Last reviewed commit: "fix(guardrails/headroom): stop compressi..." | Re-trigger Greptile
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…t on The Headroom guardrail sent every message to /v1/compress, including the system prompt and the user's current instruction. On an agentic /v1/messages request the live turn is the largest compressible blob, so it came back as a hash marker; the model then called headroom_retrieve and got its own instruction returned in a tool_result block, which reads as data it fetched rather than a request to act on, so it described the content instead of doing the work. litellm already owns the policy for what a compressor may never rewrite: get_protected_indices covers the system rows, the last user row and the last assistant row, and compress() expands it over whole tool exchanges. Headroom now consults it (promoted from a private name and given tests) and expands it the same way, so the trailing tool result cannot come back as a marker standing in for the result of the call the model just made. Protected rows are withheld from the payload rather than pinned afterwards, so their tokens are not reported as savings that are never applied; the write-back discards a compressed system prompt outright, so that saving never existed. The cost is that a query-aware service no longer sees the newest user message. A response whose row count differs from what was sent can no longer be interleaved with the withheld rows, so it goes through the configured fail policy instead of being adopted. Fail-open now returns the caller's own inputs object: translation handlers detect a rewrite by identity, so a rebuilt copy sent an unchanged request through the Anthropic write-back for nothing. That write-back rebuilt the request with one anthropic_messages_pt call, which merges every run of consecutive user/tool rows, so a tool_result turn and the user turn after it arrived fused. Converting a row at a time would separate them but breaks tool pairing: with modify_params on, an assistant row whose results are converted separately reads as an orphaned tool call and the sanitizer answers it with a synthetic "tool execution skipped" result while dropping the real one. Conversion is now grouped by tool_call_id ownership, which satisfies both, and the same grouping decides which rows headroom protects, so the two agree by construction. The CCR follow-up also dropped any text the model wrote alongside its tool call, and echoed tool calls it had no results for. Both are fixed by reusing compresr's extraction helper, now shared instead of duplicated. Resolves LIT-5018
201ac19 to
50b29ce
Compare
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 50b29ce. Configure here.
TLDR
Problem this solves:
How it solves it:
Relevant issues
/v1/compress; everything older still compressesLinear ticket
Resolves LIT-5018
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Before

After

Live proxy, a real Claude Code shaped
/v1/messagesrequest: top-levelsystemwithcache_control, a tools array whose last tool carriescache_control, an assistanttool_use, atool_result-only user turn, then the live turn holding a slash-command expansion plus the typed line "Reply with exactly one word: ACKNOWLEDGED". A stand-in compression service on :8099 replaces any string content over 400 chars with[Headroom compressed N chars; hash=<24hex>]and serves/v1/retrieve/<hash>.The provider account on this machine is out of credit, so the upstream is a litellm gateway serving a Bedrock-hosted Anthropic model; the request and response are Anthropic
/v1/messagesend to end and the calls cost real money. A direct api.anthropic.com re-run is owed once the account has credit.Before, at
abb0e36ca5:Same request with compression on, same commit:
After, at
50b29ce2eb, byte-identical request:The payload that actually leaves the gateway, captured by pointing the deployment at a recording upstream and replaying the same request through both commits:
abb0e36ca550b29ce2eb[2] usertool_result(124b),text(91b)tool_result(6392b)[3] user[2]text(4797b),text(103b)Before, the 6179-char file body and the live instruction are both 90-byte markers sharing one turn. After, both are intact and the live turn is its own message.
Compression still runs on history. Same rig, a longer session with an older tool exchange ahead of the current one:
The old exchange is still compressed and
headroom_retrieveis still injected, so CCR is untouched; only the current exchange and the live turn are held back.Type
🐛 Bug Fix
Changes
HeadroomGuardrail.apply_guardrailconsultsget_protected_indicesand withholds those rows from/v1/compressinstead of sending everythinginputsobjectThings a reviewer will ask about
Why does a single-turn request stop compressing?
[system, user]is entirely protected, so there is nothing to send and the guardrail returns early. That is the same policycompress()has always applied, and compresr defaults to it too (compress_last_user=False,compress_system=False). If a deployment wants its current prompt compressed, that wants an explicit opt-in field rather than the old behavior of compressing whatever happened to be largest.Why withhold the rows instead of restoring them after the service replies? Both keep the instruction intact, but sending rows whose result we discard reports savings that never land; that is exactly the system-prompt over-count in this ticket. The cost is that a query-aware compressor no longer sees the newest user message, which is called out in a comment at the withholding site.
Why not convert the write-back one message at a time? It breaks tool pairing. With
modify_paramson, converting an assistant row apart from its results makessanitize_messages_for_tool_callingread an orphaned tool call, answer it with a synthetic "tool execution skipped" result, and drop the real one; the file body disappears. Grouping bytool_call_idownership keeps turns separate without splitting an exchange, andtests/test_litellm/proxy/guardrails/guardrail_hooks/test_structured_messages_writeback.py::test_write_back_keeps_real_tool_results_under_modify_paramsfails if anyone regresses to per-row.Why a wall-clock assertion in the grouping test? The first version of that helper accumulated groups by rebuilding a tuple each iteration, which is O(n^2) over an array the caller controls: 20k messages took 312ms, 100k would take minutes. It is linear now (16ms at 100k, 2.06us per message flat from 10k to 80k), and the test fails at 8.97s against a 3s ceiling if anyone reintroduces the accumulator, so the ceiling carries ~200x headroom in the passing direction. The write-back path as a whole is linear on both sides of this change; converting per exchange rather than once costs a 1.55x constant, which is the extra messages the fix deliberately stops merging.
What is deliberately not in here. The ticket also reported that appending
headroom_retrieveafter the client'scache_controlbreakpoint breaks prompt caching. Measured with 24 tools and a breakpoint on the last one, a request creates the cache and the next one reads it back at the same token count, with the injected tool sitting after the breakpoint; steady-state caching still hits, so there is no mechanism to fix and the append order is unchanged. Separately,/v1/responsesbuildsstructured_messages, hands them to the guardrail and never reads the result back, so compression is computed and discarded there while savings are still recorded; that is a different handler and gets its own ticket.Final Attestation
Note
Medium Risk
Changes proxy guardrail request shaping and Anthropic message conversion on every guarded
/v1/messagespath; behavior is heavily tested but mistakes could alter what the model sees or break tool-call sequences.Overview
Fixes LIT-5018: Headroom was compressing the live user instruction, system prompt, and trailing tool exchange, and Anthropic guardrail write-back was merging separate turns.
Headroom now shares
get_protected_indiceswith built-incompress()—system rows, last user, and last assistant are withheld from/v1/compress, with protection expanded over whole tool exchanges viagroup_tool_exchanges. Compressed rows are merged back with_restore_protected_messages; mismatched row counts fail (or fail-open returns the originalinputsby identity). Retrieval follow-ups reuse sharedassistant_text_from_responseand only echo retrieve tool calls.Anthropic write-back converts
group_tool_exchangesunits throughanthropic_messages_ptso tool-result turns stay separate from the following user turn and tool pairing survivesmodify_params.group_tool_exchangesis a new linear-time helper in the prompt factory;assistant_text_from_responsemoves tocontent_text.pyfor Compresr/Headroom reuse. Tests and CI include the compression test path.Reviewed by Cursor Bugbot for commit 50b29ce. Bugbot is set up for automated code reviews on this repo. Configure here.