behest_core/cache.rs
1//! Prompt caching control types.
2//!
3//! Provider-neutral abstractions for marking message content and tool
4//! definitions as eligible for prompt caching. Adapters translate these
5//! markers to provider-specific wire formats (e.g. Anthropic's
6//! `cache_control` blocks) or rely on the provider's automatic caching
7//! (e.g. OpenAI's prefix cache).
8//!
9//! # Stability model
10//!
11//! - `CacheControl` is a per-`ContentPart` and per-`ToolSpec` marker.
12//! - A marker on the last content block of a stable region (system
13//! prompt, tool definitions, conversation tail) instructs the provider
14//! to create a cache entry covering everything from the start of the
15//! prompt up to and including that block.
16//! - Markers have no effect on providers that do not support caching;
17//! they are silently ignored.
18//!
19//! # Example
20//!
21//! ```rust
22//! use behest_core::cache::CacheControl;
23//! use behest_core::message::{ContentPart, Message};
24//!
25//! let ctrl = CacheControl::ephemeral();
26//! let part = ContentPart::text("You are a research assistant.").with_cache_control(ctrl);
27//! let message = Message::system_text("You are a research assistant.")
28//! .mark_cache_breakpoint();
29//! ```
30
31use serde::{Deserialize, Serialize};
32
33/// Provider-neutral cache control marker.
34///
35/// Attach to a [`crate::message::ContentPart`] or [`crate::tool_types::ToolSpec`]
36/// to request that the provider cache the prefix ending at this marker.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub struct CacheControl {
39 /// Cache strategy. Currently only `Ephemeral` is supported across all
40 /// integrated providers.
41 pub kind: CacheControlKind,
42 /// Time-to-live for the cache entry.
43 pub ttl: CacheTtl,
44}
45
46impl CacheControl {
47 /// Returns an `Ephemeral` cache control with the default 5-minute TTL.
48 #[must_use]
49 pub const fn ephemeral() -> Self {
50 Self {
51 kind: CacheControlKind::Ephemeral,
52 ttl: CacheTtl::FiveMinutes,
53 }
54 }
55
56 /// Returns an `Ephemeral` cache control with the specified TTL.
57 #[must_use]
58 pub const fn with_ttl(mut self, ttl: CacheTtl) -> Self {
59 self.ttl = ttl;
60 self
61 }
62
63 /// Returns the wire-format TTL string (e.g. `"5m"`, `"1h"`).
64 #[must_use]
65 pub const fn ttl_wire(&self) -> &'static str {
66 match self.ttl {
67 CacheTtl::FiveMinutes => "5m",
68 CacheTtl::OneHour => "1h",
69 }
70 }
71}
72
73impl Default for CacheControl {
74 fn default() -> Self {
75 Self::ephemeral()
76 }
77}
78
79/// Cache strategy kinds.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82#[non_exhaustive]
83pub enum CacheControlKind {
84 /// Short-lived cache that the provider may evict.
85 Ephemeral,
86}
87
88/// Time-to-live for a cache entry.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91#[non_exhaustive]
92pub enum CacheTtl {
93 /// 5-minute TTL. Cheapest cache writes (1.25× base input price).
94 #[default]
95 FiveMinutes,
96 /// 1-hour TTL. More expensive cache writes (2× base input price) but
97 /// better hit rate for long-lived sessions.
98 OneHour,
99}
100
101#[cfg(test)]
102#[allow(clippy::unwrap_used)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn cache_control_default_is_ephemeral_five_minutes() {
108 let ctrl = CacheControl::default();
109 assert_eq!(ctrl.kind, CacheControlKind::Ephemeral);
110 assert_eq!(ctrl.ttl, CacheTtl::FiveMinutes);
111 }
112
113 #[test]
114 fn cache_control_with_ttl_overrides() {
115 let ctrl = CacheControl::ephemeral().with_ttl(CacheTtl::OneHour);
116 assert_eq!(ctrl.ttl, CacheTtl::OneHour);
117 }
118
119 #[test]
120 fn cache_control_ttl_wire_strings() {
121 assert_eq!(CacheControl::ephemeral().ttl_wire(), "5m");
122 assert_eq!(
123 CacheControl::ephemeral()
124 .with_ttl(CacheTtl::OneHour)
125 .ttl_wire(),
126 "1h"
127 );
128 }
129}