1use serde_json::Value;
23
24use crate::core::gain::model_pricing::ModelCost;
25use crate::core::tokens::count_tokens;
26
27pub const MIN_CACHEABLE_TOKENS: u64 = 1024;
33
34fn 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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq)]
97pub enum MutationDecision {
98 Mutate { break_even: u32 },
100 Preserve { break_even: u32 },
102}
103
104#[must_use]
107pub fn model_cost_for(model: &str) -> ModelCost {
108 let m = model.to_ascii_lowercase();
109 if m.contains("opus") {
110 ModelCost {
111 input_per_m: 15.0,
112 output_per_m: 75.0,
113 cache_write_per_m: 18.75,
114 cache_read_per_m: 1.5,
115 }
116 } else if m.contains("haiku") {
117 ModelCost {
118 input_per_m: 0.25,
119 output_per_m: 1.25,
120 cache_write_per_m: 0.30,
121 cache_read_per_m: 0.03,
122 }
123 } else {
124 ModelCost {
125 input_per_m: 3.0,
126 output_per_m: 15.0,
127 cache_write_per_m: 3.75,
128 cache_read_per_m: 0.30,
129 }
130 }
131}
132
133pub fn should_mutate_frozen(
134 before_tokens: u64,
135 after_tokens: u64,
136 estimated_reuse_count: u32,
137 cost: &ModelCost,
138) -> MutationDecision {
139 if before_tokens < MIN_CACHEABLE_TOKENS || after_tokens >= before_tokens {
140 return MutationDecision::Preserve {
141 break_even: u32::MAX,
142 };
143 }
144 let saved_tokens = before_tokens - after_tokens;
145 let bust_cost = (after_tokens as f64 / 1_000_000.0 * cost.cache_write_per_m)
146 + (before_tokens as f64 / 1_000_000.0 * cost.cache_read_per_m);
147 let per_call_saving = saved_tokens as f64 / 1_000_000.0 * cost.input_per_m;
148 if per_call_saving <= 0.0 {
149 return MutationDecision::Preserve {
150 break_even: u32::MAX,
151 };
152 }
153 let break_even = (bust_cost / per_call_saving).ceil().max(1.0) as u32;
154 if estimated_reuse_count >= break_even {
155 MutationDecision::Mutate { break_even }
156 } else {
157 MutationDecision::Preserve { break_even }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use serde_json::json;
165
166 fn opus() -> ModelCost {
167 ModelCost {
168 input_per_m: 15.00,
169 output_per_m: 75.00,
170 cache_write_per_m: 18.75,
171 cache_read_per_m: 1.50,
172 }
173 }
174
175 #[test]
176 fn prefix_tokens_zero_when_nothing_cached() {
177 let msgs = vec![json!({"role": "user", "content": "hello"})];
178 assert_eq!(prefix_tokens(None, &msgs, 0), 0);
179 }
180
181 #[test]
182 fn prefix_tokens_counts_only_the_cached_span() {
183 let big = "lorem ipsum dolor sit amet ".repeat(50);
184 let msgs = vec![
185 json!({"role": "user", "content": big}),
186 json!({"role": "assistant", "content": "tail not counted"}),
187 ];
188 let one = prefix_tokens(None, &msgs, 1);
189 let two = prefix_tokens(None, &msgs, 2);
190 assert!(one > 0);
191 assert!(two > one, "wider cached span counts more tokens");
192 }
193
194 #[test]
195 fn prefix_tokens_includes_the_system_field() {
196 let msgs = vec![json!({"role": "user", "content": "hi"})];
199 let big_system = json!("context engineering ".repeat(400));
200 let without = prefix_tokens(None, &msgs, 1);
201 let with = prefix_tokens(Some(&big_system), &msgs, 1);
202 assert!(with > without + 500, "system prose must dominate the count");
203 }
204
205 #[test]
206 fn worth_repacking_rejects_small_prefix() {
207 let msgs = vec![json!({"role": "user", "content": "tiny"})];
208 assert!(!worth_repacking(None, &msgs, 1));
209 }
210
211 #[test]
212 fn worth_repacking_accepts_large_prefix() {
213 let big = "context engineering ".repeat(1500);
215 let msgs = vec![json!({"role": "user", "content": big})];
216 assert!(worth_repacking(None, &msgs, 1));
217 }
218
219 #[test]
220 fn worth_repacking_counts_large_system_over_tiny_messages() {
221 let msgs = vec![json!({"role": "user", "content": "hi"})];
224 let big_system = json!("context engineering ".repeat(1500));
225 assert!(
226 !worth_repacking(None, &msgs, 1),
227 "tiny prefix alone is skipped"
228 );
229 assert!(
230 worth_repacking(Some(&big_system), &msgs, 1),
231 "a large system prompt makes the prefix worth re-seeding"
232 );
233 }
234
235 #[test]
236 fn net_cost_decision_rejects_subcacheable_even_if_smaller() {
237 assert!(!net_cost_decision(500, 200, &opus()));
239 }
240
241 #[test]
242 fn net_cost_decision_rejects_when_no_shrink() {
243 assert!(!net_cost_decision(4000, 4000, &opus()));
244 assert!(!net_cost_decision(4000, 5000, &opus()));
245 }
246
247 #[test]
248 fn net_cost_decision_accepts_real_saving() {
249 assert!(net_cost_decision(4000, 2500, &opus()));
250 let saved = repack_saving_usd(4000, 2500, &opus());
252 assert!((saved - (1500.0 / 1_000_000.0 * 18.75)).abs() < 1e-9);
253 }
254
255 #[test]
256 fn repack_saving_is_zero_when_inflated() {
257 assert_eq!(repack_saving_usd(1000, 2000, &opus()), 0.0);
258 }
259
260 #[test]
261 fn should_mutate_frozen_accepts_with_enough_reuse() {
262 let d = should_mutate_frozen(4000, 2000, 10, &opus());
263 match d {
264 MutationDecision::Mutate { break_even } => assert!(break_even <= 10),
265 MutationDecision::Preserve { .. } => panic!("expected Mutate"),
266 }
267 }
268
269 #[test]
270 fn should_mutate_frozen_rejects_with_low_reuse() {
271 let d = should_mutate_frozen(4000, 3900, 1, &opus());
272 assert!(matches!(d, MutationDecision::Preserve { .. }));
273 }
274
275 #[test]
276 fn should_mutate_frozen_rejects_subcacheable() {
277 let d = should_mutate_frozen(500, 200, 100, &opus());
278 assert!(matches!(d, MutationDecision::Preserve { .. }));
279 }
280
281 #[test]
282 fn should_mutate_frozen_rejects_inflation() {
283 let d = should_mutate_frozen(3000, 4000, 100, &opus());
284 assert!(matches!(d, MutationDecision::Preserve { .. }));
285 }
286}