|
| 1 | +"""Cadence-aware prompt-cache TTL policy. |
| 2 | +
|
| 3 | +Anthropic prompt caching has two write TTLs: 5 minutes (1.25x base input) |
| 4 | +and 1 hour (2.0x base input); reads are 0.1x. A cache write is therefore |
| 5 | +~12.5x the price of a read of the same tokens, so sessions whose turn |
| 6 | +cadence exceeds 5 minutes by design (persistent crons, ScheduleWakeup |
| 7 | +monitoring loops, spaced web conversations) re-buy their entire context |
| 8 | +on every turn under the default TTL. |
| 9 | +
|
| 10 | +This module decides, per SDK-client build, which TTL a session should |
| 11 | +request. The Claude Code CLI has native support via env vars: |
| 12 | +
|
| 13 | +- ``ENABLE_PROMPT_CACHING_1H=1`` — request the 1h TTL (API-key auth; |
| 14 | + the CLI adds the ``extended-cache-ttl-2025-04-11`` beta itself). |
| 15 | +- ``ENABLE_PROMPT_CACHING_1H_BEDROCK=1`` — same, for Bedrock. |
| 16 | +- ``FORCE_PROMPT_CACHING_5M=1`` — upstream kill switch (not set here). |
| 17 | +
|
| 18 | +Policy modes (``agent.cache_ttl`` in config, or a per-session override |
| 19 | +in session metadata): |
| 20 | +
|
| 21 | +- ``"5m"`` — status quo, never request the beta. |
| 22 | +- ``"1h"`` — always request it (minus excluded models). |
| 23 | +- ``"auto"`` — per session at client-build time: |
| 24 | + 1. observed cadence wins: median of the session's recent turn gaps |
| 25 | + in (5min, 1h] → 1h; any other observed cadence → 5m (gaps beyond |
| 26 | + the TTL expire either way, so the 2x write premium buys nothing); |
| 27 | + 2. no history: wakeup-driven turns and persistent-mode cron sessions |
| 28 | + → 1h (the canonical sparse-cadence cases), everything else → 5m. |
| 29 | +
|
| 30 | +The 1h TTL only pays off if the prompt bytes are identical across turns |
| 31 | +— which in Nerve holds *within* an SDK-client lifetime, and across |
| 32 | +client rebuilds only if the system prompt is byte-stable (see the |
| 33 | +``Current date`` + frozen-recall changes in prompts.py / engine.py). |
| 34 | +""" |
| 35 | + |
| 36 | +from __future__ import annotations |
| 37 | + |
| 38 | +import logging |
| 39 | +import statistics |
| 40 | +from typing import Any, Iterable |
| 41 | + |
| 42 | +from nerve.db.usage import _get_pricing |
| 43 | + |
| 44 | +logger = logging.getLogger(__name__) |
| 45 | + |
| 46 | +FIVE_MIN_S = 300.0 |
| 47 | +ONE_HOUR_S = 3600.0 |
| 48 | + |
| 49 | +# How many recent turn gaps to consider for the cadence heuristic. |
| 50 | +CADENCE_WINDOW = 12 |
| 51 | + |
| 52 | +VALID_TTL_MODES = ("5m", "1h", "auto") |
| 53 | + |
| 54 | + |
| 55 | +def cache_ttl_env(ttl: str, is_bedrock: bool = False) -> dict[str, str]: |
| 56 | + """Env vars for the CLI subprocess implementing the resolved TTL. |
| 57 | +
|
| 58 | + ``"5m"`` returns an empty dict — the CLI default is already 5m and we |
| 59 | + deliberately do NOT set ``FORCE_PROMPT_CACHING_5M`` (that would also |
| 60 | + override a claude.ai-subscriber allowlist upstream). |
| 61 | + """ |
| 62 | + if ttl != "1h": |
| 63 | + return {} |
| 64 | + env = {"ENABLE_PROMPT_CACHING_1H": "1"} |
| 65 | + if is_bedrock: |
| 66 | + env["ENABLE_PROMPT_CACHING_1H_BEDROCK"] = "1" |
| 67 | + return env |
| 68 | + |
| 69 | + |
| 70 | +async def resolve_cache_ttl( |
| 71 | + agent_config: Any, |
| 72 | + db: Any, |
| 73 | + session_id: str, |
| 74 | + source: str, |
| 75 | + model: str | None, |
| 76 | + session_meta: dict | None = None, |
| 77 | + is_claude_model: bool = True, |
| 78 | +) -> str: |
| 79 | + """Resolve the cache TTL ("5m" | "1h") for a session's next client. |
| 80 | +
|
| 81 | + ``session_meta`` is the parsed session metadata dict; recognised keys: |
| 82 | +
|
| 83 | + - ``cache_ttl_override`` — per-session mode override (e.g. from a |
| 84 | + cron job's ``cache_ttl`` in jobs.yaml). Same values as the config. |
| 85 | + - ``cron_session_mode`` — "persistent" | "isolated", written by the |
| 86 | + cron runners; used as the no-history prior for cron sessions. |
| 87 | + """ |
| 88 | + if not is_claude_model: |
| 89 | + return "5m" # Ollama/OpenAI-translated models have no Anthropic cache |
| 90 | + |
| 91 | + meta = session_meta or {} |
| 92 | + mode = meta.get("cache_ttl_override") or getattr( |
| 93 | + agent_config, "cache_ttl", "5m", |
| 94 | + ) |
| 95 | + if mode not in VALID_TTL_MODES: |
| 96 | + logger.warning( |
| 97 | + "Unknown cache_ttl mode %r for session %s — falling back to 5m", |
| 98 | + mode, session_id, |
| 99 | + ) |
| 100 | + return "5m" |
| 101 | + if mode == "5m": |
| 102 | + return "5m" |
| 103 | + |
| 104 | + # Model exclusion applies to both "1h" and "auto". |
| 105 | + resolved = (model or getattr(agent_config, "model", "") or "").lower() |
| 106 | + excluded = getattr(agent_config, "cache_ttl_excluded_models", []) or [] |
| 107 | + if any(tok and tok.lower() in resolved for tok in excluded): |
| 108 | + return "5m" |
| 109 | + |
| 110 | + if mode == "1h": |
| 111 | + return "1h" |
| 112 | + |
| 113 | + # --- auto: observed cadence first, source priors on no data |
| 114 | + gaps: list[float] = [] |
| 115 | + try: |
| 116 | + gaps = await get_recent_turn_gaps(db, session_id, CADENCE_WINDOW) |
| 117 | + except Exception as e: # never fail a client build over the heuristic |
| 118 | + logger.warning( |
| 119 | + "cache_ttl cadence query failed for %s: %s", session_id, e, |
| 120 | + ) |
| 121 | + |
| 122 | + if gaps: |
| 123 | + med = statistics.median(gaps) |
| 124 | + return "1h" if FIVE_MIN_S < med <= ONE_HOUR_S else "5m" |
| 125 | + |
| 126 | + if source == "wakeup": |
| 127 | + return "1h" |
| 128 | + if source == "cron" and meta.get("cron_session_mode") == "persistent": |
| 129 | + return "1h" |
| 130 | + return "5m" |
| 131 | + |
| 132 | + |
| 133 | +async def get_recent_turn_gaps( |
| 134 | + db: Any, session_id: str, window: int = CADENCE_WINDOW, |
| 135 | +) -> list[float]: |
| 136 | + """Seconds between the session's most recent turns (indexed query).""" |
| 137 | + async with db.db.execute( |
| 138 | + """ |
| 139 | + SELECT CAST(strftime('%s', created_at) AS REAL) |
| 140 | + FROM session_usage WHERE session_id = ? |
| 141 | + ORDER BY id DESC LIMIT ? |
| 142 | + """, |
| 143 | + (session_id, window + 1), |
| 144 | + ) as cursor: |
| 145 | + ts = [row[0] async for row in cursor if row[0] is not None] |
| 146 | + ts.reverse() # chronological |
| 147 | + return [b - a for a, b in zip(ts, ts[1:])] |
| 148 | + |
| 149 | + |
| 150 | +# --------------------------------------------------------------------------- |
| 151 | +# Live counterfactual: what would the observed traffic have cost on 5m? |
| 152 | +# --------------------------------------------------------------------------- |
| 153 | + |
| 154 | +def estimate_live_ttl_delta( |
| 155 | + turns: Iterable[tuple], |
| 156 | + ttl_threshold: float = ONE_HOUR_S, |
| 157 | +) -> dict: |
| 158 | + """Estimate savings of observed (possibly 1h-cached) traffic vs a |
| 159 | + pure-5m baseline. |
| 160 | +
|
| 161 | + ``turns`` are chronological rows for ONE session: |
| 162 | + ``(ts_epoch, model, input_tokens, cache_read, write_5m, write_1h)``. |
| 163 | +
|
| 164 | + Model: a turn whose gap from the previous turn is in (5min, 1h] and |
| 165 | + whose predecessor wrote 1h-TTL cache benefited from the extended TTL |
| 166 | + — under 5m its first-iteration prefix read would have been a re-write |
| 167 | + at 1.25x. The warm-prefix size is tracked from creation tokens |
| 168 | + (reads multi-count across agentic-loop iterations, creations don't). |
| 169 | + Turns are also charged the 1h-vs-5m write premium they actually paid. |
| 170 | +
|
| 171 | + Returns ``{"actual": $, "baseline_5m": $, "savings": $}`` where |
| 172 | + positive savings mean the 1h TTL is paying off. |
| 173 | + """ |
| 174 | + actual = 0.0 |
| 175 | + baseline = 0.0 |
| 176 | + warm_prefix = 0 |
| 177 | + prev_ts: float | None = None |
| 178 | + prev_model: str | None = None |
| 179 | + prev_wrote_1h = False |
| 180 | + |
| 181 | + for ts, model, inp, reads, w5m, w1h in turns: |
| 182 | + p_in, _o, p_read, p_c5m, p_c1h, _w = _get_pricing(model) |
| 183 | + creation = (w5m or 0) + (w1h or 0) |
| 184 | + gap = None if prev_ts is None else ts - prev_ts |
| 185 | + cold_boundary = ( |
| 186 | + gap is None or model != prev_model or gap > ttl_threshold |
| 187 | + ) |
| 188 | + |
| 189 | + actual += ( |
| 190 | + inp * p_in + reads * p_read + w5m * p_c5m + w1h * p_c1h |
| 191 | + ) / 1_000_000 |
| 192 | + |
| 193 | + # Baseline: same turn under a pure-5m policy. |
| 194 | + converted = 0 |
| 195 | + if ( |
| 196 | + not cold_boundary |
| 197 | + and gap is not None |
| 198 | + and gap > FIVE_MIN_S |
| 199 | + and prev_wrote_1h |
| 200 | + ): |
| 201 | + # This read survived only thanks to the 1h TTL; under 5m the |
| 202 | + # prefix would have been re-written once at the 5m rate. |
| 203 | + converted = min(reads, warm_prefix) |
| 204 | + baseline += ( |
| 205 | + inp * p_in |
| 206 | + + (reads - converted) * p_read |
| 207 | + + converted * p_c5m |
| 208 | + + (w5m + w1h) * p_c5m |
| 209 | + ) / 1_000_000 |
| 210 | + |
| 211 | + # Warm-prefix bookkeeping (observed world). |
| 212 | + if cold_boundary or (gap is not None and gap > FIVE_MIN_S and not prev_wrote_1h): |
| 213 | + warm_prefix = creation |
| 214 | + else: |
| 215 | + warm_prefix += creation |
| 216 | + prev_ts = ts |
| 217 | + prev_model = model |
| 218 | + prev_wrote_1h = w1h > 0 |
| 219 | + |
| 220 | + return { |
| 221 | + "actual": round(actual, 4), |
| 222 | + "baseline_5m": round(baseline, 4), |
| 223 | + "savings": round(baseline - actual, 4), |
| 224 | + } |
| 225 | + |
| 226 | + |
| 227 | +def build_ttl_report(rows: list[tuple]) -> dict: |
| 228 | + """Aggregate the live 1h-vs-5m estimate per source for diagnostics. |
| 229 | +
|
| 230 | + ``rows`` come from ``UsageStore.get_cache_ttl_turn_rows``: |
| 231 | + ``(session_id, source, ts, model, input_tokens, reads, w5m, w1h)``, |
| 232 | + ordered by session then time. |
| 233 | +
|
| 234 | + Guardrail: a source whose 1h traffic is estimated to cost *more* than |
| 235 | + the 5m baseline (the auto policy misclassified its cadence) lands in |
| 236 | + ``regressions`` and is logged at WARNING — manual revert is one |
| 237 | + config line (``agent.cache_ttl``) or a per-job ``cache_ttl: "5m"``. |
| 238 | + """ |
| 239 | + by_session: dict[str, tuple[str, list[tuple]]] = {} |
| 240 | + for sid, source, ts, model, inp, reads, w5m, w1h in rows: |
| 241 | + if ts is None: |
| 242 | + continue |
| 243 | + by_session.setdefault(sid, (source, []))[1].append( |
| 244 | + (ts, model, inp or 0, reads or 0, w5m or 0, w1h or 0), |
| 245 | + ) |
| 246 | + |
| 247 | + per_source: dict[str, dict] = {} |
| 248 | + total = {"actual": 0.0, "baseline_5m": 0.0, "savings": 0.0} |
| 249 | + for _sid, (source, turns) in by_session.items(): |
| 250 | + est = estimate_live_ttl_delta(turns) |
| 251 | + agg = per_source.setdefault( |
| 252 | + source, {"actual": 0.0, "baseline_5m": 0.0, "savings": 0.0}, |
| 253 | + ) |
| 254 | + for k in agg: |
| 255 | + agg[k] = round(agg[k] + est[k], 4) |
| 256 | + total[k] = round(total[k] + est[k], 4) |
| 257 | + |
| 258 | + regressions = [ |
| 259 | + src for src, agg in per_source.items() if agg["savings"] < -0.5 |
| 260 | + ] |
| 261 | + for src in regressions: |
| 262 | + logger.warning( |
| 263 | + "cache_ttl guardrail: 1h caching for source %r cost " |
| 264 | + "$%.2f MORE than the 5m baseline over the window — the auto " |
| 265 | + "policy may be misclassifying its cadence (revert via " |
| 266 | + "agent.cache_ttl or a per-job cache_ttl override)", |
| 267 | + src, -per_source[src]["savings"], |
| 268 | + ) |
| 269 | + |
| 270 | + return { |
| 271 | + "by_source": per_source, |
| 272 | + "total": total, |
| 273 | + "regressions": regressions, |
| 274 | + } |
0 commit comments