lean_ctx/proxy/cache_breakpoint.rs
1//! Active prompt-cache breakpoint injection (#939, Headroom "cache aligner"
2//! adjacent).
3//!
4//! Anthropic's prompt cache is opt-in per request: the client marks a
5//! `cache_control: {type:"ephemeral"}` breakpoint and Anthropic caches the
6//! prefix up to it (billing later turns at the cached rate). A raw API client
7//! that does *not* set one pays full price for its (large, stable) system prompt
8//! on every single turn. This module injects exactly one breakpoint on the
9//! `system` field for those clients, so the proxy delivers the cache win the
10//! client left on the table.
11//!
12//! ## Anthropic-only by construction
13//! `cache_control` is an Anthropic concept. OpenAI Chat Completions and the
14//! Responses API cache prefixes **automatically** (no per-request markers; an
15//! injected `cache_control` would be ignored at best), so there is nothing to
16//! inject there — those paths rely on OpenAI's implicit caching and are left
17//! byte-unchanged. This module therefore wires into the Anthropic path only.
18//!
19//! ## Safety
20//! - **Only when the client set none.** The caller gates on
21//! `cached_prefix_len(messages) == 0` and `!prose::value_has_cache_control(system)`,
22//! so we never add a *second* breakpoint (Anthropic caps them at 4) or move a
23//! client anchor.
24//! - **Exactly one**, on `system` — the largest, most stable prefix, fixed at
25//! the very start, so it never churns with the prune boundary.
26//! - **Deterministic** (#498): a pure function of the body, so the rewritten
27//! request is byte-identical across turns and the cache prefix it creates is
28//! itself stable.
29//! - **Min size.** Below Anthropic's minimum cacheable prefix the marker is
30//! ignored, so we skip tiny system prompts to avoid pointless churn.
31
32use serde_json::{Map, Value};
33
34use crate::core::tokens::count_tokens;
35
36/// Anthropic ignores a cache breakpoint whose prefix is under its minimum
37/// cacheable size (1024 tokens for Sonnet/Opus; Haiku is higher). Injecting
38/// below this just churns bytes for no cache, so gate on it.
39const MIN_CACHEABLE_TOKENS: usize = 1024;
40
41/// The ephemeral cache-control marker Anthropic honours.
42fn ephemeral() -> Value {
43 serde_json::json!({ "type": "ephemeral" })
44}
45
46/// Inject one `cache_control: {type:"ephemeral"}` breakpoint on the Anthropic
47/// `system` field, returning `true` iff one was added.
48///
49/// `system` may be a plain string or an array of text blocks; both are valid
50/// Anthropic shapes. A string is wrapped into a single cache-marked text block
51/// (the documented way to make a string system prompt cacheable); an array gets
52/// the marker on its last block. Returns `false` when there is no `system`, it
53/// is too small to be cached, or it already carries a breakpoint (defensive —
54/// the caller already guards this).
55pub(crate) fn inject_anthropic_system(doc: &mut Value) -> bool {
56 let Some(system) = doc.get_mut("system") else {
57 return false;
58 };
59 match system {
60 Value::String(s) => {
61 if count_tokens(s) < MIN_CACHEABLE_TOKENS {
62 return false;
63 }
64 let text = std::mem::take(s);
65 let mut block = Map::new();
66 block.insert("type".into(), Value::String("text".into()));
67 block.insert("text".into(), Value::String(text));
68 block.insert("cache_control".into(), ephemeral());
69 *system = Value::Array(vec![Value::Object(block)]);
70 true
71 }
72 Value::Array(blocks) => {
73 if blocks.iter().any(|b| b.get("cache_control").is_some()) {
74 return false;
75 }
76 let total: usize = blocks
77 .iter()
78 .filter_map(|b| b.get("text").and_then(Value::as_str))
79 .map(count_tokens)
80 .sum();
81 if total < MIN_CACHEABLE_TOKENS {
82 return false;
83 }
84 let Some(last) = blocks.last_mut().and_then(Value::as_object_mut) else {
85 return false;
86 };
87 last.insert("cache_control".into(), ephemeral());
88 true
89 }
90 _ => false,
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 fn big_system() -> String {
99 // Comfortably over MIN_CACHEABLE_TOKENS so the gate fires.
100 "You are a meticulous senior engineer. ".repeat(400)
101 }
102
103 #[test]
104 fn wraps_string_system_into_cache_marked_block() {
105 let mut doc = serde_json::json!({ "system": big_system(), "messages": [] });
106 assert!(inject_anthropic_system(&mut doc));
107 let block = &doc["system"][0];
108 assert_eq!(block["type"], "text");
109 assert_eq!(block["cache_control"]["type"], "ephemeral");
110 assert!(
111 block["text"].as_str().unwrap().contains("senior engineer"),
112 "the original system text must be preserved verbatim in the block"
113 );
114 }
115
116 #[test]
117 fn marks_last_block_of_array_system() {
118 let mut doc = serde_json::json!({
119 "system": [
120 { "type": "text", "text": big_system() },
121 { "type": "text", "text": big_system() }
122 ],
123 "messages": []
124 });
125 assert!(inject_anthropic_system(&mut doc));
126 assert!(
127 doc["system"][0].get("cache_control").is_none(),
128 "only the last block is marked"
129 );
130 assert_eq!(doc["system"][1]["cache_control"]["type"], "ephemeral");
131 }
132
133 #[test]
134 fn skips_small_system_and_missing_system() {
135 let mut small = serde_json::json!({ "system": "be terse", "messages": [] });
136 assert!(
137 !inject_anthropic_system(&mut small),
138 "below the cacheable floor → no churn"
139 );
140 let mut none = serde_json::json!({ "messages": [] });
141 assert!(!inject_anthropic_system(&mut none));
142 }
143
144 #[test]
145 fn never_adds_a_second_breakpoint() {
146 let mut doc = serde_json::json!({
147 "system": [
148 { "type": "text", "text": big_system(), "cache_control": { "type": "ephemeral" } }
149 ],
150 "messages": []
151 });
152 assert!(
153 !inject_anthropic_system(&mut doc),
154 "a client breakpoint must be left as the sole anchor"
155 );
156 }
157
158 #[test]
159 fn injection_is_deterministic() {
160 let mk = || serde_json::json!({ "system": big_system(), "messages": [] });
161 let mut a = mk();
162 let mut b = mk();
163 assert!(inject_anthropic_system(&mut a));
164 assert!(inject_anthropic_system(&mut b));
165 assert_eq!(
166 a, b,
167 "identical input must yield byte-identical output (#498)"
168 );
169 }
170}