headroom/tests/test_cache_aligner_prefix_stability.py
Rod Boev cc072f0821
fix(cache-aligner): hash the frozen conversation prefix so Claude Code cache invalidation is detected (#2085) (#2161)
## Description

Running Headroom as the API proxy for Claude Code, provider prompt-cache
reuse collapsed: uncached input tokens went from ~755 to ~4.5M,
cache-creation (write) tokens inflated ~4.4×, and one session burned
~36% of a weekly model cap. Roughly 96%-cached traffic became
uncached+rewrite traffic — a net cost multiplier, not a saving.

The `CacheAligner` owns the pipeline's "is the cacheable prefix
byte-stable across requests?" signal (`stable_prefix_hash` /
`prefix_changed` on `CachePrefixMetrics`). But `CacheAligner.apply()`
computes that hash over **only `role == "system"` messages**
(`headroom/transforms/cache_aligner.py:314-325`). Under Claude Code the
system prompt is the stable part; what actually churns between requests
is the conversation head — earlier user turns and tool-result blocks —
the range Claude Code relies on for provider cache reads. `apply()`
already receives the authoritative freeze boundary
(`frozen_message_count`, produced by `PrefixCacheTracker`) and uses it
to skip volatile-content detection, but the hash ignores it. So
`prefix_changed` reports "prefix stable" even while the real cacheable
prefix churns: the budget-burning regression is invisible and the
byte-stability invariant the issue asks for is neither asserted nor
enforced.

This change scopes the aligner's stable-prefix hash to the actual frozen
cacheable prefix (`messages[:frozen_message_count]` plus system
messages), keyed on the authoritative `frozen_message_count`, so
`prefix_changed` becomes a true cache-invalidation signal — and adds the
replay regression test the issue specifies, locking the invariant "for
`messages[0..k]` identical to the previous request, the emitted prefix
bytes and hash are identical." `apply()` remains strictly detector-only
and byte-equal; no rewrite is introduced.

Closes #2085.

## 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

- Scoped `CacheAligner.apply()`'s `stable_prefix_hash` to the frozen
cacheable prefix: the byte content of
`result_messages[:frozen_message_count]` (the frozen conversation head,
in order) combined with the system messages, keyed on the authoritative
`frozen_message_count` kwarg from `PrefixCacheTracker`.
- `prefix_changed` now reflects churn in the true provider-cacheable
prefix (a changed tool-result block that leaves the system prompt
untouched is now detected), so Claude Code cache invalidation is
observable via the existing `CachePrefixMetrics` and the
`stable_prefix_hash:<hash>` marker.
- Preserved first-turn behavior: when `frozen_message_count == 0` the
hash falls back to the current system-only scope, so the first request
in a session is byte-for-byte unchanged.
- Kept `apply()` detector-only (deep copy, never mutates messages) and
left the `should_apply` skip gate, volatile-content warning, token
counts, and `TransformResult` shape unchanged.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_cache_aligner_prefix_stability.py`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_cache_aligner_prefix_stability.py -q
.....                                                                     [100%]
5 passed in 0.40s
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uv run`; no live provider.
- Exact command / steps: `uv run pytest
tests/test_cache_aligner_prefix_stability.py -q`, which replays
consecutive `apply()` calls in the Claude Code shape (stable system
prompt + accumulated tool-result prefix) with `frozen_message_count >
0`.
- Observed result: when a frozen tool-result block changes between
requests while the system prompt is byte-identical, `prefix_changed` is
now `True` and `stable_prefix_hash` differs; when the frozen prefix +
system are identical, `prefix_changed` is `False`; when only the
live/unfrozen tail changes, `prefix_changed` stays `False`; `apply()`
output stays byte-equal to input. Before the change the same
frozen-prefix churn reports `prefix_changed=False` because the hash
covers only the system prompt.
- Not tested: live Anthropic prompt-cache accounting over a full Claude
Code session.

