1use crate::types::Provider;
2
3#[derive(Debug, Clone, PartialEq)]
5pub struct CacheBoundary {
6 pub offset: usize,
9 pub provider: Provider,
11 pub discount: f64,
13}
14
15#[derive(Debug, Clone)]
17pub struct Message {
18 pub role: String,
19 pub content: String,
20 pub cache_control: Option<String>,
23}
24
25pub struct PromptCacheDetector;
34
35impl PromptCacheDetector {
36 pub fn detect_boundary(
39 &self,
40 messages: &[Message],
41 provider: Provider,
42 ) -> Option<CacheBoundary> {
43 match provider {
44 Provider::Anthropic => self.detect_anthropic(messages),
45 Provider::OpenAI => self.detect_openai(messages),
46 Provider::Google => None,
47 }
48 }
49
50 pub fn split_at_boundary(&self, content: &str, boundary: &CacheBoundary) -> (String, String) {
57 let (before, after) =
60 crate::text_boundary::split_at_boundary_safe(content, boundary.offset);
61 (before.to_owned(), after.to_owned())
62 }
63
64 fn detect_anthropic(&self, messages: &[Message]) -> Option<CacheBoundary> {
69 let mut offset: usize = 0;
72 let mut boundary_offset: Option<usize> = None;
73
74 for msg in messages {
75 offset += msg.content.len();
76 if msg.cache_control.as_deref() == Some("ephemeral") {
77 boundary_offset = Some(offset);
78 }
79 }
80
81 boundary_offset.map(|off| CacheBoundary {
82 offset: off,
83 provider: Provider::Anthropic,
84 discount: 0.9,
85 })
86 }
87
88 fn detect_openai(&self, messages: &[Message]) -> Option<CacheBoundary> {
89 const OPENAI_CACHE_CHAR_THRESHOLD: usize = 4096;
93
94 let total_len: usize = messages.iter().map(|m| m.content.len()).sum();
95 if total_len > OPENAI_CACHE_CHAR_THRESHOLD {
96 Some(CacheBoundary {
97 offset: OPENAI_CACHE_CHAR_THRESHOLD,
98 provider: Provider::OpenAI,
99 discount: 0.5,
100 })
101 } else {
102 None
103 }
104 }
105}
106
107#[cfg(test)]
112mod tests {
113 use super::*;
114
115 fn msg(role: &str, content: &str) -> Message {
116 Message {
117 role: role.to_owned(),
118 content: content.to_owned(),
119 cache_control: None,
120 }
121 }
122
123 fn cached_msg(role: &str, content: &str) -> Message {
124 Message {
125 role: role.to_owned(),
126 content: content.to_owned(),
127 cache_control: Some("ephemeral".to_owned()),
128 }
129 }
130
131 #[test]
136 fn anthropic_no_cache_control_returns_none() {
137 let detector = PromptCacheDetector;
138 let messages = vec![msg("user", "hello"), msg("assistant", "world")];
139 assert!(detector.detect_boundary(&messages, Provider::Anthropic).is_none());
140 }
141
142 #[test]
143 fn anthropic_single_cached_message() {
144 let detector = PromptCacheDetector;
145 let messages = vec![cached_msg("user", "hello world")];
146 let boundary = detector
147 .detect_boundary(&messages, Provider::Anthropic)
148 .unwrap();
149 assert_eq!(boundary.offset, "hello world".len());
150 assert_eq!(boundary.discount, 0.9);
151 assert_eq!(boundary.provider, Provider::Anthropic);
152 }
153
154 #[test]
155 fn anthropic_boundary_after_last_cached_message() {
156 let detector = PromptCacheDetector;
157 let messages = vec![
159 cached_msg("user", "first"), msg("assistant", "middle"), cached_msg("user", "second"), msg("assistant", "tail"), ];
164 let boundary = detector
165 .detect_boundary(&messages, Provider::Anthropic)
166 .unwrap();
167 assert_eq!(boundary.offset, 17);
169 }
170
171 #[test]
172 fn anthropic_only_first_message_cached() {
173 let detector = PromptCacheDetector;
174 let messages = vec![
175 cached_msg("system", "sys"), msg("user", "query"), ];
178 let boundary = detector
179 .detect_boundary(&messages, Provider::Anthropic)
180 .unwrap();
181 assert_eq!(boundary.offset, 3);
182 }
183
184 #[test]
189 fn openai_short_content_returns_none() {
190 let detector = PromptCacheDetector;
191 let messages = vec![msg("user", "short")];
192 assert!(detector.detect_boundary(&messages, Provider::OpenAI).is_none());
193 }
194
195 #[test]
196 fn openai_long_content_returns_boundary_at_4096() {
197 let detector = PromptCacheDetector;
198 let long_content = "x".repeat(5000);
199 let messages = vec![msg("user", &long_content)];
200 let boundary = detector
201 .detect_boundary(&messages, Provider::OpenAI)
202 .unwrap();
203 assert_eq!(boundary.offset, 4096);
204 assert_eq!(boundary.discount, 0.5);
205 assert_eq!(boundary.provider, Provider::OpenAI);
206 }
207
208 #[test]
209 fn openai_exactly_at_threshold_returns_none() {
210 let detector = PromptCacheDetector;
211 let content = "x".repeat(4096);
212 let messages = vec![msg("user", &content)];
213 assert!(detector.detect_boundary(&messages, Provider::OpenAI).is_none());
215 }
216
217 #[test]
222 fn google_always_returns_none() {
223 let detector = PromptCacheDetector;
224 let messages = vec![cached_msg("user", "x".repeat(10000).as_str())];
225 assert!(detector.detect_boundary(&messages, Provider::Google).is_none());
226 }
227
228 #[test]
233 fn split_at_boundary_basic() {
234 let detector = PromptCacheDetector;
235 let content = "hello world";
236 let boundary = CacheBoundary {
237 offset: 5,
238 provider: Provider::Anthropic,
239 discount: 0.9,
240 };
241 let (before, after) = detector.split_at_boundary(content, &boundary);
242 assert_eq!(before, "hello");
243 assert_eq!(after, " world");
244 }
245
246 #[test]
247 fn split_at_boundary_offset_zero() {
248 let detector = PromptCacheDetector;
249 let content = "hello";
250 let boundary = CacheBoundary {
251 offset: 0,
252 provider: Provider::Anthropic,
253 discount: 0.9,
254 };
255 let (before, after) = detector.split_at_boundary(content, &boundary);
256 assert_eq!(before, "");
257 assert_eq!(after, "hello");
258 }
259
260 #[test]
261 fn split_at_boundary_offset_beyond_end() {
262 let detector = PromptCacheDetector;
263 let content = "hello";
264 let boundary = CacheBoundary {
265 offset: 100,
266 provider: Provider::Anthropic,
267 discount: 0.9,
268 };
269 let (before, after) = detector.split_at_boundary(content, &boundary);
270 assert_eq!(before, "hello");
271 assert_eq!(after, "");
272 }
273
274 #[test]
275 fn split_before_is_byte_identical() {
276 let detector = PromptCacheDetector;
277 let content = "abcdefghij";
278 let boundary = CacheBoundary {
279 offset: 5,
280 provider: Provider::OpenAI,
281 discount: 0.5,
282 };
283 let (before, _) = detector.split_at_boundary(content, &boundary);
284 assert_eq!(before.as_bytes(), &content.as_bytes()[..5]);
286 }
287
288 use proptest::prelude::*;
293
294 fn anthropic_messages_with_boundary() -> impl Strategy<Value = Vec<Message>> {
297 (1usize..=8usize).prop_flat_map(|n| {
299 let msg_strategy = (
301 prop_oneof![Just("user"), Just("assistant"), Just("system")],
302 "[a-z]{1,50}",
303 any::<bool>(),
304 );
305 prop::collection::vec(msg_strategy, n).prop_filter(
306 "at least one cached message",
307 |msgs| msgs.iter().any(|(_, _, cached)| *cached),
308 )
309 })
310 .prop_map(|msgs| {
311 msgs.into_iter()
312 .map(|(role, content, cached)| Message {
313 role: role.to_owned(),
314 content,
315 cache_control: if cached {
316 Some("ephemeral".to_owned())
317 } else {
318 None
319 },
320 })
321 .collect()
322 })
323 }
324
325 proptest! {
326 #[test]
335 fn prop_prompt_cache_boundary_preservation(
336 messages in anthropic_messages_with_boundary(),
337 ) {
338 let detector = PromptCacheDetector;
339
340 let boundary = detector.detect_boundary(&messages, Provider::Anthropic);
342 prop_assert!(
343 boundary.is_some(),
344 "expected boundary to be detected for messages with cache_control markers"
345 );
346 let boundary = boundary.unwrap();
347
348 prop_assert_eq!(boundary.discount, 0.9);
350
351 let full_content: String = messages.iter().map(|m| m.content.as_str()).collect();
354 let (before, _after) = detector.split_at_boundary(&full_content, &boundary);
355
356 let expected_prefix = &full_content[..boundary.offset.min(full_content.len())];
357 prop_assert_eq!(
358 before.as_bytes(),
359 expected_prefix.as_bytes(),
360 "content before boundary must be byte-identical to original prefix"
361 );
362
363 prop_assert!(
366 boundary.offset <= full_content.len(),
367 "boundary offset {} exceeds content length {}",
368 boundary.offset,
369 full_content.len()
370 );
371
372 let mut cumulative = 0usize;
374 let mut last_cached_end = 0usize;
375 for msg in &messages {
376 cumulative += msg.content.len();
377 if msg.cache_control.as_deref() == Some("ephemeral") {
378 last_cached_end = cumulative;
379 }
380 }
381 prop_assert_eq!(
382 boundary.offset,
383 last_cached_end,
384 "boundary offset should be after the last cached message"
385 );
386 }
387 }
388
389 proptest! {
390 #[test]
395 fn prop_openai_cache_boundary_preservation(
396 extra in 1usize..=1000usize,
398 content in "[a-z]{4097,5000}",
399 ) {
400 let _ = extra; let detector = PromptCacheDetector;
402 let messages = vec![Message {
403 role: "user".to_owned(),
404 content: content.clone(),
405 cache_control: None,
406 }];
407
408 let boundary = detector.detect_boundary(&messages, Provider::OpenAI);
409 prop_assert!(boundary.is_some(), "expected OpenAI boundary for long content");
410 let boundary = boundary.unwrap();
411
412 prop_assert_eq!(boundary.offset, 4096);
413 prop_assert_eq!(boundary.discount, 0.5);
414
415 let (before, _) = detector.split_at_boundary(&content, &boundary);
416 prop_assert_eq!(
417 before.as_bytes(),
418 &content.as_bytes()[..4096],
419 "content before OpenAI boundary must be byte-identical"
420 );
421 }
422 }
423}