mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
2 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c16be9bbbe
|
fix(proxy/anthropic): don't replay recorded prefix over live history (#3026) (#3052)
## Description A Claude Code session that reads a large tool result through `headroom proxy` can fail on turn 2 with Anthropic's `400 prompt_too_long`. The reporter's controlled comparison completed eight turns through 0.33.0 with 187,986 input tokens, while 0.35.0 failed after five requests with 753,077 input tokens. The local regression uses an actual prior optimized request to populate tracker state, then a decision-false bypass turn with Claude-shaped tool-result content. The old unconditional replay path substitutes the compressed prefix; the eligibility gate preserves the client's outbound body without claiming a live provider reproduction. The Anthropic `/v1/messages` route computes whether a request should be compressed, but cached-prefix replay currently runs outside that decision. The replay helper also derives its prefix length from the original message list and applies that index to the optimized list without proving the two lists still align. A stale forwarded prefix can therefore be grafted onto the wrong positions and enlarge later requests. This change limits replay to requests whose existing compression decision permits it and whose pre-upstream backpressure path is inactive. It also makes `overlay_cached_prefix()` decline misaligned or inflating candidates while preserving normal append-only replay. Reported by @itsumonotakumi, whose controlled comparison isolated the failure from compression, headers, one-request serialization, memory, code graph, and CCR. Closes #3026 ## 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 - Gate Anthropic cached-prefix replay on the existing `CompressionDecision.should_compress` result and the existing pre-upstream backpressure state. - Require positional alignment between optimized and original message arrays before replay. - Reject replay candidates that would serialize larger than the current optimized messages. - Add focused handler coverage for the decision-false tool-result regression, bypass and backpressure paths, and outbound optimize-on preservation. - Add direct unit coverage for positional mismatch, no-inflation, and JSON sizing-failure bailouts. - Update the moved-cache-control and pure-block-append regression fixtures to keep the no-inflation contract explicit. - Run the unchanged OpenAI cache-stability preservation proof; no OpenAI production code was edited. ## Testing - [x] Unit tests pass (153 focused proxy, helper, cache-control, block-append, cross-turn, byte-faithful, Anthropic, OpenAI, and backpressure tests) - [x] Linting passes (Ruff check and format validation on the seven changed repository files) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed with the in-process proxy and local stub upstream ### Test Output ```text python -m pytest tests/test_proxy/test_anthropic_no_optimize_history_passthrough.py tests/test_cache_prefix_overlay.py tests/test_cross_turn_cache_safety.py tests/test_cache_control_move_bust.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_anthropic_cache_stability.py tests/test_anthropic_pre_upstream_backpressure.py -q python -m pytest tests/test_proxy_openai_cache_stability.py -q python -m pytest tests/test_issue_2671_block_growth_cache.py::test_pure_append_replays_forwarded_blocks_and_advances_breakpoint -q 153 passed across focused invocations, exit code 0 optimize_off turn2_message_count=3 marker_count=1 outbound_compact_utf8_bytes=2293 client_compact_utf8_bytes=2293 optimize_on turn2_message_count=3 client_message_count=3 marker_count=0 outbound_compact_utf8_bytes=171 client_compact_utf8_bytes=182 python -m ruff check headroom/proxy/handlers/anthropic.py headroom/cache/prefix_tracker.py tests/test_proxy/test_anthropic_no_optimize_history_passthrough.py tests/test_cache_prefix_overlay.py tests/test_cache_control_move_bust.py tests/test_issue_2671_block_growth_cache.py tests/test_proxy_openai_cache_stability.py All checks passed!, exit code 0 python -m ruff format headroom/proxy/handlers/anthropic.py headroom/cache/prefix_tracker.py tests/test_proxy/test_anthropic_no_optimize_history_passthrough.py tests/test_cache_prefix_overlay.py tests/test_cache_control_move_bust.py tests/test_issue_2671_block_growth_cache.py tests/test_proxy_openai_cache_stability.py --check 7 files already formatted, exit code 0 git diff --check clean, exit code 0 ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12 via `uv`, real Headroom proxy app with a local stub Anthropic upstream - Exact command / steps: send an actual optimize-on first request through the in-process proxy with a deterministic production-pipeline seam, then send a decision-false bypass turn containing a large Claude-shaped `tool_result` with moved `cache_control`; separately send an aligned optimize-on turn with a new suffix - Observed result: the exact base checkout fails with `AssertionError: assert 'compressed-tool-result' == 'large-tool-result-marker ...'`; the guarded path passes with the client marker present once and outbound compact JSON no larger than the client body. The optimize-on preservation run records `optimize_on turn2_message_count=3 client_message_count=3 marker_count=0 outbound_compact_utf8_bytes=171 client_compact_utf8_bytes=182`, proving the actual compressed prefix is outbound before the new suffix without turn-2 growth. - Not tested: live Claude Code session against api.anthropic.com on this host ## Runtime Rollout Safety - Rollout-managed feature(s): none. - Minimum rollout channel: N/A. - Stable/default behavior changed: cached-prefix replay now follows the existing compression and backpressure decision and rejects misaligned or inflating candidates. - Kill switch / disable path: no new switch; the existing optimize and bypass controls remain available. - Unsafe override required: none. - Qualification impact: none. - Rollback path: revert the implementation commit. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] 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 ## Additional Notes `CHANGELOG.md` is not modified because Headroom's release automation generates it from conventional commits. This change does not add a context-limit guard or alter compression, streaming tracker provenance, outbound-body selection, OpenAI behavior, or provider limits. Local tests prove request-body ownership and replay bounds. The reporter's live Claude Code completion and Anthropic token acceptance remain external to this local proof. |
||
|
|
1a04c957f5
|
fix(cache): stabilize Anthropic block-growing lineages (#2917)
## Description Fixes the remaining Anthropic prompt-cache failure in #2671 and the newly reported parallel-tool-profile variant. The production failure has three connected parts: 1. `SessionTrackerStore.resolve_tracker` only recognized whole-message prefixes. A caller that grows or regenerates blocks inside one message therefore received a fresh tracker every turn, so previous forwarded state was always empty and breakpoint relocation could never run. 2. `normalize_message_cache_control` always moved the message breakpoint to the newest block. That is correct for a pure block append, but a message that rewrites its tail can never match the prior newest-block write and repeatedly rewrites the full message prefix. 3. Parallel Anthropic sub-calls can carry identical messages but different tools. Because tools precede messages in the provider cache key, sharing one frozen-prefix tracker across those calls cross-contaminates cache state even when message lineage is identical. This PR deliberately combines the valid parts of #2699 and #2702, fixes the discriminator between their two shapes, and adds cache-key affinity for the second pattern reported on #2671. In particular, a pure append is identified by `stable_prefix_blocks == previous_block_count`; rewritten-tail relocation is only possible when `stable_prefix_blocks < previous_block_count`. This prevents a pure append from being pinned to an old boundary. Closes #2671. ## 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 - [x] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added one canonical history classifier with distinct exact, whole-message append, pure block append, rewritten-tail, and diverged outcomes. - Kept pure block appends on newest-block breakpoint placement so each request reads the old prefix and writes only its appended blocks. - Added block-level replay of the prior forwarded bytes for pure appends; the whole-message delta path explicitly refuses this shape so it cannot silently discard appended blocks. - Kept a rewritten-tail request on its existing tracker and anchored its breakpoint to the end of the byte-stable leading run. - Made rewritten-tail matching conservative: one changed message, unchanged message count, no shrink, at least 8 stable leading blocks covering at least half of old and new content, and a fixed suffix of at least 2 blocks. - Required a unique best rewritten-tail lineage match. Ambiguity creates a fresh lineage instead of making sibling sub-calls ping-pong one tracker. - Added a stable affinity fingerprint over model, deterministically forwarded tools, tool choice, thinking, and output configuration. Different provider cache-key profiles cannot share frozen-prefix state. - Snapshotted previous original/forwarded messages once in the Anthropic handler and reused that exact state for delta extraction, replay, and breakpoint placement. - Added `HEADROOM_STABLE_BOUNDARY_BREAKPOINT=0` as a rollback switch for rewritten-tail relocation. The canonical projection is used only for comparison. Replayed content always comes from the exact previously forwarded bytes or the current raw/optimized tail; canonicalized data is never reconstructed into an upstream request. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check` on changed files) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_issue_2671_block_growth_cache.py -q 12 passed in 0.10s $ python -m pytest tests/test_cache -q 253 passed, 3 skipped in 1.94s $ python -m pytest <Anthropic handler/proxy regression set> -q 134 passed, 1 warning in 9.42s $ ruff format --check <changed files> 4 files already formatted $ ruff check <changed files> All checks passed! $ git diff --check # clean ``` The Anthropic regression set covers beta stickiness, CCR injection, compaction transforms, pre-upstream backpressure, streaming reconstruction, upstream headers, model sanitization, diagnostics, and cache stability. ## Real Behavior Proof - Environment: macOS, Python 3.12.13, real FastAPI Anthropic handler with a local upstream stub plus a deterministic provider-cache oracle. - Exact steps: send a cold 35-block aggregate message, then three requests that preserve a 30-block prefix and fixed two-block suffix while regenerating a growing middle tail. Resolve the real session tracker, normalize the real handler body, record the response, and repeat. - Observed result: handler breakpoint indices are `34 -> 29 -> 29`; the cache oracle transitions from a cold 35-block write to establishing the 30-block stable boundary, then produces `(read=30, write=0)` on subsequent rewritten-tail turns. A separate pure-append sequence produces `(0,30) -> (30,4) -> (34,4) -> (38,5)`, proving its breakpoint continues to advance. - Also observed: identical message histories with different tool schemas resolve to distinct trackers in the real handler path. - Not tested: a live Anthropic billing soak, the complete repository test suite, or mypy. #2702 contains earlier live production measurements for the rewritten-tail mechanism; this PR adds the pure-append correction, affinity isolation, and broader regression model. ## 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 documentation updates where applicable (internal behavior is documented in code; no user-facing surface changed) - [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 relevant unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from the Conventional Commit PR title ## Additional Notes - Consolidates the complementary approaches in #2699 and #2702. Credit to @axisrow and @nangsontay for the traces, root-cause work, and live validation that made the two production shapes distinguishable. - The 20-block minimum for relocation mirrors the provider lookup-window risk boundary and keeps short ordinary messages on the established newest-block behavior. - Disabling stable-boundary relocation does not disable improved lineage resolution or tool-profile isolation; it restores only the previous breakpoint placement. Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local> |