Skip to main content

entelix_core/ir/
cache.rs

1//! Prompt-cache control — vendor-agnostic IR knob.
2//!
3//! Per: a vendor knob enters IR only when 2+ shipping
4//! codecs natively support it. Cache control qualifies — Anthropic
5//! Messages and Bedrock Converse (for Claude models) both expose it
6//! natively. Codecs that don't support it emit
7//! [`crate::ir::ModelWarning::LossyEncode`] — silent
8//! drop is forbidden.
9//!
10//! ## TTL choices
11//!
12//! `CacheTtl` is `#[non_exhaustive]` and currently exposes the two
13//! TTLs Anthropic publishes (5-minute default + 1-hour premium).
14//! New TTLs are added by extending the enum without breaking callers.
15//!
16//! ## Wire format (Anthropic)
17//!
18//! Anthropic's `cache_control` block is always
19//! `{"type": "ephemeral"}` — `type` never carries the TTL string.
20//! The TTL rides in a sibling `ttl` field, omitted for the 5-minute
21//! default and present for premium tiers (`"1h"`). Codecs use
22//! [`CacheTtl::wire_ttl_field`] to render the optional sibling.
23
24use serde::{Deserialize, Serialize};
25
26/// Time-to-live tier for a cached prompt block.
27#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum CacheTtl {
31    /// Five-minute default TTL — what Anthropic emits when
32    /// `cache_control: {type: "ephemeral"}` carries no `ttl` field.
33    /// Use this for hot-path caches (most agents).
34    #[default]
35    FiveMinutes,
36    /// One-hour TTL (Anthropic premium-cache tier — wire `ttl: "1h"`).
37    OneHour,
38}
39
40impl CacheTtl {
41    /// Wire string for the optional `ttl` sibling field — `None`
42    /// when the TTL is the vendor default (5 minutes), `Some("1h")`
43    /// for premium. The `type` field is always `"ephemeral"` per
44    /// Anthropic's contract; only `ttl` varies.
45    #[must_use]
46    pub const fn wire_ttl_field(self) -> Option<&'static str> {
47        match self {
48            Self::FiveMinutes => None,
49            Self::OneHour => Some("1h"),
50        }
51    }
52}
53
54/// Cache directive attached to a [`SystemBlock`](crate::ir::SystemBlock).
55///
56/// Currently a single field (`ttl`); kept as a struct rather than a
57/// bare `CacheTtl` so codec-relevant knobs (`type`, breakpoint
58/// markers) can be added later without breaking the IR shape.
59#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
60pub struct CacheControl {
61    /// Cache lifetime tier.
62    pub ttl: CacheTtl,
63}
64
65impl CacheControl {
66    /// Five-minute TTL — Anthropic's default cache tier. The most
67    /// common choice; renders as a bare `cache_control:
68    /// {type: "ephemeral"}` (no `ttl` field).
69    #[must_use]
70    pub const fn five_minutes() -> Self {
71        Self {
72            ttl: CacheTtl::FiveMinutes,
73        }
74    }
75
76    /// One-hour TTL — Anthropic premium-cache tier. Renders as
77    /// `cache_control: {type: "ephemeral", ttl: "1h"}`.
78    #[must_use]
79    pub const fn one_hour() -> Self {
80        Self {
81            ttl: CacheTtl::OneHour,
82        }
83    }
84}
85
86#[cfg(test)]
87#[allow(clippy::unwrap_used)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn wire_ttl_field_is_stable() {
93        assert_eq!(CacheTtl::FiveMinutes.wire_ttl_field(), None);
94        assert_eq!(CacheTtl::OneHour.wire_ttl_field(), Some("1h"));
95    }
96
97    #[test]
98    fn const_constructors_match_variants() {
99        assert_eq!(CacheControl::five_minutes().ttl, CacheTtl::FiveMinutes);
100        assert_eq!(CacheControl::one_hour().ttl, CacheTtl::OneHour);
101    }
102
103    #[test]
104    fn cache_control_round_trips_via_serde() {
105        let cc = CacheControl::one_hour();
106        let json = serde_json::to_string(&cc).unwrap();
107        let back: CacheControl = serde_json::from_str(&json).unwrap();
108        assert_eq!(cc, back);
109    }
110}