## 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
- [x] 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Scope: this slice corrects and locks the byte-stability invariant **at
the CacheAligner boundary** — the exact acceptance criterion in the
issue (a cache-preservation invariant over identical prefixes plus a
replay regression test). It is distinct from, and does not touch, the
upstream sources of prefix churn (ContentRouter per-block verdict flaps
under `min_ratio` drift, #1619), the `headroom stats` cache-delta
surfacing (#960), or parallel-subagent stream misclassification (#1949);
those remain separate follow-ups. `PrefixCacheTracker`'s independent
forwarded-prefix byte check (`headroom/cache/prefix_tracker.py`) is
unchanged.
- `mypy` left unchecked: not part of the focused validation for this
change.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 11:59:52 -04:00

107 lines
3.7 KiB
Python

from __future__ import annotations
from copy import deepcopy
from headroom import OpenAIProvider
from headroom.tokenizer import Tokenizer
from headroom.transforms.cache_aligner import CacheAligner
from headroom.utils import compute_short_hash
_provider = OpenAIProvider()
def _tokenizer() -> Tokenizer:
counter = _provider.get_token_counter("gpt-4o")
return Tokenizer(counter, "gpt-4o")
def _claude_code_messages(
*,
cached_tool_output: str = "cached tool output v1",
live_tail: str = "latest live turn",
) -> list[dict[str, object]]:
return [
{"role": "system", "content": "You are Headroom. Keep the cached prefix stable."},
{"role": "user", "content": "Summarize the repo state."},
{"role": "assistant", "content": cached_tool_output},
{"role": "user", "content": live_tail},
]
def test_frozen_prefix_change_flags_prefix_changed() -> None:
aligner = CacheAligner()
tokenizer = _tokenizer()
first = _claude_code_messages(cached_tool_output="cached tool output v1")
second = _claude_code_messages(cached_tool_output="cached tool output v2")
result1 = aligner.apply(first, tokenizer, frozen_message_count=3)
result2 = aligner.apply(second, tokenizer, frozen_message_count=3)
assert result1.cache_metrics.prefix_changed is False
assert result2.cache_metrics.prefix_changed is True
assert result2.cache_metrics.previous_hash == result1.cache_metrics.stable_prefix_hash
assert result2.cache_metrics.stable_prefix_hash != result1.cache_metrics.stable_prefix_hash
def test_identical_frozen_prefix_is_stable() -> None:
aligner = CacheAligner()
tokenizer = _tokenizer()
messages = _claude_code_messages()
result1 = aligner.apply(messages, tokenizer, frozen_message_count=3)
result2 = aligner.apply(deepcopy(messages), tokenizer, frozen_message_count=3)
assert result1.cache_metrics.prefix_changed is False
assert result2.cache_metrics.prefix_changed is False
assert result2.cache_metrics.stable_prefix_hash == result1.cache_metrics.stable_prefix_hash
def test_live_tail_change_does_not_flag() -> None:
aligner = CacheAligner()
tokenizer = _tokenizer()
first = _claude_code_messages(live_tail="latest live turn")
second = _claude_code_messages(live_tail="different live turn")
aligner.apply(first, tokenizer, frozen_message_count=3)
result2 = aligner.apply(second, tokenizer, frozen_message_count=3)
assert result2.cache_metrics.prefix_changed is False
def test_apply_is_byte_equal_deepcopy() -> None:
aligner = CacheAligner()
tokenizer = _tokenizer()
messages = [
{
"role": "system",
"content": "Keep the transcript stable.",
"meta": {"source": "test"},
},
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
},
]
result = aligner.apply(messages, tokenizer, frozen_message_count=1)
assert result.messages == messages
assert result.messages is not messages
assert result.messages[0] is not messages[0]
assert result.messages[1] is not messages[1]
def test_first_turn_scope_unchanged() -> None:
aligner = CacheAligner()
tokenizer = _tokenizer()
messages = _claude_code_messages()
system_text = messages[0]["content"]
result = aligner.apply(messages, tokenizer, frozen_message_count=0)
assert result.cache_metrics.prefix_changed is False
assert result.cache_metrics.stable_prefix_hash == compute_short_hash(system_text)
assert result.cache_metrics.stable_prefix_bytes == len(str(system_text).encode("utf-8"))
assert result.cache_metrics.stable_prefix_tokens_est == tokenizer.count_text(str(system_text))