Skip to content

Commit ef55ade

Browse files
authored
Merge branch 'dev' into feat/CCR-reverse-stats
2 parents fe91de7 + c810aa0 commit ef55ade

54 files changed

Lines changed: 4278 additions & 504 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/environment-variables.md

Lines changed: 476 additions & 0 deletions
Large diffs are not rendered by default.

headroom/agent_savings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,12 @@ def proxy_pipeline_kwargs(config: object) -> dict[str, object]:
279279
compression knobs should be consistent across Claude, Codex, and Cursor.
280280
"""
281281

282+
# NOTE: defer_waste_signals is intentionally NOT set here. This dict feeds
283+
# every provider handler (Anthropic, OpenAI, Gemini, batch endpoints), but
284+
# only the Anthropic messages route has the off-path background task that
285+
# compensates for deferral; deferring everywhere silently dropped
286+
# waste-signal telemetry on the other routes. The Anthropic handler passes
287+
# defer_waste_signals=True explicitly at its covered call sites.
282288
kwargs: dict[str, object] = {}
283289
profile_name = getattr(config, "savings_profile", None)
284290
if profile_name:

headroom/cache/compression_cache.py

Lines changed: 91 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,10 @@
66

77
from __future__ import annotations
88

9-
import copy
109
import hashlib
1110
import json
1211
import logging
1312
import threading
14-
import time
1513
from collections import OrderedDict
1614
from dataclasses import dataclass
1715

@@ -77,16 +75,22 @@ def _extract_tool_result_content(msg: dict) -> str | None:
7775

7876

7977
def _swap_tool_result_content(msg: dict, new_content: str) -> dict:
80-
"""Deep copy msg and replace tool result content with new_content."""
81-
new_msg = copy.deepcopy(msg)
78+
"""Shallow-copy msg and replace tool result content with new_content.
79+
80+
Only the message dict, its content list, and the swapped tool_result
81+
block are copied; sibling blocks stay shared refs with the input. Safe
82+
because nothing downstream mutates message/block dicts in place (Phase 2
83+
mutation audit) — a deepcopy here bought no safety, only cost, and ran
84+
inside the cache lock (perf review F4).
85+
"""
8286
# OpenAI format
83-
if new_msg.get("role") == "tool":
84-
new_msg["content"] = new_content
85-
return new_msg
87+
if msg.get("role") == "tool":
88+
return {**msg, "content": new_content}
8689
# Anthropic format
87-
content = new_msg.get("content")
90+
content = msg.get("content")
8891
if isinstance(content, list):
89-
for block in content:
92+
new_content_list = list(content)
93+
for i, block in enumerate(new_content_list):
9094
if isinstance(block, dict) and block.get("type") == "tool_result":
9195
inner = block.get("content")
9296
if isinstance(inner, list):
@@ -95,11 +99,13 @@ def _swap_tool_result_content(msg: dict, new_content: str) -> dict:
9599
# multiple text blocks would produce a different joined
96100
# output on re-extraction (first text block replaced,
97101
# remaining text blocks still joined).
98-
block["content"] = [{"type": "text", "text": new_content}]
102+
new_block_content: str | list = [{"type": "text", "text": new_content}]
99103
else:
100-
block["content"] = new_content
104+
new_block_content = new_content
105+
new_content_list[i] = {**block, "content": new_block_content}
101106
break
102-
return new_msg
107+
return {**msg, "content": new_content_list}
108+
return dict(msg)
103109

104110

105111
class CompressionCache:
@@ -116,7 +122,7 @@ def __init__(self, max_entries: int = 10000) -> None:
116122
# session (Claude Code background tools, parallel agents, etc.) into
117123
# `asyncio.to_thread` workers — without this, two concurrent
118124
# requests for the same `session_id` race on `_cache`,
119-
# `_stable_hashes`, `_first_seen`, and `_total_tokens_saved`. The
125+
# `_stable_hashes`, and `_total_tokens_saved`. The
120126
# observable failures are (a) lost-update on `_total_tokens_saved`,
121127
# (b) `OrderedDict mutated during iteration` in `apply_cached`, and
122128
# (c) lost stable-hash records that drop the next-turn cache lookup.
@@ -136,7 +142,6 @@ def __init__(self, max_entries: int = 10000) -> None:
136142
# `proxy/handlers/anthropic.py`) and `update_from_result`'s
137143
# "unchanged content" tracking.
138144
self._stable_hashes: set[str] = set()
139-
self._first_seen: dict[str, float] = {}
140145
self._hits: int = 0
141146
self._misses: int = 0
142147
self._total_tokens_saved: int = 0
@@ -191,40 +196,6 @@ def mark_stable_from_messages(self, messages: list[dict], up_to: int) -> None:
191196
if content is not None:
192197
self._stable_hashes.add(self.content_hash(content))
193198

194-
def should_defer_compression(
195-
self,
196-
content_hash: str,
197-
ttl_seconds: float = 300.0,
198-
batch_window: float = 30.0,
199-
) -> bool:
200-
"""Whether to defer compressing this content to avoid mid-TTL busts.
201-
202-
Returns True if we have evidence this content has been re-sent
203-
within the cache TTL window — recompressing it now would bust an
204-
existing prefix-cache entry without TTL-amortizing the bust over
205-
future turns. Returns False otherwise:
206-
207-
- **First sight** of the content. Compress now: there is no
208-
prefix-cache entry to preserve yet (this byte range was not in
209-
a prior request), so compression carries no bust cost. Issue
210-
#327: a previous version returned True here, which marked the
211-
freshest tool_result on every turn as "stable" and effectively
212-
disabled compression for typical Claude Code workloads where
213-
each tool_result is unique-per-turn.
214-
- **Near the TTL boundary**: compress now and amortize the bust
215-
across future turns (batched recompression).
216-
"""
217-
with self._lock:
218-
now = time.time()
219-
first_seen = self._first_seen.get(content_hash)
220-
if first_seen is None:
221-
self._first_seen[content_hash] = now
222-
return False # First time — compress now (no cache entry to preserve)
223-
age = now - first_seen
224-
if age >= ttl_seconds - batch_window:
225-
return False # Near TTL boundary — compress now (batch window)
226-
return True # Seen recently within TTL — defer to preserve cache
227-
228199
def get_stats(self) -> dict:
229200
"""Return cache statistics."""
230201
with self._lock:
@@ -311,6 +282,78 @@ def apply_cached(self, messages: list[dict]) -> list[dict]:
311282
result.append(msg)
312283
return result
313284

285+
def prepare_turn(
286+
self, messages: list[dict], tracker_frozen_count: int
287+
) -> tuple[int, list[dict]]:
288+
"""Single-pass replacement for compute_frozen_count + mark_stable_from_messages + apply_cached.
289+
290+
Extracts and hashes each tool_result exactly ONCE instead of 3-4
291+
times per request (perf review F4). Returns ``(frozen_count,
292+
working_messages)``:
293+
294+
- ``frozen_count``: identical semantics to ``compute_frozen_count``
295+
plus the caller's ``min(frozen_message_count, cache_frozen_count)``
296+
clamp (including the trailing-message live-zone cap) — pass in
297+
the provider-confirmed ``tracker_frozen_count`` and get the final
298+
clamped value back directly.
299+
- ``working_messages``: identical to ``apply_cached(messages)`` —
300+
cached compressions swapped in where available. Computing this is
301+
free here (same walk); callers that skip compression this turn
302+
(e.g. deferred tool injection) may simply ignore it and use
303+
``messages`` directly. Note: because the skip decision depends on
304+
the frozen count this method returns, the swap lookup runs before
305+
the caller can know it will skip — so hit/miss stats and the LRU
306+
recency touch fire on skip turns too (the old ``apply_cached``
307+
wasn't called there). Telemetry-only drift; the touched entries
308+
are genuinely in active use, so the recency signal stays honest.
309+
310+
Content extraction + hashing happen BEFORE the lock is acquired —
311+
the lock only needs to guard ``_cache``/``_stable_hashes`` access
312+
(perf review F4 lock-hygiene note).
313+
"""
314+
# `hashes[i]` is None for non-tool_result messages or tool_results
315+
# with non-string/unextractable content (mirrors the "treat as
316+
# unstable" branch in the old `compute_frozen_count`).
317+
hashes: list[str | None] = [None] * len(messages)
318+
for i, msg in enumerate(messages):
319+
if _is_tool_result_message(msg):
320+
content = _extract_tool_result_content(msg)
321+
if content is not None:
322+
hashes[i] = self.content_hash(content)
323+
324+
with self._lock:
325+
# (a) compute_frozen_count equivalent, using pre-mark state —
326+
# matches the original call order (frozen count computed BEFORE
327+
# mark_stable_from_messages runs).
328+
count = 0
329+
for i, msg in enumerate(messages):
330+
if _is_tool_result_message(msg):
331+
h = hashes[i]
332+
if h is None or (h not in self._cache and h not in self._stable_hashes):
333+
break
334+
count += 1
335+
local_frozen_count = min(count, max(0, len(messages) - 1))
336+
frozen_count = min(tracker_frozen_count, local_frozen_count)
337+
338+
# (b) mark_stable_from_messages(messages, frozen_count) equivalent.
339+
for i in range(frozen_count):
340+
h = hashes[i]
341+
if h is not None:
342+
self._stable_hashes.add(h)
343+
344+
# (c) apply_cached equivalent.
345+
working_messages: list[dict] = []
346+
for i, msg in enumerate(messages):
347+
h = hashes[i]
348+
if h is not None:
349+
compressed = self.get_compressed(h)
350+
if compressed is not None:
351+
working_messages.append(_swap_tool_result_content(msg, compressed))
352+
continue
353+
working_messages.append(msg)
354+
355+
return frozen_count, working_messages
356+
314357
def update_from_result(self, originals: list[dict], compressed: list[dict]) -> None:
315358
"""Cache new compressions by comparing original and compressed messages.
316359

headroom/cache/prefix_tracker.py

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,33 @@ def overlay_cached_prefix(
359359
return list(prev_fwd[:k]) + list(optimized_messages[k:])
360360

361361

362+
def is_append_only_extension(
363+
current_original_messages: list[dict[str, Any]],
364+
previous_original_messages: list[dict[str, Any]] | None,
365+
) -> bool:
366+
"""Return True iff ``previous_original_messages`` is a full canonical
367+
prefix of ``current_original_messages``.
368+
369+
This is the same append-only condition ``overlay_cached_prefix`` requires
370+
before it will replay ANY previously-forwarded content — a position is
371+
only guaranteed to hold last turn's cached bytes when this holds for the
372+
ENTIRE previous message list, not just a leading run of it. Used by
373+
handler-level injection guards to detect "this exact tail position was
374+
already forwarded last turn" without duplicating the canonicalization
375+
logic.
376+
"""
377+
if not previous_original_messages:
378+
return False
379+
n = len(previous_original_messages)
380+
if len(current_original_messages) < n:
381+
return False
382+
return all(
383+
_canonicalize_for_prefix_compare(current_original_messages[i])
384+
== _canonicalize_for_prefix_compare(previous_original_messages[i])
385+
for i in range(n)
386+
)
387+
388+
362389
def normalize_message_cache_control(
363390
messages: list[dict[str, Any]],
364391
) -> list[dict[str, Any]]:
@@ -504,6 +531,14 @@ def update_from_response(
504531
"""
505532
self._last_activity = time.time()
506533
self._turn_number += 1
534+
if original_messages is None:
535+
logger.warning(
536+
"PrefixCacheTracker[%s]: update_from_response called without "
537+
"original_messages — falling back to forwarded messages as "
538+
"originals, which busts the overlay's append-only cache-safety "
539+
"check on the next turn.",
540+
self.provider,
541+
)
507542
self._last_original_messages = copy.deepcopy(original_messages or messages)
508543
self._last_forwarded_messages = copy.deepcopy(messages)
509544

@@ -546,10 +581,12 @@ def update_from_response(
546581
)
547582

548583
def get_last_original_messages(self) -> list[dict[str, Any]]:
549-
return copy.deepcopy(self._last_original_messages)
584+
"""Returns internal state — treat as immutable; do not mutate messages or blocks."""
585+
return self._last_original_messages
550586

551587
def get_last_forwarded_messages(self) -> list[dict[str, Any]]:
552-
return copy.deepcopy(self._last_forwarded_messages)
588+
"""Returns internal state — treat as immutable; do not mutate messages or blocks."""
589+
return self._last_forwarded_messages
553590

554591
def resolved_cache_ttl_seconds(self) -> int:
555592
"""Effective prompt-cache lifetime for this session's provider."""
@@ -665,30 +702,6 @@ def record_bust_avoided(self, tokens_preserved: int, compression_foregone: int)
665702
self._tokens_preserved += tokens_preserved
666703
self._compression_foregone_tokens += compression_foregone
667704

668-
def should_force_compress(
669-
self,
670-
message_index: int,
671-
message_tokens: int,
672-
estimated_compressed_tokens: int,
673-
) -> bool:
674-
"""Check if compression savings outweigh cache preservation.
675-
676-
Returns True if we should bust the cache and compress anyway.
677-
This happens when compression would save a large fraction of tokens
678-
AND the savings exceed the cache read discount.
679-
"""
680-
if message_index >= self._cached_message_count:
681-
return True # Not in frozen prefix, always compress
682-
683-
if message_tokens == 0:
684-
return False
685-
686-
savings_fraction = (message_tokens - estimated_compressed_tokens) / message_tokens
687-
688-
# Would compression savings exceed the cache read discount?
689-
read_discount = _PROVIDER_READ_DISCOUNT.get(self.provider, 0.5)
690-
return savings_fraction > read_discount
691-
692705
@property
693706
def is_expired(self) -> bool:
694707
"""Check if this tracker has been idle beyond TTL."""

0 commit comments

Comments
 (0)