Skip to content

Commit 4820134

Browse files
authored
fix(proxy): keep cache_control bounded + stable so the freeze overlay stops busting (headroomlabs-ai#1852)
Follow-up to headroomlabs-ai#1850. Two residual cache-bust sources, both `cache_control`-related: 1. **Guard too strict.** `overlay_cached_prefix` decided "is this turn an append-only extension?" by comparing whole message dicts — including `cache_control`. Clients (Claude Code, litellm) move the cache breakpoint to the newest message every call, so a marker landing in the frozen prefix made the guard fail, the overlay skip its replay, and the raw freeze forward ORIGINAL bytes over the cached COMPRESSED prefix → partial bust (the ~42% residual on the a10 run, `prefix_change=0`). Fix: run the append-only guard on **content only** (strip `cache_control` before comparing) — content is what the provider's cache keys on. 2. **Marker accumulation.** The overlay replays the markers that rode on each turn's then-newest message, so `cache_control` blocks pile up ~1/turn; Anthropic hard-errors at >4 total. Fix: `normalize_message_cache_control` strips every message-level marker and re-places a single ephemeral breakpoint on the last block (one breakpoint caches the whole prefix; cache is content-keyed so re-placing never busts). Wired into the Anthropic handler after the overlay. **Per-provider (deliberately scoped):** - **Anthropic**: `cache_control` markers → both fixes apply. - **OpenAI**: AUTOMATIC prefix caching, no markers → overlay (byte-identity) only; normalize is NOT applied (Anthropic markers on an OpenAI request would be wrong). - **Bedrock**: serves Claude via the pipeline but has no cachePoint/freeze-replay path → not affected; a cachePoint analog would be needed if caching is expanded. - **Gemini**: explicit Cache API (`cachedContent`), no inline markers/freeze → N/A. > Stacked on headroomlabs-ai#1850 — review that first; the diff against `main` includes its overlay + `has_new_ccr_markers` work. ## Description Keeps the freeze overlay's cache-safety intact against real clients that relocate the `cache_control` breakpoint each turn, and prevents `cache_control` blocks from accumulating past Anthropic's 4-marker limit. See the two fixes above. Closes #<!-- none --> — follow-up to headroomlabs-ai#1850 (no separate issue). ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/cache/prefix_tracker.py`: append-only guard in `overlay_cached_prefix` now compares **content only** (ignores `cache_control`); new `normalize_message_cache_control()` collapses message-level markers to a single ephemeral breakpoint on the last block. - `headroom/proxy/handlers/anthropic.py`: apply `normalize_message_cache_control` after the overlay (Anthropic only). - `tests/test_cache_control_move_bust.py`: reproduces the moved-marker bust + proves both fixes. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed (local, see below) ### Test Output ```text $ pytest tests/test_cache_control_move_bust.py -q ....... [100%] 7 passed in 0.19s # broader cache-safety suite (overlay + cross-turn + CCR deferred + openai/anthropic cache-stability + helpers) $ pytest tests/test_cache_control_move_bust.py tests/test_cache_prefix_overlay.py \ tests/test_cross_turn_cache_safety.py tests/test_proxy/test_anthropic_ccr_deferred_injection.py \ tests/test_proxy_handler_helpers.py tests/test_proxy_openai_cache_stability.py \ tests/test_proxy_anthropic_cache_stability.py -q 91 passed, 2 warnings in 29.98s $ ruff check . # ruff 0.15.17 (CI-pinned) All checks passed! $ ruff format --check . # ruff 0.15.17 1057 files already formatted $ mypy headroom --ignore-missing-imports Success: no issues found (changed modules: prefix_tracker, anthropic, openai, helpers) ``` ## Real Behavior Proof - **Environment:** local (`.venv`, Python 3.12), ruff 0.15.17 / mypy pinned to CI versions. - **Exact command / steps:** `tests/test_cache_control_move_bust.py` drives the REAL tracker + freeze + `overlay_cached_prefix` + `normalize_message_cache_control` across multiple append-only turns where the client moves the `cache_control` breakpoint each turn. - **Observed result:** with a moved marker in the frozen prefix, the content-only guard keeps the overlay replaying (forwarded prefix stays byte-identical → no bust); `cache_control` blocks stay ≤4 across many turns and content is never altered. The reproduction test fails without the fix and passes with it. - **Not tested (this PR):** the end-to-end a10 SWE-bench run is the field observation motivating fix headroomlabs-ai#1 (~42% residual, `prefix_change=0`); not re-run here. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes Stacked on headroomlabs-ai#1850; land that first. Docs/CHANGELOG untouched (behavioral cache-safety fix; no user-facing surface change). N/A: no screenshots (no UI).
1 parent 5d14080 commit 4820134

3 files changed

Lines changed: 278 additions & 4 deletions

File tree

‎headroom/cache/prefix_tracker.py‎

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,22 @@ class CacheMissAttribution:
112112
ttl_exceeded: bool = False
113113

114114

115+
def _strip_cache_control(obj: Any) -> Any:
116+
"""Recursively drop ``cache_control`` for content-only equality checks.
117+
118+
Clients (notably Claude Code) move the cache_control breakpoint to the newest
119+
message on every call, so the exact same message carries cache_control on one
120+
turn and not the next. That per-call annotation must be ignored when deciding
121+
whether this turn append-only-extends the previous one — otherwise a moved
122+
marker spuriously fails the check and we skip the byte-identical replay,
123+
busting the cache."""
124+
if isinstance(obj, dict):
125+
return {k: _strip_cache_control(v) for k, v in obj.items() if k != "cache_control"}
126+
if isinstance(obj, list):
127+
return [_strip_cache_control(v) for v in obj]
128+
return obj
129+
130+
115131
def overlay_cached_prefix(
116132
optimized_messages: list[dict[str, Any]],
117133
current_original_messages: list[dict[str, Any]],
@@ -151,13 +167,68 @@ def overlay_cached_prefix(
151167
return optimized_messages
152168
if len(current_original_messages) < n or len(optimized_messages) < n:
153169
return optimized_messages
154-
# Append-only guard: the frozen region must be the same messages we cached.
155-
if current_original_messages[:n] != prev_orig:
170+
# Append-only guard on CONTENT ONLY: the frozen region must be the same
171+
# messages we cached. Compare with cache_control stripped — clients move that
172+
# breakpoint to the newest message each turn, so a raw dict compare would
173+
# spuriously fail whenever a marker lands in the frozen prefix, skip the
174+
# replay, and bust the cache (the residual busts observed after the first
175+
# fix). Content stability is what the provider's prefix cache actually keys on.
176+
if _strip_cache_control(current_original_messages[:n]) != _strip_cache_control(prev_orig):
156177
return optimized_messages
157-
# Replay the cached (compressed) prefix; keep this turn's compressed tail.
178+
# Replay the cached (compressed) prefix byte-identical; keep this turn's tail.
158179
return list(prev_fwd) + list(optimized_messages[n:])
159180

160181

182+
def normalize_message_cache_control(
183+
messages: list[dict[str, Any]],
184+
) -> list[dict[str, Any]]:
185+
"""Own message-level cache_control placement so breakpoints stay bounded.
186+
187+
Two forces pile up cache_control markers turn over turn: clients move the
188+
breakpoint to the newest message each call, and ``overlay_cached_prefix``
189+
replays the markers that rode on each turn's then-newest message. Anthropic
190+
hard-errors at **>4 cache_control blocks total** (system + tools + messages),
191+
so on a long conversation the accumulation eventually 400s.
192+
193+
Fix: strip EVERY message-level cache_control and re-place a **single**
194+
ephemeral breakpoint on the last block of the last block-style message. One
195+
breakpoint caches the whole message prefix up to it, and — because the
196+
provider's cache key is message CONTENT, not marker presence (moving the
197+
breakpoint forward is the documented client pattern and it hits) — stripping
198+
and re-placing markers never busts. system/tools breakpoints live outside
199+
``messages`` and are left untouched (they still count toward the 4 limit, so
200+
holding messages to one breakpoint leaves room for them).
201+
202+
Only block-style (list) content can carry cache_control; string content is
203+
left as-is. Returns the input unchanged when there is nothing to normalize.
204+
"""
205+
changed = False
206+
out: list[dict[str, Any]] = []
207+
last_block_idx = -1
208+
for i, msg in enumerate(messages):
209+
content = msg.get("content") if isinstance(msg, dict) else None
210+
if isinstance(content, list):
211+
had = any(isinstance(b, dict) and "cache_control" in b for b in content)
212+
stripped = [
213+
{k: v for k, v in b.items() if k != "cache_control"} if isinstance(b, dict) else b
214+
for b in content
215+
]
216+
out.append({**msg, "content": stripped} if had else msg)
217+
changed = changed or had
218+
if stripped and isinstance(stripped[-1], dict):
219+
last_block_idx = i
220+
else:
221+
out.append(msg)
222+
# Re-place exactly one breakpoint on the last block-style message.
223+
if last_block_idx >= 0:
224+
msg = out[last_block_idx]
225+
content = list(msg["content"])
226+
content[-1] = {**content[-1], "cache_control": {"type": "ephemeral"}}
227+
out[last_block_idx] = {**msg, "content": content}
228+
changed = True
229+
return out if changed else messages
230+
231+
161232
class PrefixCacheTracker:
162233
"""Tracks provider prefix cache state across turns in a session.
163234

‎headroom/proxy/handlers/anthropic.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1348,7 +1348,10 @@ class _DeferredCompressionResult:
13481348
# previously-forwarded prefix keeps it byte-identical → cache hits.
13491349
# Append-only-guarded and idempotent (cache mode already replays), so
13501350
# it is safe to run unconditionally here.
1351-
from headroom.cache.prefix_tracker import overlay_cached_prefix
1351+
from headroom.cache.prefix_tracker import (
1352+
normalize_message_cache_control,
1353+
overlay_cached_prefix,
1354+
)
13521355

13531356
_ov = overlay_cached_prefix(
13541357
optimized_messages,
@@ -1360,6 +1363,16 @@ class _DeferredCompressionResult:
13601363
optimized_messages = _ov
13611364
optimized_tokens = tokenizer.count_messages(optimized_messages)
13621365

1366+
# Own cache_control placement: the client moves the breakpoint each
1367+
# turn and the overlay replays past markers, so they accumulate ~1/turn
1368+
# and Anthropic hard-errors at >4. Strip message-level markers and keep
1369+
# a single breakpoint on the last block (caches the whole prefix;
1370+
# content-keyed cache so re-placing never busts). Applied last so the
1371+
# forwarded AND recorded (next_forwarded) messages stay bounded.
1372+
_norm = normalize_message_cache_control(optimized_messages)
1373+
if _norm is not optimized_messages:
1374+
optimized_messages = _norm
1375+
13631376
# Guard: if "optimization" inflated tokens, revert to originals.
13641377
# Skip in cache mode where prefix-stability may legitimately shift counts.
13651378
if optimized_tokens > original_tokens and not is_cache_mode(self.config.mode):
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Reproduce the residual cache-bust: client MOVING cache_control defeats the
2+
prefix overlay.
3+
4+
Real clients (Claude Code, litellm) move the cache_control breakpoint to the
5+
newest message every turn — so a message that was marked last turn is unmarked
6+
this turn (its dict bytes change). The first overlay fix compared *raw* message
7+
dicts for its append-only guard, so a moved marker in the frozen prefix made the
8+
guard fail → the overlay skipped the replay → the raw freeze forwarded ORIGINAL
9+
bytes over the cached COMPRESSED prefix → partial bust (the ~42% residual seen
10+
on the a10 run, with prefix_change=0).
11+
12+
These tests pin the exact scenario, prove the content-only guard fixes it, and
13+
document the remaining piece (marker accumulation > 4 → needs stable placement).
14+
"""
15+
16+
from headroom.cache.prefix_tracker import (
17+
PrefixCacheTracker,
18+
PrefixFreezeConfig,
19+
overlay_cached_prefix,
20+
)
21+
22+
23+
def M(role, text, cc=False):
24+
m = {"role": role, "content": text}
25+
if cc:
26+
m["cache_control"] = {"type": "ephemeral"}
27+
return m
28+
29+
30+
def _toklen(m):
31+
return max(1, len(str(m.get("content", ""))))
32+
33+
34+
def _compress(m):
35+
c = str(m.get("content", ""))
36+
return {**m, "content": c[: max(1, len(c) // 2)]}
37+
38+
39+
def _freeze(original, frozen):
40+
# content_router freeze model: frozen prefix = ORIGINAL bytes, rest compressed.
41+
return [(original[i] if i < frozen else _compress(original[i])) for i in range(len(original))]
42+
43+
44+
# ── Unit reproduction ────────────────────────────────────────────────────────
45+
# Last turn we forwarded the compressed prefix; the client had marked msg1.
46+
PREV_ORIG = [M("user", "READ foo:\n<big>"), M("assistant", "ok", cc=True)]
47+
PREV_FWD = [M("user", "READ foo:\n<compressed>"), M("assistant", "ok", cc=True)]
48+
# This turn the client MOVED the marker off msg1 onto the new last message (msg2).
49+
CUR_ORIG = [M("user", "READ foo:\n<big>"), M("assistant", "ok"), M("user", "grep:\n<big>", cc=True)]
50+
# Freeze forwarded ORIGINAL bytes for the frozen prefix + compressed tail.
51+
OPTIMIZED = [M("user", "READ foo:\n<big>"), M("assistant", "ok"), M("user", "grep:\n<compressed>")]
52+
53+
54+
def test_marker_move_would_fail_a_raw_dict_guard():
55+
# This is the exact condition the old (raw) guard tripped on: the frozen
56+
# prefix differs ONLY because cache_control moved off msg1.
57+
assert CUR_ORIG[:2] != PREV_ORIG
58+
# ...but with cache_control stripped, the content is an append-only extension.
59+
from headroom.cache.prefix_tracker import _strip_cache_control
60+
61+
assert _strip_cache_control(CUR_ORIG[:2]) == _strip_cache_control(PREV_ORIG)
62+
63+
64+
def test_overlay_replays_despite_moved_marker():
65+
out = overlay_cached_prefix(OPTIMIZED, CUR_ORIG, PREV_ORIG, PREV_FWD)
66+
# The content-only guard lets the replay happen: the forwarded prefix is now
67+
# byte-identical to what the provider cached (compressed), NOT the freeze's
68+
# original bytes → cache hits instead of busting.
69+
assert out[:2] == PREV_FWD
70+
assert out[:2] != OPTIMIZED[:2]
71+
assert out[2] == OPTIMIZED[2] # compressed tail preserved
72+
73+
74+
# ── Cross-turn: client moves the marker every turn, provider keys on full bytes ─
75+
def _client_convo(t):
76+
msgs = [{"role": "user", "content": f"turn-{k}:" + "X" * 300} for k in range(1, t + 1)]
77+
msgs[-1] = {**msgs[-1], "cache_control": {"type": "ephemeral"}} # mark ONLY the newest
78+
return msgs
79+
80+
81+
def _cache_read(fwd, prev_fwd):
82+
# cache_control-AWARE (worst case): a moved marker changes the block's bytes,
83+
# so it breaks the byte-identical prefix.
84+
if not prev_fwd:
85+
return 0
86+
matched = 0
87+
for a, b in zip(fwd, prev_fwd):
88+
if a == b:
89+
matched += _toklen(a)
90+
else:
91+
break
92+
return matched
93+
94+
95+
def _drive(use_overlay, turns=5):
96+
tracker = PrefixCacheTracker("anthropic", PrefixFreezeConfig(min_cached_tokens=0))
97+
prev_fwd = None
98+
results = []
99+
last_fwd = None
100+
for t in range(1, turns + 1):
101+
cur = _client_convo(t)
102+
frozen = tracker.get_frozen_message_count()
103+
fwd = _freeze(cur, frozen)
104+
if use_overlay:
105+
fwd = overlay_cached_prefix(
106+
fwd,
107+
cur,
108+
tracker.get_last_original_messages(),
109+
tracker.get_last_forwarded_messages(),
110+
)
111+
exp = sum(_toklen(m) for m in prev_fwd) if prev_fwd else 0
112+
act = _cache_read(fwd, prev_fwd)
113+
results.append((exp, act))
114+
counts = [_toklen(m) for m in fwd]
115+
tracker.update_from_response(
116+
act, sum(counts) - act, fwd, message_token_counts=counts, original_messages=cur
117+
)
118+
prev_fwd = fwd
119+
last_fwd = fwd
120+
return results, last_fwd
121+
122+
123+
def test_moving_marker_busts_without_overlay():
124+
results, _ = _drive(use_overlay=False)
125+
assert any(exp > act for exp, act in results[1:]), "moving marker should bust the raw freeze"
126+
127+
128+
def test_moving_marker_no_bust_with_overlay():
129+
results, _ = _drive(use_overlay=True)
130+
for exp, act in results[1:]:
131+
assert act >= exp, f"cache bust under moved marker: expected {exp} read {act}"
132+
133+
134+
# ── fix-2: Headroom owns cache_control placement (realistic block content) ────
135+
from headroom.cache.prefix_tracker import ( # noqa: E402
136+
_strip_cache_control,
137+
normalize_message_cache_control,
138+
)
139+
140+
141+
def B(role, text, cc=False):
142+
"""Anthropic block-style message (cache_control lives on a content block)."""
143+
blk = {"type": "text", "text": text}
144+
if cc:
145+
blk["cache_control"] = {"type": "ephemeral"}
146+
return {"role": role, "content": [blk]}
147+
148+
149+
def _markers(messages):
150+
return sum(
151+
1
152+
for m in messages
153+
if isinstance(m.get("content"), list)
154+
for b in m["content"]
155+
if isinstance(b, dict) and "cache_control" in b
156+
)
157+
158+
159+
def test_normalize_strips_all_and_keeps_one_on_last():
160+
# 5 accumulated markers (the pile-up the overlay would produce).
161+
msgs = [
162+
B("user", "a", cc=True),
163+
B("assistant", "b", cc=True),
164+
B("user", "c", cc=True),
165+
B("user", "d", cc=True),
166+
B("user", "e", cc=True),
167+
]
168+
out = normalize_message_cache_control(msgs)
169+
assert _markers(out) == 1 # bounded — no >4 error
170+
assert "cache_control" in out[-1]["content"][-1] # on the last block
171+
assert _strip_cache_control(out) == _strip_cache_control(msgs) # content untouched
172+
173+
174+
def test_normalize_stays_bounded_across_many_turns():
175+
"""The accumulation that would 400 Anthropic is now capped at 1 every turn."""
176+
conv = []
177+
forwarded = []
178+
for t in range(1, 12):
179+
conv = conv + [B("user", f"turn-{t}", cc=True)] # client marks the newest
180+
forwarded = normalize_message_cache_control(conv)
181+
assert _markers(forwarded) <= 4 # never exceeds Anthropic's limit
182+
assert _markers(forwarded) == 1 # exactly one, on the last message
183+
184+
185+
def test_normalize_is_noop_when_no_block_markers():
186+
plain = [B("user", "a"), B("assistant", "b")] # no cache_control
187+
out = normalize_message_cache_control(plain)
188+
# places exactly one breakpoint (so the prefix gets cached), content stable
189+
assert _markers(out) == 1
190+
assert _strip_cache_control(out) == _strip_cache_control(plain)

0 commit comments

Comments
 (0)