headroom/tests/test_cache_aligner_prefix_stability.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

108 lines
3.7 KiB
Python
Raw Normal View History

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