Files
wehub-resource-sync 0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

859 lines
38 KiB
Python

"""Prefix Cache Tracker — session-scoped state for cache-aware compression.
Tracks provider prefix cache state between turns so the transform pipeline
can freeze already-cached messages and only compress new content.
Problem: Clients like Claude Code already manage prefix caching (up to 4
cache_control breakpoints, growing-prefix strategy). If Headroom compresses
or modifies messages in the cached prefix, it invalidates the cache —
replacing a 90% read discount (Anthropic) or 50% (OpenAI) with a 25%
write penalty.
Solution: After each API response, record how many tokens the provider
cached. On the next turn, freeze that many messages so the transform
pipeline skips them entirely.
"""
from __future__ import annotations
import copy
import hashlib
import json
import logging
import time
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# Provider cache economics for cost comparisons
_PROVIDER_READ_DISCOUNT = {
"anthropic": 0.9, # 90% discount on reads
"openai": 0.5, # 50% discount on reads
"gemini": 0.9,
"bedrock": 0.9,
}
_PROVIDER_WRITE_PENALTY = {
"anthropic": 0.25, # 25% surcharge on writes
"openai": 0.0, # No write penalty
"gemini": 0.0,
"bedrock": 0.25,
}
# Default prompt-cache lifetime per provider, in seconds. Used by
# `classify_cache_miss` to decide whether a miss is most likely a TTL
# lapse (idle longer than this) versus a prefix-content change. Anthropic's
# default ephemeral cache is 5 minutes (matches
# headroom.cache.anthropic.ANTHROPIC_CACHE_TTL_SECONDS); the others are best-
# effort defaults and only matter once those providers are wired in. A
# session that opts into Anthropic's 1h cache breakpoint can override this
# via the tracker config (see PrefixFreezeConfig.cache_ttl_seconds).
_PROVIDER_CACHE_TTL_SECONDS = {
"anthropic": 300, # 5 minutes (default ephemeral cache)
"openai": 300, # automatic prefix cache, ~5-10 min; conservative floor
"gemini": 300,
"bedrock": 300,
}
@dataclass
class PrefixFreezeConfig:
"""Configuration for cache-aware prefix freezing."""
enabled: bool = True
min_cached_tokens: int = 1024 # Min cached tokens to activate freeze
session_ttl_seconds: int = 600 # Session tracker cleanup TTL
force_compress_threshold: float = 0.5 # Bust cache if compression saves > this fraction
# Provider prompt-cache lifetime used by `classify_cache_miss` to tell a
# TTL lapse from a prefix change. `None` falls back to the per-provider
# default in `_PROVIDER_CACHE_TTL_SECONDS`. Set to 3600 for a session that
# uses Anthropic's 1h cache breakpoint so idle-gap attribution stays honest.
cache_ttl_seconds: int | None = None
@dataclass
class FreezeStats:
"""Statistics from prefix freezing for metrics/dashboard."""
busts_avoided: int = 0
tokens_preserved: int = 0
compression_foregone_tokens: int = 0
net_benefit_tokens: int = 0 # tokens_preserved - compression_foregone
frozen_message_count: int = 0
turn_number: int = 0
# Cache-miss attribution verdicts. `reason` is one of these literals so
# metrics/dashboard can bucket without re-deriving the logic. See
# PrefixCacheTracker.classify_cache_miss.
MISS_TTL_EXPIRY = "ttl_expiry"
MISS_PREFIX_CHANGE = "prefix_change"
MISS_COLD_START = "cold_start" # no prior cached prefix to miss against
MISS_UNKNOWN = "unknown" # expected a hit, content stable, idle within TTL
@dataclass
class CacheMissAttribution:
"""Why a turn that expected a prompt-cache hit missed instead.
Produced by :meth:`PrefixCacheTracker.classify_cache_miss`. ``is_miss``
is False when the turn actually hit cache (or there was nothing to hit),
in which case ``reason`` is informational only.
"""
is_miss: bool
reason: str # one of the MISS_* literals
idle_seconds: float = 0.0
cache_ttl_seconds: int = 0
expected_cached_tokens: int = 0
cache_read_tokens: int = 0
prefix_changed: bool = False
ttl_exceeded: bool = False
def _strip_cache_control(obj: Any) -> Any:
"""Recursively drop ``cache_control`` for content-only equality checks.
Clients (notably Claude Code) move the cache_control breakpoint to the newest
message on every call, so the exact same message carries cache_control on one
turn and not the next. That per-call annotation must be ignored when deciding
whether this turn append-only-extends the previous one — otherwise a moved
marker spuriously fails the check and we skip the byte-identical replay,
busting the cache."""
if isinstance(obj, dict):
return {k: _strip_cache_control(v) for k, v in obj.items() if k != "cache_control"}
if isinstance(obj, list):
return [_strip_cache_control(v) for v in obj]
return obj
# Keys that carry NO semantic payload for the model — transport / caching-directive
# / telemetry / client-routing annotations that clients attach and vary turn-to-turn.
# Grounded in provider API docs (Anthropic Messages, OpenAI Chat+Responses, Bedrock
# Converse) + client-library field inventories (litellm, Vercel AI SDK, opencode,
# Claude Code, Cline). Dropped from the cross-turn prefix-equality key ONLY.
#
# NOTE ON SAFETY: this projection is a COMPARISON KEY, never a source to rebuild
# forwarded bytes — the cache-stable-delta path always forwards the previously
# forwarded bytes + the raw appended delta. So dropping these can't deprive the
# model. What we must NOT do is drop a *semantic* field (that would mask a real
# divergence and replay a stale prefix), which is why: (1) reasoning SIGNATURES are
# NOT in this set (Anthropic 400s if a thinking block is altered/missing, and a
# present/absent flip is a real divergence we want to detect); (2) tool inputs /
# arguments / json payloads are treated as OPAQUE and compared verbatim (see
# _OPAQUE_PAYLOAD_KEYS) so a user key that happens to be named "index"/"state" is
# never stripped from inside a tool call.
_NON_SEMANTIC_KEYS = frozenset(
{
# cache-breakpoint markers (moved to the newest block every turn)
"cache_control", # Anthropic (per-block)
"cachePoint", # Bedrock (per-block content block)
# litellm unified-message / tool annotations
"caller", # litellm programmatic-tool tag on tool_use
"provider_specific_fields",
"reasoning_content", # litellm display echo (the paired signature is separate)
"reasoning_items",
"annotations", # citation/display metadata
# OpenAI response echoes that can ride on assistant messages
"system_fingerprint",
"service_tier",
# Vercel AI SDK / opencode part transport
"providerMetadata",
"providerOptions",
"callProviderMetadata",
"state",
"providerExecuted",
"synthetic",
"ignored",
# streaming-assembly artifact
"index",
}
)
# Values under these keys are opaque semantic payloads (tool-call input, OpenAI
# stringified arguments, Bedrock tool_result json). They are compared VERBATIM — we
# never recurse into them to strip "noise" keys, because arbitrary user data there
# may legitimately contain keys that collide with _NON_SEMANTIC_KEYS (e.g. an
# `input` of {"state": "CA", "index": 3}). Recursing would corrupt the comparison.
_OPAQUE_PAYLOAD_KEYS = frozenset({"input", "arguments", "json"})
def _canonicalize_for_prefix_compare(obj: Any) -> Any:
"""Representation-agnostic canonical form for cross-turn prefix equality.
Providers accept several *equivalent* encodings for the same message, and real
clients vary them turn-to-turn; a raw-dict prefix compare then fails spuriously
and drops cache mode to raw (uncompressed) forwarding. This normalizes ONLY
representation:
* drops non-semantic annotation / cache-directive / telemetry keys
(_NON_SEMANTIC_KEYS) at any message/block level;
* wraps a bare string ``content`` into ``[{"type": "text", "text": ...}]``
(Anthropic's string sugar, which litellm flips per turn);
* leaves tool ``input`` / ``arguments`` / ``json`` payloads verbatim
(_OPAQUE_PAYLOAD_KEYS) so user data is never corrupted;
* KEEPS all real content (text, tool name/input, tool_result content, reasoning
signatures, ids) so two messages canonicalize-equal iff they are semantically
identical.
Used ONLY as a comparison key for the cache-stable delta path; the original,
unmodified messages are always what gets forwarded.
"""
if isinstance(obj, dict):
out: dict[str, Any] = {}
for key, value in obj.items():
if key in _NON_SEMANTIC_KEYS:
continue
if key in _OPAQUE_PAYLOAD_KEYS:
out[key] = value # verbatim — do not recurse into user payloads
elif key == "content" and isinstance(value, str):
out[key] = [{"type": "text", "text": value}]
else:
out[key] = _canonicalize_for_prefix_compare(value)
return out
if isinstance(obj, list):
canon = [_canonicalize_for_prefix_compare(value) for value in obj]
# Drop blocks that projected to {} — a pure cache-directive content block
# (e.g. Bedrock {"cachePoint": {...}}) whose only key was non-semantic. Left
# in place it would be an empty-dict entry, so a directive block moving
# position across turns would spuriously fail the length/order compare.
return [value for value in canon if value != {}]
return obj
def extract_cache_stable_delta(
current_messages: list[dict[str, Any]],
previous_original_messages: list[dict[str, Any]] | None,
previous_forwarded_messages: list[dict[str, Any]] | None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]] | None:
"""Return ``(stable_forwarded_prefix, appended_delta_messages)`` when the current
request append-only-extends the previous one, else ``None``.
Provider-agnostic delta engine for cache mode. "Append-only" is decided by comparing
the *canonicalized* prefix (:func:`_canonicalize_for_prefix_compare`, which ignores
per-turn transport / cache-directive / client-annotation noise across
Anthropic / OpenAI / Bedrock and the common clients), so a moved cache marker or
shape churn does not spuriously collapse cache mode to raw forwarding. On a match the
caller replays the byte-identical previously-forwarded prefix and compresses ONLY the
appended delta.
This is a COMPARISON + slice only: the returned prefix is the previously-forwarded
bytes verbatim and the delta is the raw appended messages — never a rebuild from the
canonical projection — so the projection dropping non-semantic fields is safe.
"""
if not previous_original_messages or previous_forwarded_messages is None:
return None
prefix_len = len(previous_original_messages)
if len(current_messages) < prefix_len:
return None
if _canonicalize_for_prefix_compare(
current_messages[:prefix_len]
) != _canonicalize_for_prefix_compare(previous_original_messages):
return None
return (
copy.deepcopy(previous_forwarded_messages),
copy.deepcopy(current_messages[prefix_len:]),
)
def overlay_cached_prefix(
optimized_messages: list[dict[str, Any]],
current_original_messages: list[dict[str, Any]],
previous_original_messages: list[dict[str, Any]] | None,
previous_forwarded_messages: list[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Replay the previously-forwarded (cached, compressed) prefix byte-identical.
Provider-agnostic cache-safety guard for the freeze path. When a message is
"frozen", the compression pipeline may emit the agent's ORIGINAL bytes for
it — but the provider cached whatever we FORWARDED last turn (the compressed
form). Forwarding the original then mismatches the cached prefix and busts
the prompt cache from that point (100% of observed misses were this
``prefix_change``). This overlays the exact previously-forwarded prefix onto
the corresponding leading messages so the forwarded prefix stays byte-for-byte
what the provider hashed for its cache key.
Safe only when this turn append-only-extends the previous turn (the standard
growing-conversation shape): the previous ORIGINAL messages must be an exact
prefix of the current ORIGINAL messages, and there is exactly one forwarded
message per original. Otherwise the previous forwarded bytes may not
correspond to the same positions, so we return ``optimized_messages``
unchanged (accept a possible bust rather than forward wrong content).
This makes freezing byte-identical in BOTH proxy modes, so the only remaining
difference between them is how large a mutable (still-compressible) tail each
leaves — not whether the frozen prefix busts the cache.
"""
prev_orig = previous_original_messages
prev_fwd = previous_forwarded_messages
if not prev_orig or not prev_fwd:
return optimized_messages
n = len(prev_orig)
# Positional 1:1 correspondence between prev_orig[i] and prev_fwd[i] holds
# only when last turn forwarded exactly one message per original (the
# append-only, no-injection shape update_from_response records). If the
# counts differ, an injected / dropped / merged message shifted the
# mapping, so replaying prev_fwd[i] at position i could forward the wrong
# content — bail (leave this turn's output untouched) rather than risk it.
if len(prev_fwd) != n:
logger.debug(
"overlay: forwarded/original count mismatch (prev_fwd=%d, prev_orig=%d) "
"— skipping cached-prefix replay (possible bust)",
len(prev_fwd),
n,
)
return optimized_messages
# Append-only guard on CONTENT ONLY, message-by-message. Replay the
# previously-forwarded (cached, compressed) bytes for the longest LEADING
# run of messages that is byte-for-byte (content-canonical) identical to
# what we forwarded last turn, and stop at the first divergence.
#
# This is the cache-safety centerpiece for token mode (which relies solely
# on this replay; cache mode is already byte-stable by construction). The
# prior all-or-nothing guard busted the ENTIRE cached prefix the moment any
# single leading message failed to canonicalize-equal last turn — most
# commonly the just-added assistant turn, whose client-resent form can
# differ trivially from the copy we reconstructed and recorded. Stopping at
# the first divergence instead keeps the (much larger) cache-hit region
# up to that point and only re-forwards from the changed message onward.
#
# Comparison uses the shared canonicalizer (not just cache_control
# stripping) so it is robust to ALL per-turn transport / annotation churn —
# cache_control movement (Anthropic), litellm `caller`,
# provider_specific_fields, streaming `index`, string<->block content shape,
# etc. Content stability is what the provider's prefix cache actually keys
# on. Safe by construction: we only replay prev_fwd[k] where
# current_original[k] canonicalize-equals prev_orig[k], and prev_fwd[k]
# positionally corresponds to prev_orig[k] (guaranteed by the count check
# above), so no wrong bytes are ever forwarded.
limit = min(n, len(current_original_messages), len(optimized_messages))
k = 0
while k < limit and _canonicalize_for_prefix_compare(
current_original_messages[k]
) == _canonicalize_for_prefix_compare(prev_orig[k]):
k += 1
if k == 0:
logger.debug(
"overlay: prefix diverged at message 0 — no cached-prefix replay "
"(cold prefix or client rewrote history head)"
)
return optimized_messages
if k < n:
logger.debug(
"overlay: cached-prefix replay for %d/%d leading messages "
"(diverged at %d — re-forwarding tail fresh)",
k,
n,
k,
)
# Replay the cached (compressed) prefix byte-identical up to the first
# divergence; keep this turn's freshly-produced output for the rest.
return list(prev_fwd[:k]) + list(optimized_messages[k:])
def normalize_message_cache_control(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Own message-level cache_control placement so breakpoints stay bounded.
Two forces pile up cache_control markers turn over turn: clients move the
breakpoint to the newest message each call, and ``overlay_cached_prefix``
replays the markers that rode on each turn's then-newest message. Anthropic
hard-errors at **>4 cache_control blocks total** (system + tools + messages),
so on a long conversation the accumulation eventually 400s.
Fix: strip EVERY message-level cache_control and re-place a **single**
ephemeral breakpoint on the last block of the last block-style message. One
breakpoint caches the whole message prefix up to it, and — because the
provider's cache key is message CONTENT, not marker presence (moving the
breakpoint forward is the documented client pattern and it hits) — stripping
and re-placing markers never busts. system/tools breakpoints live outside
``messages`` and are left untouched (they still count toward the 4 limit, so
holding messages to one breakpoint leaves room for them).
Only block-style (list) content can carry cache_control; string content is
left as-is. Returns the input unchanged when there is nothing to normalize.
"""
changed = False
out: list[dict[str, Any]] = []
last_block_idx = -1
for i, msg in enumerate(messages):
content = msg.get("content") if isinstance(msg, dict) else None
if isinstance(content, list):
had = any(isinstance(b, dict) and "cache_control" in b for b in content)
stripped = [
{k: v for k, v in b.items() if k != "cache_control"} if isinstance(b, dict) else b
for b in content
]
out.append({**msg, "content": stripped} if had else msg)
changed = changed or had
if stripped and isinstance(stripped[-1], dict):
last_block_idx = i
else:
out.append(msg)
# Re-place exactly one breakpoint on the last block-style message.
if last_block_idx >= 0:
msg = out[last_block_idx]
content = list(msg["content"])
content[-1] = {**content[-1], "cache_control": {"type": "ephemeral"}}
out[last_block_idx] = {**msg, "content": content}
changed = True
return out if changed else messages
class PrefixCacheTracker:
"""Tracks provider prefix cache state across turns in a session.
Usage:
tracker = PrefixCacheTracker("anthropic")
# Before compression (turn 2+):
frozen = tracker.get_frozen_message_count()
result = pipeline.apply(messages, model, frozen_message_count=frozen)
# After API response:
tracker.update_from_response(
cache_read_tokens=usage["cache_read_input_tokens"],
cache_write_tokens=usage["cache_creation_input_tokens"],
messages=optimized_messages,
tokenizer=tokenizer,
)
"""
def __init__(self, provider: str, config: PrefixFreezeConfig | None = None):
self.provider = provider
self.config = config or PrefixFreezeConfig()
self._cached_token_count: int = 0
self._cached_message_count: int = 0
self._turn_number: int = 0
self._last_activity: float = time.time()
self._last_original_messages: list[dict[str, Any]] = []
self._last_forwarded_messages: list[dict[str, Any]] = []
# Idle gap (seconds) since the PREVIOUS turn's response, captured by
# SessionTrackerStore.get_or_create at fetch time — BEFORE it refreshes
# _last_activity. Without this snapshot, seconds_since_activity() reads
# ~0 on every request (the fetch itself bumps the clock), so the
# net-cost/TTL P_alive gate could never see idle time. The handler reads
# this and forwards it to the pipeline as `idle_seconds`.
self._idle_seconds_at_fetch: float = 0.0
# Session-scoped ReadMaturationManager (Mechanism B), created
# lazily by the handler when read maturation is enabled. Rides
# here so it shares the session's affinity and TTL cleanup.
self.read_maturation_manager: Any = None
# Stats
self._busts_avoided: int = 0
self._tokens_preserved: int = 0
self._compression_foregone_tokens: int = 0
def get_frozen_message_count(self) -> int:
"""How many leading messages to skip compression on the next turn.
Returns 0 on turn 0 (cold start) or if caching is disabled/below threshold.
"""
if not self.config.enabled:
return 0
if self._turn_number == 0:
return 0
if self._cached_token_count < self.config.min_cached_tokens:
return 0
return self._cached_message_count
def update_from_response(
self,
cache_read_tokens: int,
cache_write_tokens: int,
messages: list[dict[str, Any]],
message_token_counts: list[int] | None = None,
original_messages: list[dict[str, Any]] | None = None,
) -> None:
"""Update tracker with cache metrics from the API response.
Called after every API call. Computes how many messages to freeze
on the next turn based on the cache_read_tokens reported.
Args:
cache_read_tokens: Tokens read from cache (cache hit portion).
cache_write_tokens: Tokens written to cache (new cache entries).
messages: The messages that were sent to the API.
message_token_counts: Pre-computed token counts per message.
If None, estimates from content length.
"""
self._last_activity = time.time()
self._turn_number += 1
self._last_original_messages = copy.deepcopy(original_messages or messages)
self._last_forwarded_messages = copy.deepcopy(messages)
# Compute total cached tokens (read + write = what's in cache now)
total_cached = cache_read_tokens + cache_write_tokens
if total_cached == 0:
self._cached_token_count = 0
self._cached_message_count = 0
return
# Estimate per-message token counts if not provided
if message_token_counts is None:
message_token_counts = self._estimate_message_tokens(messages)
# Walk messages from the start, accumulating tokens until we exceed
# the cached amount. All messages within the cached prefix are frozen.
accumulated = 0
frozen_count = 0
for i, tok_count in enumerate(message_token_counts):
accumulated += tok_count
if accumulated <= total_cached:
frozen_count = i + 1
else:
break
self._cached_token_count = total_cached
self._cached_message_count = frozen_count
logger.debug(
"PrefixCacheTracker[%s]: turn=%d, cached=%d tokens, "
"frozen=%d/%d messages (read=%d, write=%d)",
self.provider,
self._turn_number,
total_cached,
frozen_count,
len(messages),
cache_read_tokens,
cache_write_tokens,
)
def get_last_original_messages(self) -> list[dict[str, Any]]:
return copy.deepcopy(self._last_original_messages)
def get_last_forwarded_messages(self) -> list[dict[str, Any]]:
return copy.deepcopy(self._last_forwarded_messages)
def resolved_cache_ttl_seconds(self) -> int:
"""Effective prompt-cache lifetime for this session's provider."""
if self.config.cache_ttl_seconds is not None:
return self.config.cache_ttl_seconds
return _PROVIDER_CACHE_TTL_SECONDS.get(self.provider, 300)
def classify_cache_miss(
self,
cache_read_tokens: int,
current_forwarded_messages: list[dict[str, Any]],
idle_seconds: float | None = None,
) -> CacheMissAttribution:
"""Attribute *this turn's* cache outcome: hit, TTL lapse, or prefix change.
Call this BEFORE :meth:`update_from_response` — it reads the state
captured from the *previous* turn (``_cached_token_count``,
``_last_forwarded_messages``, ``_last_activity``), all of which
``update_from_response`` overwrites.
Attribution only fires when the previous turn left a cacheable prefix
(``_cached_token_count > 0``); the very first warm turn has nothing to
miss against, so it is reported as ``cold_start`` with ``is_miss=False``.
When a hit was expected but ``cache_read_tokens == 0``:
* If the idle gap since the last turn exceeded the provider cache TTL,
the cache entry had already lapsed — ``ttl_expiry``. **TTL wins ties:**
once the entry expired, a coincident prefix change is moot (the issue
asks "should I move 5m → 1h?", which only the TTL signal answers).
* Otherwise, if the forwarded prefix changed versus last turn, the new
bytes couldn't match the cached prefix — ``prefix_change``.
* If neither signal fires (stable prefix, within TTL) we can't explain
it from local state — ``unknown`` (e.g. provider-side eviction).
A partial read (``0 < cache_read_tokens``) counts as a hit here; the
existing model-aware bust detection in PrometheusMetrics already covers
partial-invalidation accounting, and double-counting it as a "miss"
would muddy the 5m-vs-1h signal this method exists to provide.
Returns a :class:`CacheMissAttribution`; ``is_miss`` is False for hits
and cold starts.
"""
if idle_seconds is None:
idle_seconds = self.seconds_since_activity()
ttl = self.resolved_cache_ttl_seconds()
expected = self._cached_token_count
# Nothing was cached last turn → cold start, not a miss.
if expected <= 0:
return CacheMissAttribution(
is_miss=False,
reason=MISS_COLD_START,
idle_seconds=idle_seconds,
cache_ttl_seconds=ttl,
expected_cached_tokens=expected,
cache_read_tokens=cache_read_tokens,
)
# We expected a hit. A non-zero read means the prefix cache worked.
if cache_read_tokens > 0:
return CacheMissAttribution(
is_miss=False,
reason="hit",
idle_seconds=idle_seconds,
cache_ttl_seconds=ttl,
expected_cached_tokens=expected,
cache_read_tokens=cache_read_tokens,
)
# Full miss on a prefix we expected cached. Attribute it.
ttl_exceeded = idle_seconds > ttl
prefix_changed = not self._forwarded_prefix_stable(current_forwarded_messages)
if ttl_exceeded:
reason = MISS_TTL_EXPIRY # TTL wins ties (see docstring)
elif prefix_changed:
reason = MISS_PREFIX_CHANGE
else:
reason = MISS_UNKNOWN
return CacheMissAttribution(
is_miss=True,
reason=reason,
idle_seconds=idle_seconds,
cache_ttl_seconds=ttl,
expected_cached_tokens=expected,
cache_read_tokens=cache_read_tokens,
prefix_changed=prefix_changed,
ttl_exceeded=ttl_exceeded,
)
def _forwarded_prefix_stable(self, current_forwarded_messages: list[dict[str, Any]]) -> bool:
"""True if last turn's forwarded prefix is still an exact prefix of this turn's.
The cached prefix is whatever we forwarded last turn. If those exact
messages still lead the current forwarded list, the bytes the provider
hashed for its cache key are unchanged, so a miss can't be blamed on
content. Anything else (a frozen message rewritten, the prefix
reordered, the list now shorter) counts as a prefix change.
"""
prev = self._last_forwarded_messages
if not prev:
# No recorded prefix to compare — can't claim it changed.
return True
if len(current_forwarded_messages) < len(prev):
return False
return current_forwarded_messages[: len(prev)] == prev
def record_bust_avoided(self, tokens_preserved: int, compression_foregone: int) -> None:
"""Record when we chose to preserve cache over compressing."""
self._busts_avoided += 1
self._tokens_preserved += tokens_preserved
self._compression_foregone_tokens += compression_foregone
def should_force_compress(
self,
message_index: int,
message_tokens: int,
estimated_compressed_tokens: int,
) -> bool:
"""Check if compression savings outweigh cache preservation.
Returns True if we should bust the cache and compress anyway.
This happens when compression would save a large fraction of tokens
AND the savings exceed the cache read discount.
"""
if message_index >= self._cached_message_count:
return True # Not in frozen prefix, always compress
if message_tokens == 0:
return False
savings_fraction = (message_tokens - estimated_compressed_tokens) / message_tokens
# Would compression savings exceed the cache read discount?
read_discount = _PROVIDER_READ_DISCOUNT.get(self.provider, 0.5)
return savings_fraction > read_discount
@property
def is_expired(self) -> bool:
"""Check if this tracker has been idle beyond TTL."""
return (time.time() - self._last_activity) > self.config.session_ttl_seconds
def seconds_since_activity(self) -> float:
"""Wall-clock seconds since this tracker last saw activity.
#856 P3b feeds this to the net-cost gate as an idle signal: as it
approaches the provider's prompt-cache TTL (~300s for Anthropic),
P_alive decays toward 0 and deep edits near cache lapse become free.
Distinct from :attr:`is_expired`, which uses the much longer
session-tracker *cleanup* TTL (``session_ttl_seconds``), not the cache
TTL.
Wiring caveat: ``SessionTrackerStore.get_or_create`` refreshes
``_last_activity`` on access, so a caller that wants the idle gap
since the *previous turn's response* must read this before fetching
the tracker for the current request (or the store must capture it at
fetch time). ``update_from_response`` is the per-turn activity stamp.
"""
return max(0.0, time.time() - self._last_activity)
@property
def stats(self) -> FreezeStats:
"""Return stats for dashboard/metrics."""
return FreezeStats(
busts_avoided=self._busts_avoided,
tokens_preserved=self._tokens_preserved,
compression_foregone_tokens=self._compression_foregone_tokens,
net_benefit_tokens=self._tokens_preserved - self._compression_foregone_tokens,
frozen_message_count=self._cached_message_count,
turn_number=self._turn_number,
)
@staticmethod
def _estimate_message_tokens(messages: list[dict[str, Any]]) -> list[int]:
"""Rough token count per message (chars / 3.5).
Counts text, tool_result content, and tool_use input fields
for accurate Anthropic-format estimation.
"""
counts = []
for msg in messages:
content = msg.get("content", "")
if isinstance(content, str):
chars = len(content)
elif isinstance(content, list):
chars = 0
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type", "")
if block_type == "text":
chars += len(block.get("text", ""))
elif block_type == "tool_result":
inner = block.get("content", "")
if isinstance(inner, str):
chars += len(inner)
elif isinstance(inner, list):
chars += sum(
len(b.get("text", "")) for b in inner if isinstance(b, dict)
)
elif block_type == "tool_use":
inp = block.get("input")
if isinstance(inp, str):
chars += len(inp)
elif isinstance(inp, dict):
chars += len(json.dumps(inp, separators=(",", ":")))
else:
text = block.get("text", "")
if text:
chars += len(text)
else:
chars = 0
# OpenAI function-calling: the assistant's command lives in the
# top-level `tool_calls` (or legacy `function_call`) field, NOT in
# `content` (which is empty/None on a tool-call turn). Anthropic puts
# the equivalent in a `tool_use` content BLOCK (counted above), but
# the OpenAI shape was never counted here. That under-counted every
# tool-based assistant turn to ~0, so the frozen-prefix estimate
# overshot the real cache boundary and froze the NEWEST delta — which
# is why OpenAI/Kimi (fireworks) tool harnesses got ~zero compression
# while text/back-tick harnesses (command in `content`) compressed.
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
fn = tc.get("function") or {}
chars += len(str(fn.get("name", ""))) + len(str(fn.get("arguments", "")))
fc = msg.get("function_call")
if isinstance(fc, dict):
chars += len(str(fc.get("name", ""))) + len(str(fc.get("arguments", "")))
# Add overhead for role, block structure, etc.
chars += 20
counts.append(max(1, int(chars / 3.5)))
return counts
class SessionTrackerStore:
"""Manages PrefixCacheTracker instances across sessions.
Keyed by session ID (from x-headroom-session-id header or computed hash).
Automatically cleans up expired sessions.
"""
def __init__(self, default_config: PrefixFreezeConfig | None = None):
self._trackers: dict[str, PrefixCacheTracker] = {}
self._default_config = default_config or PrefixFreezeConfig()
self._last_cleanup: float = time.time()
self._cleanup_interval: float = 60.0 # Cleanup every 60s
def get_or_create(self, session_id: str, provider: str) -> PrefixCacheTracker:
"""Get existing tracker or create a new one for this session."""
self._maybe_cleanup()
if session_id in self._trackers:
tracker = self._trackers[session_id]
# Snapshot idle-since-last-response BEFORE bumping the access clock,
# so the net-cost/TTL gate sees the true gap (see the attribute's
# docstring in PrefixCacheTracker.__init__).
tracker._idle_seconds_at_fetch = max(0.0, time.time() - tracker._last_activity)
tracker._last_activity = time.time()
return tracker
tracker = PrefixCacheTracker(provider, self._default_config)
tracker._idle_seconds_at_fetch = 0.0 # cold start: nothing cached to lapse
self._trackers[session_id] = tracker
return tracker
def compute_session_id(
self,
request: Any,
model: str,
messages: list[dict[str, Any]],
) -> str:
"""Compute a session ID from the request.
Priority:
1. x-headroom-session-id header (explicit)
2. Hash of (model + system prompt) — stable per conversation
The system prompt is harvested from ``role:"system"`` entries in
``messages``. Anthropic carries the system prompt as a top-level
``body["system"]`` field instead, so its handler prepends that as a
synthetic ``role:"system"`` message before calling this — otherwise every
Anthropic conversation on the same model would collapse to one session id
and their session-sticky state (CCR/memory tools, beta headers, frozen
prefix) would cross-contaminate.
"""
# Check for explicit session header
if hasattr(request, "headers"):
session_header = request.headers.get("x-headroom-session-id")
if session_header:
return str(session_header)
# Fall back to hashing model + all system-text content.
system_parts: list[str] = []
for msg in messages:
if msg.get("role") == "system":
content = msg.get("content", "")
if isinstance(content, str):
system_parts.append(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
system_parts.append(block.get("text", ""))
system_content = json.dumps(system_parts, ensure_ascii=False, separators=(",", ":"))
key = f"{model}:{system_content}"
return hashlib.md5(key.encode()).hexdigest()[:16] # nosec B324
def _maybe_cleanup(self) -> None:
"""Remove expired trackers periodically."""
now = time.time()
if now - self._last_cleanup < self._cleanup_interval:
return
expired = [sid for sid, tracker in self._trackers.items() if tracker.is_expired]
for sid in expired:
del self._trackers[sid]
if expired:
logger.debug("SessionTrackerStore: cleaned up %d expired sessions", len(expired))
self._last_cleanup = now
@property
def active_sessions(self) -> int:
"""Number of active session trackers."""
return len(self._trackers)