Skip to main content

sqz_engine/
prompt_cache.rs

1use crate::types::Provider;
2
3/// A detected prompt cache boundary in a message sequence.
4#[derive(Debug, Clone, PartialEq)]
5pub struct CacheBoundary {
6    /// Byte offset in the concatenated content string where the boundary falls.
7    /// Content before this offset is cached; content at/after is not.
8    pub offset: usize,
9    /// The provider that owns this cache boundary.
10    pub provider: Provider,
11    /// The cache discount fraction (0.9 for Anthropic, 0.5 for OpenAI).
12    pub discount: f64,
13}
14
15/// A single message in a conversation, used for cache boundary detection.
16#[derive(Debug, Clone)]
17pub struct Message {
18    pub role: String,
19    pub content: String,
20    /// Anthropic-style cache control marker.
21    /// When `Some("ephemeral")`, this message has a cache_control marker.
22    pub cache_control: Option<String>,
23}
24
25/// Detects prompt cache boundaries for Anthropic and OpenAI API formats.
26///
27/// - **Anthropic**: uses `cache_control: {"type": "ephemeral"}` markers on
28///   individual messages. The boundary is placed after the last marked message.
29/// - **OpenAI**: automatically caches the first 1024 tokens of a prompt.
30///   Detected heuristically: if total content length > 4096 chars (≈1024 tokens),
31///   the boundary is at char position 4096.
32/// - **Google**: no cache boundary concept; always returns `None`.
33pub struct PromptCacheDetector;
34
35impl PromptCacheDetector {
36    /// Detect a cache boundary in the given message sequence for the specified
37    /// provider. Returns `None` if no boundary is detected.
38    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    /// Split `content` at the given boundary offset.
51    ///
52    /// Returns `(before, after)` where `before` is byte-identical to the
53    /// original content up to `boundary.offset`, and `after` is the remainder.
54    ///
55    /// If `boundary.offset >= content.len()`, `after` will be empty.
56    pub fn split_at_boundary(&self, content: &str, boundary: &CacheBoundary) -> (String, String) {
57        // Boundary offsets are byte estimates; round down to a char
58        // boundary so multi-byte content can't panic (issue #34 class).
59        let (before, after) =
60            crate::text_boundary::split_at_boundary_safe(content, boundary.offset);
61        (before.to_owned(), after.to_owned())
62    }
63
64    // -----------------------------------------------------------------------
65    // Private helpers
66    // -----------------------------------------------------------------------
67
68    fn detect_anthropic(&self, messages: &[Message]) -> Option<CacheBoundary> {
69        // Find the last message that carries a cache_control marker.
70        // The boundary offset is the byte position after that message's content.
71        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        // OpenAI automatically caches the first 1024 tokens.
90        // Heuristic: if total content length > 4096 chars, the boundary is at
91        // char position 4096 (≈1024 tokens at ~4 chars/token).
92        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// ---------------------------------------------------------------------------
108// Tests
109// ---------------------------------------------------------------------------
110
111#[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    // -----------------------------------------------------------------------
132    // Anthropic tests
133    // -----------------------------------------------------------------------
134
135    #[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        // Two cached messages; boundary should be after the second one.
158        let messages = vec![
159            cached_msg("user", "first"),   // offset 5
160            msg("assistant", "middle"),    // offset 5+6=11
161            cached_msg("user", "second"),  // offset 11+6=17
162            msg("assistant", "tail"),      // offset 17+4=21
163        ];
164        let boundary = detector
165            .detect_boundary(&messages, Provider::Anthropic)
166            .unwrap();
167        // "first" (5) + "middle" (6) + "second" (6) = 17
168        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"),  // offset 3
176            msg("user", "query"),         // offset 3+5=8
177        ];
178        let boundary = detector
179            .detect_boundary(&messages, Provider::Anthropic)
180            .unwrap();
181        assert_eq!(boundary.offset, 3);
182    }
183
184    // -----------------------------------------------------------------------
185    // OpenAI tests
186    // -----------------------------------------------------------------------
187
188    #[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        // total_len == 4096, not > 4096, so no boundary
214        assert!(detector.detect_boundary(&messages, Provider::OpenAI).is_none());
215    }
216
217    // -----------------------------------------------------------------------
218    // Google tests
219    // -----------------------------------------------------------------------
220
221    #[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    // -----------------------------------------------------------------------
229    // split_at_boundary tests
230    // -----------------------------------------------------------------------
231
232    #[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        // before must be byte-identical to the original prefix
285        assert_eq!(before.as_bytes(), &content.as_bytes()[..5]);
286    }
287
288    // -----------------------------------------------------------------------
289    // Property tests
290    // ---------------------------------------------------------------------------
291
292    use proptest::prelude::*;
293
294    /// Strategy: generate a list of messages where at least one has a
295    /// cache_control marker (Anthropic format).
296    fn anthropic_messages_with_boundary() -> impl Strategy<Value = Vec<Message>> {
297        // Generate 1-8 messages; at least one will be marked as cached.
298        (1usize..=8usize).prop_flat_map(|n| {
299            // For each message, generate content and whether it's cached.
300            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        /// **Validates: Requirements 4.1, 4.2**
327        ///
328        /// Property 3: Prompt cache boundary preservation.
329        ///
330        /// For any message sequence containing a prompt cache boundary marker
331        /// (Anthropic or OpenAI format), the Prompt_Cache_Detector SHALL
332        /// identify the boundary, and content preceding the boundary SHALL be
333        /// byte-identical to the original.
334        #[test]
335        fn prop_prompt_cache_boundary_preservation(
336            messages in anthropic_messages_with_boundary(),
337        ) {
338            let detector = PromptCacheDetector;
339
340            // 1. Boundary must be detected (at least one cached message exists).
341            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            // 2. Discount must be 0.9 for Anthropic.
349            prop_assert_eq!(boundary.discount, 0.9);
350
351            // 3. Content before the boundary must be byte-identical to the original.
352            //    Concatenate all message contents to form the "full content".
353            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            // 4. The boundary offset must be at or after the last cached message's
364            //    content end, and must not exceed the total content length.
365            prop_assert!(
366                boundary.offset <= full_content.len(),
367                "boundary offset {} exceeds content length {}",
368                boundary.offset,
369                full_content.len()
370            );
371
372            // 5. Verify the boundary is placed after the last cached message.
373            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        /// Property 3 (OpenAI variant): For any message sequence whose total
391        /// content length exceeds 4096 chars, the detector SHALL identify a
392        /// boundary at offset 4096, and content before that offset SHALL be
393        /// byte-identical to the original.
394        #[test]
395        fn prop_openai_cache_boundary_preservation(
396            // Generate content that is definitely > 4096 chars total
397            extra in 1usize..=1000usize,
398            content in "[a-z]{4097,5000}",
399        ) {
400            let _ = extra; // suppress unused warning
401            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}