Skip to main content

lean_ctx_proxy/
cache_policy.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Net-cost policy for cache-busting rewrites (#986, cache-economics).
3//!
4//! The cold-prefix repack (#480) re-seeds a leaner prompt cache when the proxy
5//! predicts the client-cached prefix has already gone cold (idle past the TTL).
6//! Because the entry is *already* expired, the provider re-writes the prefix on
7//! the next turn no matter what — so compressing that unavoidable re-write is
8//! free savings, **except** for prefixes too small to be cached at all. Repacking
9//! one of those only churns the conversation's cache identity for no benefit.
10//!
11//! This module is the pricing brain that decides when a repack pays:
12//!
13//! - [`worth_repacking`] — the live gate applied in `anthropic.rs`. It runs
14//!   *before* compression, so it can only weigh the measurable precondition: is
15//!   the cacheable prefix even large enough to be worth re-seeding?
16//! - [`net_cost_decision`] / [`repack_saving_usd`] — the fully priced primitive
17//!   (before/after token counts × `ModelCost`) for callers that already know
18//!   the compressed size (tests today, cache-edit batching later).
19//! - [`system_mutation_decision`] — the warm-prefix gate for compressing the
20//!   `system` prompt while a message prefix is cached (#1912): it prices the
21//!   bust of the whole prefix against the per-turn cache-read saving, with the
22//!   conversation's own turn count as the reuse estimate and current prices
23//!   from the shared table ([`model_cost_for`]).
24//!
25//! Pure functions, no globals; gated behind the opt-in `proxy.cache_policy` at
26//! the call site so a default proxy keeps today's behaviour exactly.
27
28use serde_json::Value;
29
30use lean_ctx::core::gain::model_pricing::ModelCost;
31use lean_ctx::core::tokens::count_tokens;
32
33/// Minimum cacheable prefix size, in tokens. Anthropic will not cache a prefix
34/// below this (1024 for most models; Haiku needs more), so re-seeding a smaller
35/// one can never produce a cache the provider would keep — the conservative
36/// floor below which a repack is pure churn. Chosen as the documented Anthropic
37/// minimum rather than an estimate.
38pub const MIN_CACHEABLE_TOKENS: u64 = 1024;
39
40/// Token count of an Anthropic `system` field (a string or a content-block
41/// array) — the part of the cacheable prefix that precedes every message.
42/// Serialized and BPE-counted like the messages below so both halves of the
43/// prefix use one consistent measure.
44fn system_tokens(system: Option<&Value>) -> u64 {
45    match system {
46        Some(v) if !v.is_null() => serde_json::to_string(v).map_or(0, |s| count_tokens(&s) as u64),
47        _ => 0,
48    }
49}
50
51/// Token count of the prefix the provider would actually cache: the `system`
52/// field plus the client-cached messages `messages[0..cached]`. Measured (not
53/// estimated) via the same BPE counter the rest of the proxy uses, so the gate
54/// reflects the real prefix — including the system prose a cold-prefix repack
55/// re-seeds, which is usually the bulk of it.
56#[must_use]
57pub fn prefix_tokens(system: Option<&Value>, messages: &[Value], cached: usize) -> u64 {
58    let mut total = system_tokens(system);
59    let end = cached.min(messages.len());
60    if end > 0
61        && let Ok(serialized) = serde_json::to_string(&messages[..end])
62    {
63        total += count_tokens(&serialized) as u64;
64    }
65    total
66}
67
68/// Live repack gate (pre-compression). A cold-prefix repack only pays when the
69/// cacheable prefix (system + cached messages) is large enough that the provider
70/// will actually cache the re-seeded version; below [`MIN_CACHEABLE_TOKENS`] the
71/// repack just churns the conversation's cache key. Applied as an extra
72/// AND-condition on the existing repack decision, so the policy can only make
73/// repacking *more* conservative — never trigger a rewrite that would not have
74/// happened.
75#[must_use]
76pub fn worth_repacking(system: Option<&Value>, messages: &[Value], cached: usize) -> bool {
77    prefix_tokens(system, messages, cached) >= MIN_CACHEABLE_TOKENS
78}
79
80/// Cache-write cost saved by re-seeding a compressed prefix instead of the full
81/// one, in USD. On a cold prefix the provider re-writes regardless, so the saving
82/// is the avoided write of the dropped tokens: `(before − after) × cache_write`.
83/// Clamped to `0.0` when compression did not shrink the prefix.
84#[must_use]
85pub fn repack_saving_usd(before_tokens: u64, after_tokens: u64, cost: &ModelCost) -> f64 {
86    let saved = before_tokens.saturating_sub(after_tokens);
87    saved as f64 / 1_000_000.0 * cost.cache_write_per_m
88}
89
90/// Fully priced repack decision for callers that already know the compressed
91/// size. True when the prefix is cacheable *and* re-seeding it strictly lowers
92/// the unavoidable cold re-write cost. The precondition mirrors
93/// [`worth_repacking`] so the live gate and the priced primitive never disagree.
94#[must_use]
95pub fn net_cost_decision(before_tokens: u64, after_tokens: u64, cost: &ModelCost) -> bool {
96    before_tokens >= MIN_CACHEABLE_TOKENS
97        && after_tokens < before_tokens
98        && repack_saving_usd(before_tokens, after_tokens, cost) > 0.0
99}
100
101/// Decision outcome for a frozen-region mutation.
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum MutationDecision {
104    /// The compression saving exceeds the cache-bust cost.
105    Mutate { break_even: u32 },
106    /// The cache-bust cost exceeds the compression saving; preserve the prefix.
107    Preserve { break_even: u32 },
108}
109
110/// Cost parameters for a model name, from the shared pricing table (embedded
111/// prices, `[cost.prices]` overrides, live list) so the gate never prices a
112/// request with stale per-family constants (#1912).
113#[must_use]
114pub fn model_cost_for(model: &str) -> ModelCost {
115    lean_ctx::core::gain::model_pricing::ModelPricing::load()
116        .quote(Some(model))
117        .cost
118}
119
120/// Generalised net-cost gate for **any** frozen-region mutation. A frozen
121/// region is served from the prompt cache, so:
122///
123/// - the one-off bust is the mutation turn's re-write of the shrunk prefix in
124///   place of the cache read it would otherwise have been
125///   (`after × cache_write − before × cache_read`, never negative);
126/// - the per-call saving afterwards is the dropped tokens at the *cache-read*
127///   price (they would have been cache reads, not fresh input).
128///
129/// Mutates only when the expected reuse count reaches the break-even point.
130#[must_use]
131pub fn should_mutate_frozen(
132    before_tokens: u64,
133    after_tokens: u64,
134    estimated_reuse_count: u32,
135    cost: &ModelCost,
136) -> MutationDecision {
137    if before_tokens < MIN_CACHEABLE_TOKENS || after_tokens >= before_tokens {
138        return MutationDecision::Preserve {
139            break_even: u32::MAX,
140        };
141    }
142    let saved_tokens = before_tokens - after_tokens;
143    let bust_cost = (after_tokens as f64 / 1_000_000.0 * cost.cache_write_per_m
144        - before_tokens as f64 / 1_000_000.0 * cost.cache_read_per_m)
145        .max(0.0);
146    let per_call_saving = saved_tokens as f64 / 1_000_000.0 * cost.cache_read_per_m;
147    if per_call_saving <= 0.0 {
148        return MutationDecision::Preserve {
149            break_even: u32::MAX,
150        };
151    }
152    let break_even = (bust_cost / per_call_saving).ceil().max(1.0) as u32;
153    if estimated_reuse_count >= break_even {
154        MutationDecision::Mutate { break_even }
155    } else {
156        MutationDecision::Preserve { break_even }
157    }
158}
159
160/// Expected future reuses of a cached prefix, read from the conversation
161/// itself: every prior assistant turn is a request that already re-sent the
162/// prefix. A pure function of the request, so the gate below decides the same
163/// way on every replay of the same turn (#498) — unlike a process-wide rate.
164#[must_use]
165pub fn observed_prefix_reuse(messages: &[Value]) -> u32 {
166    let turns = messages
167        .iter()
168        .filter(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
169        .count();
170    u32::try_from(turns).unwrap_or(u32::MAX)
171}
172
173/// Priced gate for compressing the `system` prompt while the client caches a
174/// message prefix (#1912). The system block sits in front of every cached
175/// message, so rewriting it busts the *whole* prefix: the bust is priced on
176/// `prefix_before` (system + cached messages, see [`prefix_tokens`]) and the
177/// saving on the measured shrink of the system block alone.
178#[must_use]
179pub fn system_mutation_decision(
180    prefix_before: u64,
181    system_before: &Value,
182    system_after: &Value,
183    reuse: u32,
184    cost: &ModelCost,
185) -> MutationDecision {
186    let saved =
187        system_tokens(Some(system_before)).saturating_sub(system_tokens(Some(system_after)));
188    should_mutate_frozen(
189        prefix_before,
190        prefix_before.saturating_sub(saved),
191        reuse,
192        cost,
193    )
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use serde_json::json;
200
201    fn opus() -> ModelCost {
202        ModelCost {
203            input_per_m: 5.00,
204            output_per_m: 25.00,
205            cache_write_per_m: 6.25,
206            cache_read_per_m: 0.50,
207        }
208    }
209
210    #[test]
211    fn prefix_tokens_zero_when_nothing_cached() {
212        let msgs = vec![json!({"role": "user", "content": "hello"})];
213        assert_eq!(prefix_tokens(None, &msgs, 0), 0);
214    }
215
216    #[test]
217    fn prefix_tokens_counts_only_the_cached_span() {
218        let big = "lorem ipsum dolor sit amet ".repeat(50);
219        let msgs = vec![
220            json!({"role": "user", "content": big}),
221            json!({"role": "assistant", "content": "tail not counted"}),
222        ];
223        let one = prefix_tokens(None, &msgs, 1);
224        let two = prefix_tokens(None, &msgs, 2);
225        assert!(one > 0);
226        assert!(two > one, "wider cached span counts more tokens");
227    }
228
229    #[test]
230    fn prefix_tokens_includes_the_system_field() {
231        // The system prose is part of Anthropic's cacheable prefix and is what a
232        // cold repack re-seeds, so it must count toward the gate.
233        let msgs = vec![json!({"role": "user", "content": "hi"})];
234        let big_system = json!("context engineering ".repeat(400));
235        let without = prefix_tokens(None, &msgs, 1);
236        let with = prefix_tokens(Some(&big_system), &msgs, 1);
237        assert!(with > without + 500, "system prose must dominate the count");
238    }
239
240    #[test]
241    fn worth_repacking_rejects_small_prefix() {
242        let msgs = vec![json!({"role": "user", "content": "tiny"})];
243        assert!(!worth_repacking(None, &msgs, 1));
244    }
245
246    #[test]
247    fn worth_repacking_accepts_large_prefix() {
248        // Comfortably above the 1024-token cacheable floor.
249        let big = "context engineering ".repeat(1500);
250        let msgs = vec![json!({"role": "user", "content": big})];
251        assert!(worth_repacking(None, &msgs, 1));
252    }
253
254    #[test]
255    fn worth_repacking_counts_large_system_over_tiny_messages() {
256        // A small message prefix but a large system prompt still clears the gate,
257        // because the provider caches system + messages together.
258        let msgs = vec![json!({"role": "user", "content": "hi"})];
259        let big_system = json!("context engineering ".repeat(1500));
260        assert!(
261            !worth_repacking(None, &msgs, 1),
262            "tiny prefix alone is skipped"
263        );
264        assert!(
265            worth_repacking(Some(&big_system), &msgs, 1),
266            "a large system prompt makes the prefix worth re-seeding"
267        );
268    }
269
270    #[test]
271    fn net_cost_decision_rejects_subcacheable_even_if_smaller() {
272        // Below the cacheable floor: a smaller "after" still doesn't pay.
273        assert!(!net_cost_decision(500, 200, &opus()));
274    }
275
276    #[test]
277    fn net_cost_decision_rejects_when_no_shrink() {
278        assert!(!net_cost_decision(4000, 4000, &opus()));
279        assert!(!net_cost_decision(4000, 5000, &opus()));
280    }
281
282    #[test]
283    fn net_cost_decision_accepts_real_saving() {
284        assert!(net_cost_decision(4000, 2500, &opus()));
285        // Saving is the avoided write of the 1500 dropped tokens.
286        let saved = repack_saving_usd(4000, 2500, &opus());
287        assert!((saved - (1500.0 / 1_000_000.0 * 6.25)).abs() < 1e-9);
288    }
289
290    #[test]
291    fn repack_saving_is_zero_when_inflated() {
292        assert_eq!(repack_saving_usd(1000, 2000, &opus()), 0.0);
293    }
294
295    #[test]
296    fn should_mutate_frozen_accepts_with_enough_reuse() {
297        // bust = 2000×6.25 − 4000×0.50 = 10 500; saving/call = 2000×0.50 = 1 000
298        // (per-million units) → break-even after 11 reuses.
299        let d = should_mutate_frozen(4000, 2000, 20, &opus());
300        assert_eq!(d, MutationDecision::Mutate { break_even: 11 });
301        assert_eq!(
302            should_mutate_frozen(4000, 2000, 10, &opus()),
303            MutationDecision::Preserve { break_even: 11 }
304        );
305    }
306
307    #[test]
308    fn should_mutate_frozen_prices_savings_as_cache_reads() {
309        // Without a cache discount there is nothing to bust: mutate at once.
310        let flat = ModelCost {
311            input_per_m: 2.0,
312            output_per_m: 8.0,
313            cache_write_per_m: 2.0,
314            cache_read_per_m: 2.0,
315        };
316        assert_eq!(
317            should_mutate_frozen(4000, 2000, 1, &flat),
318            MutationDecision::Mutate { break_even: 1 }
319        );
320    }
321
322    #[test]
323    fn observed_reuse_counts_prior_assistant_turns() {
324        let msgs = vec![
325            json!({"role": "user", "content": "a"}),
326            json!({"role": "assistant", "content": "b"}),
327            json!({"role": "user", "content": "c"}),
328            json!({"role": "assistant", "content": "d"}),
329            json!({"role": "user", "content": "e"}),
330        ];
331        assert_eq!(observed_prefix_reuse(&msgs), 2);
332        assert_eq!(observed_prefix_reuse(&[]), 0);
333    }
334
335    #[test]
336    fn system_gate_prices_the_whole_prefix() {
337        let before = json!("context engineering ".repeat(1500));
338        let after = json!("context engineering ".repeat(300));
339        let prefix = system_tokens(Some(&before));
340        // Same shrink, but a large cached message tail behind the system block
341        // makes the bust dearer — the break-even must move out.
342        let small = system_mutation_decision(prefix, &before, &after, 1000, &opus());
343        let large = system_mutation_decision(prefix * 10, &before, &after, 1000, &opus());
344        let be = |d: MutationDecision| match d {
345            MutationDecision::Mutate { break_even } | MutationDecision::Preserve { break_even } => {
346                break_even
347            }
348        };
349        assert!(be(large) > be(small), "{small:?} vs {large:?}");
350        assert!(matches!(
351            system_mutation_decision(prefix, &before, &before, 1000, &opus()),
352            MutationDecision::Preserve { .. }
353        ));
354    }
355
356    #[test]
357    fn should_mutate_frozen_rejects_with_low_reuse() {
358        let d = should_mutate_frozen(4000, 3900, 1, &opus());
359        assert!(matches!(d, MutationDecision::Preserve { .. }));
360    }
361
362    #[test]
363    fn should_mutate_frozen_rejects_subcacheable() {
364        let d = should_mutate_frozen(500, 200, 100, &opus());
365        assert!(matches!(d, MutationDecision::Preserve { .. }));
366    }
367
368    #[test]
369    fn should_mutate_frozen_rejects_inflation() {
370        let d = should_mutate_frozen(3000, 4000, 100, &opus());
371        assert!(matches!(d, MutationDecision::Preserve { .. }));
372    }
373
374    #[test]
375    fn model_cost_uses_current_pricing_table() {
376        // #1912: the gate used to price every Opus at the retired $15/$75 and
377        // every Haiku at $0.25 — now it reads the shared, current table.
378        let opus = model_cost_for("claude-opus-4-5");
379        assert!((opus.input_per_m - 5.0).abs() < 1e-9, "{opus:?}");
380        assert!((opus.cache_write_per_m - 6.25).abs() < 1e-9, "{opus:?}");
381        let haiku = model_cost_for("claude-haiku-4-5");
382        assert!((haiku.input_per_m - 1.0).abs() < 1e-9, "{haiku:?}");
383    }
384}