Skip to main content

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}