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}