1use serde_json::{json, Value};
21
22const MAX_BREAKPOINTS: usize = 4;
23
24pub fn apply_anthropic_prompt_cache(body: &mut Value, ttl: &str) {
28 let marker = build_marker(ttl);
29
30 let mut budget = MAX_BREAKPOINTS;
31
32 if let Some(obj) = body.as_object_mut() {
33 if let Some(system) = obj.get_mut("system") {
34 if mark_system(system, &marker) {
35 budget -= 1;
36 }
37 }
38
39 if budget == 0 {
40 return;
41 }
42
43 if let Some(messages) = obj.get_mut("messages").and_then(|v| v.as_array_mut()) {
44 let mut targets: Vec<usize> = messages
48 .iter()
49 .enumerate()
50 .filter(|(_, m)| {
51 m.get("role")
52 .and_then(|r| r.as_str())
53 .map(|r| r != "system")
54 .unwrap_or(true)
55 })
56 .map(|(i, _)| i)
57 .collect();
58 let take = budget.min(targets.len());
59 let start = targets.len().saturating_sub(take);
60 targets.drain(..start);
61 for idx in targets {
62 mark_message(&mut messages[idx], &marker);
63 }
64 }
65 }
66}
67
68fn build_marker(ttl: &str) -> Value {
69 if ttl == "1h" {
70 json!({"type": "ephemeral", "ttl": "1h"})
71 } else {
72 json!({"type": "ephemeral"})
73 }
74}
75
76fn mark_system(system: &mut Value, marker: &Value) -> bool {
79 match system {
80 Value::String(s) => {
81 if s.is_empty() {
82 return false;
83 }
84 *system = json!([{
85 "type": "text",
86 "text": s.clone(),
87 "cache_control": marker,
88 }]);
89 true
90 }
91 Value::Array(blocks) => {
92 if let Some(last) = blocks.last_mut() {
93 attach_marker_to_block(last, marker);
94 true
95 } else {
96 false
97 }
98 }
99 _ => false,
100 }
101}
102
103fn mark_message(msg: &mut Value, marker: &Value) {
107 let Some(obj) = msg.as_object_mut() else {
108 return;
109 };
110 let content = obj.entry("content").or_insert(Value::Null);
111 match content {
112 Value::String(s) => {
113 *content = json!([{
114 "type": "text",
115 "text": s.clone(),
116 "cache_control": marker,
117 }]);
118 }
119 Value::Array(blocks) => {
120 if let Some(last) = blocks.last_mut() {
121 attach_marker_to_block(last, marker);
122 }
123 }
124 _ => {
125 }
127 }
128}
129
130fn attach_marker_to_block(block: &mut Value, marker: &Value) {
131 if let Some(obj) = block.as_object_mut() {
132 obj.insert("cache_control".to_string(), marker.clone());
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use serde_json::json;
140
141 fn ephemeral() -> Value {
142 json!({"type": "ephemeral"})
143 }
144
145 #[test]
146 fn marks_system_string_as_block_array() {
147 let mut body = json!({
148 "model": "claude-sonnet-4-5",
149 "system": "You are helpful.",
150 "messages": [],
151 });
152 apply_anthropic_prompt_cache(&mut body, "5m");
153 let sys = &body["system"];
154 assert!(sys.is_array());
155 assert_eq!(sys[0]["text"], "You are helpful.");
156 assert_eq!(sys[0]["cache_control"], ephemeral());
157 }
158
159 #[test]
160 fn empty_system_string_no_marker() {
161 let mut body = json!({
162 "model": "claude-sonnet-4-5",
163 "system": "",
164 "messages": [
165 {"role": "user", "content": "hi"},
166 ],
167 });
168 apply_anthropic_prompt_cache(&mut body, "5m");
169 assert!(body["system"].is_string());
172 assert_eq!(
173 body["messages"][0]["content"][0]["cache_control"],
174 ephemeral()
175 );
176 }
177
178 #[test]
179 fn marks_last_three_non_system_messages() {
180 let mut body = json!({
181 "system": "S",
182 "messages": [
183 {"role": "user", "content": "m1"},
184 {"role": "assistant", "content": "m2"},
185 {"role": "user", "content": "m3"},
186 {"role": "assistant", "content": "m4"},
187 {"role": "user", "content": "m5"},
188 ],
189 });
190 apply_anthropic_prompt_cache(&mut body, "5m");
191 assert!(body["messages"][0]["content"].is_string(), "m1 untouched");
193 assert!(body["messages"][1]["content"].is_string(), "m2 untouched");
194 for i in [2, 3, 4] {
195 assert_eq!(
196 body["messages"][i]["content"][0]["cache_control"],
197 ephemeral(),
198 "m{} should be marked",
199 i + 1
200 );
201 }
202 }
203
204 #[test]
205 fn marks_last_block_when_content_is_array() {
206 let mut body = json!({
207 "system": "S",
208 "messages": [
209 {
210 "role": "user",
211 "content": [
212 {"type": "text", "text": "first"},
213 {"type": "text", "text": "second"},
214 ],
215 },
216 ],
217 });
218 apply_anthropic_prompt_cache(&mut body, "5m");
219 let blocks = &body["messages"][0]["content"];
220 assert!(blocks[0].get("cache_control").is_none());
221 assert_eq!(blocks[1]["cache_control"], ephemeral());
222 }
223
224 #[test]
225 fn ttl_1h_marker() {
226 let mut body = json!({
227 "system": "S",
228 "messages": [],
229 });
230 apply_anthropic_prompt_cache(&mut body, "1h");
231 assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h");
232 }
233
234 #[test]
235 fn budget_capped_at_four_total() {
236 let mut body = json!({
237 "system": "S",
238 "messages": [
239 {"role": "user", "content": "m1"},
240 {"role": "assistant", "content": "m2"},
241 {"role": "user", "content": "m3"},
242 {"role": "assistant", "content": "m4"},
243 {"role": "user", "content": "m5"},
244 ],
245 });
246 apply_anthropic_prompt_cache(&mut body, "5m");
247 assert!(body["messages"][1]["content"].is_string());
249 }
250
251 #[test]
252 fn no_messages_just_system() {
253 let mut body = json!({
254 "system": "S",
255 "messages": [],
256 });
257 apply_anthropic_prompt_cache(&mut body, "5m");
258 assert_eq!(body["system"][0]["cache_control"], ephemeral());
259 }
260
261 #[test]
262 fn missing_system_field_safe() {
263 let mut body = json!({
264 "messages": [{"role": "user", "content": "hi"}],
265 });
266 apply_anthropic_prompt_cache(&mut body, "5m");
267 assert_eq!(
268 body["messages"][0]["content"][0]["cache_control"],
269 ephemeral()
270 );
271 }
272}