Skip to main content

lean_ctx/proxy/
cache_policy.rs

1//! Net-cost policy for cache-busting rewrites (#986, cache-economics).
2//!
3//! The cold-prefix repack (#480) re-seeds a leaner prompt cache when the proxy
4//! predicts the client-cached prefix has already gone cold (idle past the TTL).
5//! Because the entry is *already* expired, the provider re-writes the prefix on
6//! the next turn no matter what — so compressing that unavoidable re-write is
7//! free savings, **except** for prefixes too small to be cached at all. Repacking
8//! one of those only churns the conversation's cache identity for no benefit.
9//!
10//! This module is the pricing brain that decides when a repack pays:
11//!
12//! - [`worth_repacking`] — the live gate applied in `anthropic.rs`. It runs
13//!   *before* compression, so it can only weigh the measurable precondition: is
14//!   the cacheable prefix even large enough to be worth re-seeding?
15//! - [`net_cost_decision`] / [`repack_saving_usd`] — the fully priced primitive
16//!   (before/after token counts × [`ModelCost`]) for callers that already know
17//!   the compressed size (tests today, cache-edit batching later).
18//!
19//! Pure functions, no globals; gated behind the opt-in `proxy.cache_policy` at
20//! the call site so a default proxy keeps today's behaviour exactly.
21
22use serde_json::Value;
23
24use crate::core::gain::model_pricing::ModelCost;
25use crate::core::tokens::count_tokens;
26
27/// Minimum cacheable prefix size, in tokens. Anthropic will not cache a prefix
28/// below this (1024 for most models; Haiku needs more), so re-seeding a smaller
29/// one can never produce a cache the provider would keep — the conservative
30/// floor below which a repack is pure churn. Chosen as the documented Anthropic
31/// minimum rather than an estimate.
32pub const MIN_CACHEABLE_TOKENS: u64 = 1024;
33
34/// Token count of an Anthropic `system` field (a string or a content-block
35/// array) — the part of the cacheable prefix that precedes every message.
36/// Serialized and BPE-counted like the messages below so both halves of the
37/// prefix use one consistent measure.
38fn system_tokens(system: Option<&Value>) -> u64 {
39    match system {
40        Some(v) if !v.is_null() => serde_json::to_string(v).map_or(0, |s| count_tokens(&s) as u64),
41        _ => 0,
42    }
43}
44
45/// Token count of the prefix the provider would actually cache: the `system`
46/// field plus the client-cached messages `messages[0..cached]`. Measured (not
47/// estimated) via the same BPE counter the rest of the proxy uses, so the gate
48/// reflects the real prefix — including the system prose a cold-prefix repack
49/// re-seeds, which is usually the bulk of it.
50#[must_use]
51pub fn prefix_tokens(system: Option<&Value>, messages: &[Value], cached: usize) -> u64 {
52    let mut total = system_tokens(system);
53    let end = cached.min(messages.len());
54    if end > 0
55        && let Ok(serialized) = serde_json::to_string(&messages[..end])
56    {
57        total += count_tokens(&serialized) as u64;
58    }
59    total
60}
61
62/// Live repack gate (pre-compression). A cold-prefix repack only pays when the
63/// cacheable prefix (system + cached messages) is large enough that the provider
64/// will actually cache the re-seeded version; below [`MIN_CACHEABLE_TOKENS`] the
65/// repack just churns the conversation's cache key. Applied as an extra
66/// AND-condition on the existing repack decision, so the policy can only make
67/// repacking *more* conservative — never trigger a rewrite that would not have
68/// happened.
69#[must_use]
70pub fn worth_repacking(system: Option<&Value>, messages: &[Value], cached: usize) -> bool {
71    prefix_tokens(system, messages, cached) >= MIN_CACHEABLE_TOKENS
72}
73
74/// Cache-write cost saved by re-seeding a compressed prefix instead of the full
75/// one, in USD. On a cold prefix the provider re-writes regardless, so the saving
76/// is the avoided write of the dropped tokens: `(before − after) × cache_write`.
77/// Clamped to `0.0` when compression did not shrink the prefix.
78#[must_use]
79pub fn repack_saving_usd(before_tokens: u64, after_tokens: u64, cost: &ModelCost) -> f64 {
80    let saved = before_tokens.saturating_sub(after_tokens);
81    saved as f64 / 1_000_000.0 * cost.cache_write_per_m
82}
83
84/// Fully priced repack decision for callers that already know the compressed
85/// size. True when the prefix is cacheable *and* re-seeding it strictly lowers
86/// the unavoidable cold re-write cost. The precondition mirrors
87/// [`worth_repacking`] so the live gate and the priced primitive never disagree.
88#[must_use]
89pub fn net_cost_decision(before_tokens: u64, after_tokens: u64, cost: &ModelCost) -> bool {
90    before_tokens >= MIN_CACHEABLE_TOKENS
91        && after_tokens < before_tokens
92        && repack_saving_usd(before_tokens, after_tokens, cost) > 0.0
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use serde_json::json;
99
100    fn opus() -> ModelCost {
101        ModelCost {
102            input_per_m: 15.00,
103            output_per_m: 75.00,
104            cache_write_per_m: 18.75,
105            cache_read_per_m: 1.50,
106        }
107    }
108
109    #[test]
110    fn prefix_tokens_zero_when_nothing_cached() {
111        let msgs = vec![json!({"role": "user", "content": "hello"})];
112        assert_eq!(prefix_tokens(None, &msgs, 0), 0);
113    }
114
115    #[test]
116    fn prefix_tokens_counts_only_the_cached_span() {
117        let big = "lorem ipsum dolor sit amet ".repeat(50);
118        let msgs = vec![
119            json!({"role": "user", "content": big}),
120            json!({"role": "assistant", "content": "tail not counted"}),
121        ];
122        let one = prefix_tokens(None, &msgs, 1);
123        let two = prefix_tokens(None, &msgs, 2);
124        assert!(one > 0);
125        assert!(two > one, "wider cached span counts more tokens");
126    }
127
128    #[test]
129    fn prefix_tokens_includes_the_system_field() {
130        // The system prose is part of Anthropic's cacheable prefix and is what a
131        // cold repack re-seeds, so it must count toward the gate.
132        let msgs = vec![json!({"role": "user", "content": "hi"})];
133        let big_system = json!("context engineering ".repeat(400));
134        let without = prefix_tokens(None, &msgs, 1);
135        let with = prefix_tokens(Some(&big_system), &msgs, 1);
136        assert!(with > without + 500, "system prose must dominate the count");
137    }
138
139    #[test]
140    fn worth_repacking_rejects_small_prefix() {
141        let msgs = vec![json!({"role": "user", "content": "tiny"})];
142        assert!(!worth_repacking(None, &msgs, 1));
143    }
144
145    #[test]
146    fn worth_repacking_accepts_large_prefix() {
147        // Comfortably above the 1024-token cacheable floor.
148        let big = "context engineering ".repeat(1500);
149        let msgs = vec![json!({"role": "user", "content": big})];
150        assert!(worth_repacking(None, &msgs, 1));
151    }
152
153    #[test]
154    fn worth_repacking_counts_large_system_over_tiny_messages() {
155        // A small message prefix but a large system prompt still clears the gate,
156        // because the provider caches system + messages together.
157        let msgs = vec![json!({"role": "user", "content": "hi"})];
158        let big_system = json!("context engineering ".repeat(1500));
159        assert!(
160            !worth_repacking(None, &msgs, 1),
161            "tiny prefix alone is skipped"
162        );
163        assert!(
164            worth_repacking(Some(&big_system), &msgs, 1),
165            "a large system prompt makes the prefix worth re-seeding"
166        );
167    }
168
169    #[test]
170    fn net_cost_decision_rejects_subcacheable_even_if_smaller() {
171        // Below the cacheable floor: a smaller "after" still doesn't pay.
172        assert!(!net_cost_decision(500, 200, &opus()));
173    }
174
175    #[test]
176    fn net_cost_decision_rejects_when_no_shrink() {
177        assert!(!net_cost_decision(4000, 4000, &opus()));
178        assert!(!net_cost_decision(4000, 5000, &opus()));
179    }
180
181    #[test]
182    fn net_cost_decision_accepts_real_saving() {
183        assert!(net_cost_decision(4000, 2500, &opus()));
184        // Saving is the avoided write of the 1500 dropped tokens.
185        let saved = repack_saving_usd(4000, 2500, &opus());
186        assert!((saved - (1500.0 / 1_000_000.0 * 18.75)).abs() < 1e-9);
187    }
188
189    #[test]
190    fn repack_saving_is_zero_when_inflated() {
191        assert_eq!(repack_saving_usd(1000, 2000, &opus()), 0.0);
192    }
193}