headroom/tests/test_cache_control_move_bust.py
Zhenjia ZHOU 8906d3a676
fix(cache): preserve client cache_control ttl when consolidating breakpoints (#2382)
## Description

`normalize_message_cache_control()` consolidates message-level
`cache_control` breakpoints (strip all, re-place exactly one) to stay
under Anthropic's 4-block limit. The re-placed marker was hardcoded to
`{"type": "ephemeral"}`, so a client using 1-hour caching
(`cache_control: {"type": "ephemeral", "ttl": "1h"}`) was silently
downgraded to the 5-minute default on every consolidated turn — no
error, no signal, just quietly worse cache economics.

Fix: track the newest client marker while stripping, and re-place **that
marker verbatim** (a copy). Headroom keeps owning *where* the breakpoint
goes; the client keeps owning *what it says*. Older replayed markers
don't win — if the client's newest marker has no `ttl`, we don't
resurrect a stale `1h` (covered by a dedicated regression test).

Fixes #2375.

## Type of Change

- [x] Bug fix (silent 1h→5m cache downgrade)

## Changes Made

- `headroom/cache/prefix_tracker.py`:
`normalize_message_cache_control()` records the last marker dict seen in
message order and re-places a copy of it instead of a hardcoded
`{"type": "ephemeral"}`; docstring documents the ownership split.
- `tests/test_cache_control_move_bust.py`: 3 new tests — ttl preserved,
newest-marker-wins over stale ttls, ttl survives an 8-turn conversation
loop.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff` + `mypy`, CI-pinned settings)
- [x] Reproduced the bug first (2 new tests failed on the old code),
then verified the fix

### Test Output

```text
$ .venv/bin/python -m pytest tests/test_cache_control_move_bust.py -q
10 passed

# Before the fix, the two new ttl tests fail exactly as #2375 describes:
#   FAILED ...::test_normalize_preserves_ttl_of_newest_marker
#   FAILED ...::test_normalize_ttl_survives_many_turns

$ ruff check headroom/cache/prefix_tracker.py tests/test_cache_control_move_bust.py   # All checks passed!
$ ruff format --check <both files>                                                     # already formatted
$ mypy headroom/cache/prefix_tracker.py --ignore-missing-imports                       # Success: no issues
```

## Real Behavior Proof

- Environment: macOS (Darwin), Python in a uv venv, branch
`fix/cache-control-ttl-preserve` off `main` (`56c7d4a5`).
- Exact command / steps: drove `normalize_message_cache_control`
directly with a 2-message conversation whose marker carries `ttl: "1h"`,
printed the re-placed marker before/after the fix, and ran the new
regression tests against the unfixed code first.
- Observed result: before — output marker `{'type': 'ephemeral'}` (ttl
silently dropped); after — output marker `{'type': 'ephemeral', 'ttl':
'1h'}` with marker count still exactly 1 (the ≤4-block guarantee is
untouched).
- Not tested: a live Anthropic round-trip asserting
`cache_creation.ephemeral_1h_input_tokens` (needs a billed API call);
the marker dict forwarded on the wire is what the assertion pins.

## 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 — N/A
(docstring updated)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — N/A

## Additional Notes

- The `test_normalize_newest_marker_wins_over_stale_ttl` test also
guards against over-fixing (e.g. "any 1h seen anywhere wins"), which
would pin users to 1h pricing after they switch back to the default.
2026-07-18 09:52:22 -07:00

226 lines
8.7 KiB
Python

"""Reproduce the residual cache-bust: client MOVING cache_control defeats the
prefix overlay.
Real clients (Claude Code, litellm) move the cache_control breakpoint to the
newest message every turn — so a message that was marked last turn is unmarked
this turn (its dict bytes change). The first overlay fix compared *raw* message
dicts for its append-only guard, so a moved marker in the frozen prefix made the
guard fail → the overlay skipped the replay → the raw freeze forwarded ORIGINAL
bytes over the cached COMPRESSED prefix → partial bust (the ~42% residual seen
on the a10 run, with prefix_change=0).
These tests pin the exact scenario, prove the content-only guard fixes it, and
document the remaining piece (marker accumulation > 4 → needs stable placement).
"""
from headroom.cache.prefix_tracker import (
PrefixCacheTracker,
PrefixFreezeConfig,
overlay_cached_prefix,
)
def M(role, text, cc=False):
m = {"role": role, "content": text}
if cc:
m["cache_control"] = {"type": "ephemeral"}
return m
def _toklen(m):
return max(1, len(str(m.get("content", ""))))
def _compress(m):
c = str(m.get("content", ""))
return {**m, "content": c[: max(1, len(c) // 2)]}
def _freeze(original, frozen):
# content_router freeze model: frozen prefix = ORIGINAL bytes, rest compressed.
return [(original[i] if i < frozen else _compress(original[i])) for i in range(len(original))]
# ── Unit reproduction ────────────────────────────────────────────────────────
# Last turn we forwarded the compressed prefix; the client had marked msg1.
PREV_ORIG = [M("user", "READ foo:\n<big>"), M("assistant", "ok", cc=True)]
PREV_FWD = [M("user", "READ foo:\n<compressed>"), M("assistant", "ok", cc=True)]
# This turn the client MOVED the marker off msg1 onto the new last message (msg2).
CUR_ORIG = [M("user", "READ foo:\n<big>"), M("assistant", "ok"), M("user", "grep:\n<big>", cc=True)]
# Freeze forwarded ORIGINAL bytes for the frozen prefix + compressed tail.
OPTIMIZED = [M("user", "READ foo:\n<big>"), M("assistant", "ok"), M("user", "grep:\n<compressed>")]
def test_marker_move_would_fail_a_raw_dict_guard():
# This is the exact condition the old (raw) guard tripped on: the frozen
# prefix differs ONLY because cache_control moved off msg1.
assert CUR_ORIG[:2] != PREV_ORIG
# ...but with cache_control stripped, the content is an append-only extension.
from headroom.cache.prefix_tracker import _strip_cache_control
assert _strip_cache_control(CUR_ORIG[:2]) == _strip_cache_control(PREV_ORIG)
def test_overlay_replays_despite_moved_marker():
out = overlay_cached_prefix(OPTIMIZED, CUR_ORIG, PREV_ORIG, PREV_FWD)
# The content-only guard lets the replay happen: the forwarded prefix is now
# byte-identical to what the provider cached (compressed), NOT the freeze's
# original bytes → cache hits instead of busting.
assert out[:2] == PREV_FWD
assert out[:2] != OPTIMIZED[:2]
assert out[2] == OPTIMIZED[2] # compressed tail preserved
# ── Cross-turn: client moves the marker every turn, provider keys on full bytes ─
def _client_convo(t):
msgs = [{"role": "user", "content": f"turn-{k}:" + "X" * 300} for k in range(1, t + 1)]
msgs[-1] = {**msgs[-1], "cache_control": {"type": "ephemeral"}} # mark ONLY the newest
return msgs
def _cache_read(fwd, prev_fwd):
# cache_control-AWARE (worst case): a moved marker changes the block's bytes,
# so it breaks the byte-identical prefix.
if not prev_fwd:
return 0
matched = 0
for a, b in zip(fwd, prev_fwd):
if a == b:
matched += _toklen(a)
else:
break
return matched
def _drive(use_overlay, turns=5):
tracker = PrefixCacheTracker("anthropic", PrefixFreezeConfig(min_cached_tokens=0))
prev_fwd = None
results = []
last_fwd = None
for t in range(1, turns + 1):
cur = _client_convo(t)
frozen = tracker.get_frozen_message_count()
fwd = _freeze(cur, frozen)
if use_overlay:
fwd = overlay_cached_prefix(
fwd,
cur,
tracker.get_last_original_messages(),
tracker.get_last_forwarded_messages(),
)
exp = sum(_toklen(m) for m in prev_fwd) if prev_fwd else 0
act = _cache_read(fwd, prev_fwd)
results.append((exp, act))
counts = [_toklen(m) for m in fwd]
tracker.update_from_response(
act, sum(counts) - act, fwd, message_token_counts=counts, original_messages=cur
)
prev_fwd = fwd
last_fwd = fwd
return results, last_fwd
def test_moving_marker_busts_without_overlay():
results, _ = _drive(use_overlay=False)
assert any(exp > act for exp, act in results[1:]), "moving marker should bust the raw freeze"
def test_moving_marker_no_bust_with_overlay():
results, _ = _drive(use_overlay=True)
for exp, act in results[1:]:
assert act >= exp, f"cache bust under moved marker: expected {exp} read {act}"
# ── fix-2: Headroom owns cache_control placement (realistic block content) ────
from headroom.cache.prefix_tracker import ( # noqa: E402
_strip_cache_control,
normalize_message_cache_control,
)
def B(role, text, cc=False):
"""Anthropic block-style message (cache_control lives on a content block)."""
blk = {"type": "text", "text": text}
if cc:
blk["cache_control"] = {"type": "ephemeral"}
return {"role": role, "content": [blk]}
def _markers(messages):
return sum(
1
for m in messages
if isinstance(m.get("content"), list)
for b in m["content"]
if isinstance(b, dict) and "cache_control" in b
)
def test_normalize_strips_all_and_keeps_one_on_last():
# 5 accumulated markers (the pile-up the overlay would produce).
msgs = [
B("user", "a", cc=True),
B("assistant", "b", cc=True),
B("user", "c", cc=True),
B("user", "d", cc=True),
B("user", "e", cc=True),
]
out = normalize_message_cache_control(msgs)
assert _markers(out) == 1 # bounded — no >4 error
assert "cache_control" in out[-1]["content"][-1] # on the last block
assert _strip_cache_control(out) == _strip_cache_control(msgs) # content untouched
def test_normalize_stays_bounded_across_many_turns():
"""The accumulation that would 400 Anthropic is now capped at 1 every turn."""
conv = []
forwarded = []
for t in range(1, 12):
conv = conv + [B("user", f"turn-{t}", cc=True)] # client marks the newest
forwarded = normalize_message_cache_control(conv)
assert _markers(forwarded) <= 4 # never exceeds Anthropic's limit
assert _markers(forwarded) == 1 # exactly one, on the last message
def test_normalize_is_noop_when_no_block_markers():
plain = [B("user", "a"), B("assistant", "b")] # no cache_control
out = normalize_message_cache_control(plain)
# places exactly one breakpoint (so the prefix gets cached), content stable
assert _markers(out) == 1
assert _strip_cache_control(out) == _strip_cache_control(plain)
# ── fix-3 (#2375): consolidation must not silently drop the client's ttl ─────
def B_ttl(role, text, ttl):
"""Block-style message whose marker carries an explicit ttl (1h caching)."""
blk = {"type": "text", "text": text, "cache_control": {"type": "ephemeral", "ttl": ttl}}
return {"role": role, "content": [blk]}
def test_normalize_preserves_ttl_of_newest_marker():
"""A 1h-ttl client must not be silently downgraded to the 5m default."""
msgs = [B("user", "a", cc=True), B_ttl("user", "b", "1h")]
out = normalize_message_cache_control(msgs)
assert _markers(out) == 1
assert out[-1]["content"][-1]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
def test_normalize_newest_marker_wins_over_stale_ttl():
# Older replayed markers still carry 1h, but the client's NEWEST marker has
# no ttl — the client switched back to the default; don't resurrect 1h.
msgs = [B_ttl("user", "a", "1h"), B_ttl("assistant", "b", "1h"), B("user", "c", cc=True)]
out = normalize_message_cache_control(msgs)
assert _markers(out) == 1
assert out[-1]["content"][-1]["cache_control"] == {"type": "ephemeral"}
def test_normalize_ttl_survives_many_turns():
"""The #2375 scenario: ttl held for one turn, gone on every later turn."""
conv = []
for t in range(1, 8):
conv = conv + [B_ttl("user", f"turn-{t}", "1h")] # client always asks 1h
conv = normalize_message_cache_control(conv)
assert _markers(conv) == 1
assert conv[-1]["content"][-1]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}