Skip to content

Commit 2285889

Browse files
authored
Merge branch 'feat/token-mode-prefix-cache' into main
2 parents eba812e + f191995 commit 2285889

22 files changed

Lines changed: 1213 additions & 61 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414

1515
### Fixed
1616
- **install:** `install_supervisor`'s macOS branch did an unconditional `launchctl bootout` followed by a bare `bootstrap` with no retry, unlike `start_supervisor`, which already rides out the ~15s EIO (error 5) window launchd exhibits for several seconds after a bootout. This left `install apply`'s own reinstall path (and anything that re-applies a deployment, e.g. a future `headroom doctor --fix`) exposed to a race that previously required manual recovery (bootout + remove the plist + reapply). Extracted the retry loop already used by `start_supervisor` into a shared `_bootstrap_with_retry()` helper, now used by both call sites.
17+
- **proxy/anthropic:** CCR system-instruction injection (`ccr_inject_system_instructions`, default off) now appends to the top-level `body["system"]` field instead of prepending a `role:"system"` message to `messages` — the Anthropic Messages API rejects that message shape outright, so every marker-bearing turn with the flag enabled previously got a 400 from upstream. The injected text is now a static, hash-free block (`create_stable_system_instructions`) appended idempotently (existing `cache_control` breakpoints on prior system blocks are left untouched), so the system segment stays byte-identical turn over turn instead of busting the cache via the old per-turn hash list.
18+
- **proxy/ccr:** the messages-based system-instruction injection path (`CCRToolInjector.inject_into_system_message`, used by OpenAI-format clients when `ccr_inject_system_instructions` is enabled) now injects the same static, hash-free block via `create_stable_system_instructions` instead of `create_system_instructions`'s per-turn `Available hashes` list, so an OpenAI-format system prompt stays byte-identical turn over turn instead of busting the provider's prompt cache each time the enumerated hashes change.
19+
- **proxy/ccr:** CCR proactive-expansion and memory-context injection now skip appending to the live-zone tail message when that exact position was already forwarded (and byte-replayed by `overlay_cached_prefix`) last turn. Previously both injections ran unconditionally in token mode, so a same-messages re-request appended a second copy on top of the one `overlay_cached_prefix` had just replayed — busting the final segment and growing it unboundedly turn over turn. Proactive-expansion recommendations are also deduped per session (a `SessionExpansionDedupTracker`, mirroring the existing sticky-CCR-tool session store) so the same compressed-content hash is never injected twice into one conversation, and `ContextTracker.analyze_query`/`track_compression` now accept an optional `session_id` so a fresh session started after Claude Code's `/compact` no longer has pre-compact compressed content surfaced back into it ([#2186](https://github.com/headroomlabs-ai/headroom/issues/2186)).
20+
- **proxy/session:** `compute_session_id`'s fallback (used when no `x-headroom-session-id` header is sent) now folds in a conversation discriminator — the caller-supplied hint (Anthropic `metadata.user_id` / OpenAI `user`) or, failing that, the first user message's text — instead of hashing only `model + system prompt`. Two concurrent or successive conversations sharing a model and system prompt (two Claude Code tabs on one repo, a new session started after ending one) previously collapsed onto the same `PrefixCacheTracker`, so interleaved turns overwrote each other's recorded original/forwarded messages and busted the prompt cache for both ([#1808](https://github.com/headroomlabs-ai/headroom/issues/1808)).
21+
- **proxy/openai:** the OpenAI-format chat-completions path (`handle_openai_chat`, both the LiteLLM/any-llm backend branch and the direct-backend branch) and its streaming finalizer now pass the raw client messages as `original_messages` to `PrefixCacheTracker.update_from_response`. All three call sites previously omitted it, so the tracker fell back to recording the FORWARDED (compressed) bytes as "originals" — the next turn's append-only cache-safety check then compared raw client bytes against compressed ones, diverged immediately, and never replayed the cached prefix, silently busting the provider's prompt cache on every turn for OpenAI-format clients in token mode.
1722
- **proxy/savings:** `SavingsTracker.record_request()` only appended a history point when `tokens_saved > 0` (headroom's own lossy compression). In `--mode cache`, `tokens_saved` is near-always 0 by design, since the frozen prefix is byte-replayed rather than compressed to keep the provider's prompt cache warm. That silently dropped every history point on a cache-mode deployment even when `cache_read_tokens`/`cache_savings_usd` were large, making `headroom-monthly`-style tooling read as a total savings collapse. The guard now fires on `tokens_saved` OR `cache_read_tokens`, and the appended entry carries `cache_read_tokens`/`cache_savings_usd` so downstream consumers can show them; `_normalize_history_entry` defaults both fields to 0/0.0 for legacy entries that predate this change.
1823
- **litellm:** vendor-specific top-level fields on `/v1/chat/completions`, including vLLM's `chat_template_kwargs` for per-request Qwen3 thinking-mode toggles, now reach OpenAI-compatible backends through LiteLLM `extra_body` instead of being dropped by the standard-parameter allowlist ([#2128](https://github.com/headroomlabs-ai/headroom/issues/2128)).
1924
- **cache aligner:** hash the actual frozen Claude Code prefix instead of only system-message text, so `stable_prefix_hash` / `prefix_changed` now surface prompt-cache churn when a cached tool-result block changes without any system-prompt edit ([#2085](https://github.com/headroomlabs-ai/headroom/issues/2085)).

docs/content/docs/configuration.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ For provider-only proxying, prefer `HEADROOM_HTTP_PROXY` over process-wide varia
317317

318318
### Session Beta Header Tracking
319319

320-
When running as a proxy, Headroom maintains a per-session union of `anthropic-beta` (and `OpenAI-Beta`) tokens via `SessionBetaTracker`. The session key is derived from the `x-headroom-session-id` header if present, otherwise from `md5(model + system_prompt[:500])[:16]`stable across turns of the same conversation.
320+
When running as a proxy, Headroom maintains a per-session union of `anthropic-beta` (and `OpenAI-Beta`) tokens via `SessionBetaTracker`. The session key is derived from the `x-headroom-session-id` header if present, otherwise from `md5(model + system_prompt + conversation_discriminator)[:16]`, where the discriminator is a client-supplied hint (Anthropic `metadata.user_id` / OpenAI `user`) or, failing that, the first user message's text. This keeps the key stable across turns of one conversation while staying distinct across concurrent conversations that share a model and system prompt.
321321

322322
**Why:** clients such as Claude Code and Codex CLI may drop a beta token between consecutive turns. Because `anthropic-beta` is part of the request bytes that determine the upstream prefix-cache key, a dropped token would bust the cache mid-conversation. The tracker re-injects any token seen earlier in the session so the cache key stays stable.
323323

headroom/cache/prefix_tracker.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,33 @@ def overlay_cached_prefix(
351351
return list(prev_fwd[:k]) + list(optimized_messages[k:])
352352

353353

354+
def is_append_only_extension(
355+
current_original_messages: list[dict[str, Any]],
356+
previous_original_messages: list[dict[str, Any]] | None,
357+
) -> bool:
358+
"""Return True iff ``previous_original_messages`` is a full canonical
359+
prefix of ``current_original_messages``.
360+
361+
This is the same append-only condition ``overlay_cached_prefix`` requires
362+
before it will replay ANY previously-forwarded content — a position is
363+
only guaranteed to hold last turn's cached bytes when this holds for the
364+
ENTIRE previous message list, not just a leading run of it. Used by
365+
handler-level injection guards to detect "this exact tail position was
366+
already forwarded last turn" without duplicating the canonicalization
367+
logic.
368+
"""
369+
if not previous_original_messages:
370+
return False
371+
n = len(previous_original_messages)
372+
if len(current_original_messages) < n:
373+
return False
374+
return all(
375+
_canonicalize_for_prefix_compare(current_original_messages[i])
376+
== _canonicalize_for_prefix_compare(previous_original_messages[i])
377+
for i in range(n)
378+
)
379+
380+
354381
def normalize_message_cache_control(
355382
messages: list[dict[str, Any]],
356383
) -> list[dict[str, Any]]:
@@ -482,6 +509,14 @@ def update_from_response(
482509
"""
483510
self._last_activity = time.time()
484511
self._turn_number += 1
512+
if original_messages is None:
513+
logger.warning(
514+
"PrefixCacheTracker[%s]: update_from_response called without "
515+
"original_messages — falling back to forwarded messages as "
516+
"originals, which busts the overlay's append-only cache-safety "
517+
"check on the next turn.",
518+
self.provider,
519+
)
485520
self._last_original_messages = copy.deepcopy(original_messages or messages)
486521
self._last_forwarded_messages = copy.deepcopy(messages)
487522

@@ -764,6 +799,30 @@ def _estimate_message_tokens(messages: list[dict[str, Any]]) -> list[int]:
764799
return counts
765800

766801

802+
def _first_user_message_text(messages: list[dict[str, Any]]) -> str:
803+
"""Extract the first user message's text, capped for use as a hash input.
804+
805+
Stable within one append-only conversation (the client always resends the
806+
first message unchanged) but differs across conversations in practice, so
807+
it serves as a conversation discriminator when no explicit hint is given.
808+
"""
809+
for msg in messages:
810+
if msg.get("role") != "user":
811+
continue
812+
content = msg.get("content", "")
813+
if isinstance(content, str):
814+
return content[:500]
815+
if isinstance(content, list):
816+
parts = [
817+
block.get("text", "")
818+
for block in content
819+
if isinstance(block, dict) and block.get("type") == "text"
820+
]
821+
return "".join(parts)[:500]
822+
return ""
823+
return ""
824+
825+
767826
class SessionTrackerStore:
768827
"""Manages PrefixCacheTracker instances across sessions.
769828
@@ -800,12 +859,19 @@ def compute_session_id(
800859
request: Any,
801860
model: str,
802861
messages: list[dict[str, Any]],
862+
*,
863+
conversation_hint: str | None = None,
803864
) -> str:
804865
"""Compute a session ID from the request.
805866
806867
Priority:
807868
1. x-headroom-session-id header (explicit)
808-
2. Hash of (model + system prompt) — stable per conversation
869+
2. Hash of (model + system prompt + conversation discriminator)
870+
871+
The discriminator is ``conversation_hint`` when the caller supplies one
872+
(Anthropic ``metadata.user_id`` / OpenAI ``user``), else the first user
873+
message's text. It keeps concurrent or successive conversations that
874+
share a model+system prompt from colliding onto one tracker.
809875
810876
The system prompt is harvested from ``role:"system"`` entries in
811877
``messages``. Anthropic carries the system prompt as a top-level
@@ -834,7 +900,17 @@ def compute_session_id(
834900
system_parts.append(block.get("text", ""))
835901

836902
system_content = json.dumps(system_parts, ensure_ascii=False, separators=(",", ":"))
837-
key = f"{model}:{system_content}"
903+
# Conversation discriminator: two conversations sharing model+system
904+
# (concurrent tabs, a new session started after ending one) must NOT
905+
# collapse onto the same tracker/CCR-sticky state. Prefer a
906+
# client-supplied hint (Anthropic metadata.user_id / OpenAI user);
907+
# fall back to the first user message's text, which is stable across
908+
# append-only turns of ONE conversation but differs across
909+
# conversations in practice. Hash only — never log raw content.
910+
discriminator = str(conversation_hint)[:200] if conversation_hint else ""
911+
if not discriminator:
912+
discriminator = _first_user_message_text(messages)
913+
key = f"{model}:{system_content}:{discriminator}"
838914
return hashlib.md5(key.encode()).hexdigest()[:16] # nosec B324
839915

840916
def _maybe_cleanup(self) -> None:

headroom/ccr/context_tracker.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class CompressedContext:
5656
query_context: str # The query/context when compression happened
5757
sample_content: str # Preview of what was compressed (for relevance matching)
5858
workspace_key: str # Stable per-project identity (see ProjectResolver in storage_router)
59+
session_id: str | None = None # Conversation identity (see analyze_query)
5960

6061

6162
@dataclass
@@ -135,6 +136,7 @@ def track_compression(
135136
compressed_count: int,
136137
*,
137138
workspace_key: str,
139+
session_id: str | None = None,
138140
query_context: str = "",
139141
sample_content: str = "",
140142
) -> None:
@@ -167,6 +169,7 @@ def track_compression(
167169
query_context=query_context,
168170
sample_content=sample_content[:2000], # Limit sample size
169171
workspace_key=workspace_key,
172+
session_id=session_id,
170173
)
171174

172175
# Add or update context
@@ -193,6 +196,7 @@ def analyze_query(
193196
current_turn: int | None = None,
194197
*,
195198
workspace_key: str,
199+
session_id: str | None = None,
196200
) -> list[ExpansionRecommendation]:
197201
"""Analyze a query to find relevant compressed contexts.
198202
@@ -240,6 +244,18 @@ def analyze_query(
240244
if context.workspace_key != workspace_key:
241245
continue
242246

247+
# Session filter (#2186): a NEW session (e.g. started after
248+
# Claude Code's /compact) must not have old compressed content
249+
# surfaced back into it — the whole point of compacting was to
250+
# drop it. Only enforced when both sides have a concrete id;
251+
# legacy/no-session callers keep workspace-only scoping.
252+
if (
253+
session_id is not None
254+
and context.session_id is not None
255+
and context.session_id != session_id
256+
):
257+
continue
258+
243259
# Check age
244260
age = now - context.timestamp
245261
if age > self.config.max_context_age_seconds:

headroom/ccr/tool_injection.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,42 @@ def create_system_instructions(
144144
"""
145145

146146

147+
def create_stable_system_instructions(
148+
retrieval_endpoint: str = "/v1/retrieve",
149+
) -> str:
150+
"""Create session-stable CCR retrieval instructions (no hash list).
151+
152+
Same purpose as ``create_system_instructions`` but WITHOUT the per-turn
153+
``**Available hashes:**`` list. Once a session injects this text, it must
154+
stay byte-identical every subsequent turn or the segment it lives in
155+
busts the provider's prompt cache each time the enumerated hashes change.
156+
The model can always find a compressed output's hash from the marker
157+
already present in that tool result (``[N items compressed to M.
158+
Retrieve more: hash=abc123]``), so the separate list was redundant, not
159+
load-bearing.
160+
161+
Args:
162+
retrieval_endpoint: The endpoint path for retrieval (unused in the
163+
text today, kept for signature parity with
164+
``create_system_instructions``).
165+
166+
Returns:
167+
Static instruction text to append to the system prompt.
168+
"""
169+
return f"""
170+
## Compressed Context Available
171+
172+
Some tool outputs have been compressed to reduce context size. If you need
173+
the full uncompressed data, you can retrieve it using the `{CCR_TOOL_NAME}` tool.
174+
175+
**How to retrieve:**
176+
- Call `{CCR_TOOL_NAME}(hash="<hash>")` to get the full original content back
177+
178+
Look for markers like `[N items compressed to M. Retrieve more: hash=abc123]`
179+
in tool results to find the hash for each compressed output.
180+
"""
181+
182+
147183
@dataclass
148184
class CCRToolInjector:
149185
"""Manages CCR tool injection into LLM requests.
@@ -362,8 +398,12 @@ def inject_into_system_message(
362398
if not self.inject_system_instructions or not self.has_compressed_content:
363399
return messages
364400

365-
instructions = create_system_instructions(
366-
self._detected_hashes,
401+
# Use the hash-free stable variant. create_system_instructions embeds
402+
# an "Available hashes" list that changes every turn as new content is
403+
# compressed, which busts the provider's prompt cache on the system
404+
# segment. The list is redundant — each compressed tool result carries
405+
# its own retrievable hash in its marker — so drop it here.
406+
instructions = create_stable_system_instructions(
367407
self.retrieval_endpoint,
368408
)
369409

headroom/proxy/ccr_session_tracker.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,56 @@ def reset(self) -> None:
8585

8686
with self._lock:
8787
self._sessions.clear()
88+
89+
90+
class SessionExpansionDedupTracker:
91+
"""Bounded LRU tracker for per-session CCR proactive-expansion dedup.
92+
93+
Prevents the same compressed-content hash from being proactively
94+
re-injected more than once per session (#2186): a same-messages
95+
re-request or a continued conversation must not receive duplicate
96+
expansion blocks for content the agent already saw this session.
97+
Dedup is per-session, not global — a different conversation may
98+
legitimately need the same expansion.
99+
"""
100+
101+
def __init__(self, max_sessions: int) -> None:
102+
if max_sessions <= 0:
103+
raise ValueError("max_sessions must be > 0")
104+
self._max_sessions = max_sessions
105+
self._lock = threading.RLock()
106+
self._sessions: OrderedDict[str, set[str]] = OrderedDict()
107+
108+
def filter_new(self, session_id: str, hash_keys: list[str]) -> list[str]:
109+
"""Return the subset of hash_keys not yet injected for this session."""
110+
111+
if not session_id:
112+
raise ValueError("session_id must be non-empty")
113+
with self._lock:
114+
seen = self._sessions.get(session_id)
115+
if seen is None:
116+
return list(hash_keys)
117+
return [h for h in hash_keys if h not in seen]
118+
119+
def record_injected(self, session_id: str, hash_keys: list[str]) -> None:
120+
"""Mark hash_keys as injected for this session."""
121+
122+
if not session_id:
123+
raise ValueError("session_id must be non-empty")
124+
if not hash_keys:
125+
return
126+
with self._lock:
127+
seen = self._sessions.get(session_id)
128+
if seen is None:
129+
seen = set()
130+
self._sessions[session_id] = seen
131+
seen.update(hash_keys)
132+
self._sessions.move_to_end(session_id)
133+
while len(self._sessions) > self._max_sessions:
134+
self._sessions.popitem(last=False)
135+
136+
def reset(self) -> None:
137+
"""Clear all session state."""
138+
139+
with self._lock:
140+
self._sessions.clear()

0 commit comments

Comments
 (0)