fix(guardrails): preserve cache_control breakpoints in compresr write-back - #34660
Conversation
…-back Anthropic cache_control breakpoints are positional: each one caches the prefix ending at the part that carries it. Compresr flattened every text part of a message into one string and wrote the compressed result back into the first text part only, which dropped every later breakpoint and, when a non-text part sat between text parts, moved the trailing text to the other side of it. The positional invariant now has one owner. guardrail_hooks/content_text.py holds content_to_text alongside is_all_text_parts and merge_rewritten_text_parts, so a compressed string is only ever written back over a contiguous run of text parts, and the merged part carries the last declared breakpoint and its TTL. Compresr consumes that owner at both ends: _select_targets no longer selects a row holding a non-text part, and _replace_text_in_content returns such a row unchanged rather than merging across it. Rows whose content is a plain string are unaffected. Mixed rows therefore stop being compressed, which is a deliberate trade; no single-string write-back can preserve a breakpoint across a non-text part, so the alternative is silently caching a different prefix than the caller configured.
Greptile SummaryThis PR prevents Compresr from rewriting mixed-content rows and preserves the final cache-control breakpoint when compressed all-text parts are merged.
Confidence Score: 5/5The PR appears safe to merge; no actionable correctness or security defects were identified. Mixed-content rows are now excluded before compression, while all-text rows retain their final cache-control breakpoint during write-back, with focused tests covering both corrected behaviors.
|
| Filename | Overview |
|---|---|
| litellm/proxy/guardrails/guardrail_hooks/compresr/compresr.py | Uses shared content helpers, skips mixed-content rows, and safely writes compressed text back only to strings or all-text part lists. |
| litellm/proxy/guardrails/guardrail_hooks/content_text.py | Adds focused helpers that flatten textual content, identify safe all-text rows, and retain the last positional cache-control breakpoint during merging. |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/test_compresr.py | Adds meaningful regression coverage for preserving the final cache-control value and leaving mixed-content ordering untouched. |
Reviews (1): Last reviewed commit: "fix(guardrails): preserve cache_control ..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
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 c63e24b. Configure here.
TLDR
Problem this solves:
cache_controlbreakpoints declared on any text block after the first, so a caller's configured prompt cache never formedHow it solves it:
Relevant issues
cache_controlwhen it rewrites a multi-part row, dropping every later breakpointLinear ticket
Resolves LIT-4804
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
Live proxy on
localhost:4804against realclaude-sonnet-5through/v1/messages, so the cache counters below are Anthropic's own accounting. The compresr compression service is a local stub that records every context it is handed and returns a deterministic ~4000 token summary; that is the component under test's collaborator, and stubbing it is what makes "which rows did the gateway select, and what text did it flatten" observable.Two request shapes, byte-identical across the before and after runs:
Before, on unfixed code:
The 27322 chars on B is the hazard in one number; it is block A plus block B concatenated, with the image dropped from the middle, and that single string is written back ahead of the image.
After, same requests:
A still compresses and now writes a 1h cache entry, and the repeat run reads 4975 tokens back out of it, which is the caching the caller configured and previously never got. B is left alone, so its blocks reach the model in the order they were sent and its breakpoint is honored.
Type
🐛 Bug Fix
Changes
guardrail_hooks/content_text.pyholdingcontent_to_text(moved out of compresr) plusis_all_text_partsandmerge_rewritten_text_parts, so the positional-breakpoint rule has one owner_replace_text_in_contentcollapses an all-text row into a single part carrying the last declaredcache_controland its TTL, and returns any other list unchanged_select_targetsstops selecting rows that hold a non-text parttest_compresr.pyfor the new contract and pin both hazards; string content is unaffected throughoutQA runbook
Configure the compresr guardrail against a compression service, send an Anthropic request whose user content is two text blocks with
cache_controlon the second, and confirm the response reports non-zerocache_creation_input_tokens; repeat the request and confirmcache_read_input_tokensmatches. Then send a row shaped[text, image, text]and confirm the content reaches the model unmodified.Things a reviewer will ask about
Mixed rows stop being compressed, and that is the deliberate trade. A breakpoint caches the prefix ending at its own part, so no single-string write-back can preserve one across an image; compressing each text run separately would mean one service call per run and a per-part rather than per-message batch contract, which is a larger change than the defect warrants. This matches the contract #34586 landed for headroom.
The merged part carries the last declared breakpoint rather than the first. After the merge the row is a single part, so the last one is the only one that still describes it; keeping the first would cache a prefix shorter than any the caller asked for.
content_text.pyis also created by #34586, which is open. Whichever lands first, the other resolves to the union; this PR's copy is a superset andcontent_to_textis byte-identical in both.Final Attestation
Note
Medium Risk
Changes Compresr guardrail message mutation and eligibility rules for multimodal content; behavior is narrower (fewer rows compressed) but affects prompt caching and tool-message shapes on the proxy hot path.
Overview
Fixes Compresr multimodal write-back so Anthropic
cache_controlbreakpoints and part order stay correct.Shared helpers in
content_text.py(content_to_text,is_all_text_parts,merge_rewritten_text_parts) replace inline Compresr logic and document the positional-breakpoint rule (aligned with headroom).Write-back: all-text part lists collapse to one text part with the last declared
cache_control(and TTL). Lists that include images or other non-text parts are returned unchanged—no more flattening text across an image.Target selection: messages whose content is not an all-text part list are skipped for compression, so mixed rows are never sent to the service or rewritten.
Unit tests cover last-breakpoint preservation, skipping mixed rows, and refusing to relocate text around non-text parts.
Reviewed by Cursor Bugbot for commit c63e24b. Bugbot is set up for automated code reviews on this repo. Configure here.