1use std::collections::VecDeque;
42use std::sync::{Arc, Mutex};
43use std::time::{Duration, Instant};
44
45use serde::{Deserialize, Serialize};
46
47use crate::core::ocla::types::ResponseOptimizationRequest;
48use crate::core::savings_ledger::{self, SavingsEvent};
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(default)]
53pub struct ResponseOptimizerConfig {
54 pub enabled: bool,
56 pub cache_enabled: bool,
58 pub dedup_enabled: bool,
60 pub cache_ttl_secs: u64,
62 pub cache_capacity: usize,
64 pub dedup_window: usize,
66}
67
68impl Default for ResponseOptimizerConfig {
69 fn default() -> Self {
70 Self {
71 enabled: false,
72 cache_enabled: true,
73 dedup_enabled: true,
74 cache_ttl_secs: 300,
75 cache_capacity: 64,
76 dedup_window: 16,
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
83struct CacheEntry {
84 response_body: String,
85 created_at: Instant,
86}
87
88#[derive(Debug)]
90pub struct ResponseCache {
91 entries: VecDeque<(u64, CacheEntry)>,
92 capacity: usize,
93 ttl: Duration,
94}
95
96impl ResponseCache {
97 pub fn new(capacity: usize, ttl: Duration) -> Self {
98 Self {
99 entries: VecDeque::with_capacity(capacity),
100 capacity,
101 ttl,
102 }
103 }
104
105 pub fn get(&mut self, key: u64) -> Option<&str> {
107 self.evict_expired();
108 let pos = self.entries.iter().position(|(k, _)| *k == key)?;
109 let entry = self.entries.remove(pos)?;
111 self.entries.push_back(entry);
112 self.entries.back().map(|(_, e)| e.response_body.as_str())
114 }
115
116 pub fn put(&mut self, key: u64, response: String, _output_tokens: u64) {
118 self.evict_expired();
119 self.entries.retain(|(k, _)| *k != key);
121 while self.entries.len() >= self.capacity {
123 self.entries.pop_front();
124 }
125 self.entries.push_back((
126 key,
127 CacheEntry {
128 response_body: response,
129 created_at: Instant::now(),
130 },
131 ));
132 }
133
134 fn evict_expired(&mut self) {
136 let now = Instant::now();
137 self.entries
138 .retain(|(_, e)| now.duration_since(e.created_at) < self.ttl);
139 }
140
141 pub fn len(&self) -> usize {
142 self.entries.len()
143 }
144
145 pub fn is_empty(&self) -> bool {
146 self.entries.is_empty()
147 }
148}
149
150#[derive(Debug)]
152pub struct DedupTracker {
153 fingerprints: VecDeque<u64>,
154 window: usize,
155}
156
157impl DedupTracker {
158 pub fn new(window: usize) -> Self {
159 Self {
160 fingerprints: VecDeque::with_capacity(window),
161 window,
162 }
163 }
164
165 pub fn record(&mut self, fingerprint: u64) -> bool {
168 let is_dup = self.fingerprints.contains(&fingerprint);
169 if self.fingerprints.len() >= self.window {
170 self.fingerprints.pop_front();
171 }
172 self.fingerprints.push_back(fingerprint);
173 is_dup
174 }
175
176 pub fn clear(&mut self) {
177 self.fingerprints.clear();
178 }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183pub struct OptimizationDecision {
184 pub cache_hit: bool,
186 pub is_duplicate: bool,
188 pub cache_key: u64,
190 pub tokens_saved: u64,
192 pub source: OptimizationSource,
194}
195
196#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
198pub enum OptimizationSource {
199 None,
201 Cache,
203 Dedup,
205 CacheAndDedup,
207}
208
209#[derive(Debug)]
211pub struct SessionOptimizer {
212 pub cache: ResponseCache,
213 pub dedup: DedupTracker,
214 pub config: ResponseOptimizerConfig,
215 pub stats: OptimizerStats,
216}
217
218#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220pub struct OptimizerStats {
221 pub cache_hits: u64,
222 pub cache_misses: u64,
223 pub dedup_detections: u64,
224 pub total_tokens_saved: u64,
225}
226
227impl SessionOptimizer {
228 pub fn new(config: ResponseOptimizerConfig) -> Self {
229 let cache = ResponseCache::new(
230 config.cache_capacity,
231 Duration::from_secs(config.cache_ttl_secs),
232 );
233 let dedup = DedupTracker::new(config.dedup_window);
234 Self {
235 cache,
236 dedup,
237 config,
238 stats: OptimizerStats::default(),
239 }
240 }
241
242 pub fn try_cache_hit(&mut self, cache_key: u64) -> Option<&str> {
245 if !self.config.cache_enabled {
246 return None;
247 }
248 let hit = self.cache.get(cache_key);
249 if hit.is_some() {
250 self.stats.cache_hits += 1;
251 } else {
252 self.stats.cache_misses += 1;
253 }
254 hit
255 }
256
257 pub fn record_response(
259 &mut self,
260 cache_key: u64,
261 response: &str,
262 output_tokens: u64,
263 ) -> OptimizationDecision {
264 let fingerprint = fingerprint_response(response);
265 let is_dup = if self.config.dedup_enabled {
266 let dup = self.dedup.record(fingerprint);
267 if dup {
268 self.stats.dedup_detections += 1;
269 }
270 dup
271 } else {
272 false
273 };
274
275 if self.config.cache_enabled {
276 self.cache
277 .put(cache_key, response.to_string(), output_tokens);
278 }
279
280 OptimizationDecision {
281 cache_hit: false,
282 is_duplicate: is_dup,
283 cache_key,
284 tokens_saved: 0,
285 source: if is_dup {
286 OptimizationSource::Dedup
287 } else {
288 OptimizationSource::None
289 },
290 }
291 }
292
293 pub fn cache_hit_decision(&self, cache_key: u64, tokens_saved: u64) -> OptimizationDecision {
295 OptimizationDecision {
296 cache_hit: true,
297 is_duplicate: false,
298 cache_key,
299 tokens_saved,
300 source: OptimizationSource::Cache,
301 }
302 }
303}
304
305pub fn compute_cache_key(model: &str, system: Option<&str>, messages: &[&str]) -> u64 {
308 let mut hasher = SimpleHasher::new();
309 hasher.write(model.as_bytes());
310 hasher.write(b"\x00");
311 if let Some(sys) = system {
312 hasher.write(sys.as_bytes());
313 }
314 hasher.write(b"\x00");
315 for msg in messages {
316 hasher.write(msg.as_bytes());
317 hasher.write(b"\x01");
318 }
319 hasher.finish()
320}
321
322pub fn fingerprint_response(response: &str) -> u64 {
325 let prefix = if response.len() > 200 {
326 &response[..200]
327 } else {
328 response
329 };
330 let mut hasher = SimpleHasher::new();
331 hasher.write(prefix.as_bytes());
332 hasher.finish()
333}
334
335struct SimpleHasher {
339 state: u64,
340}
341
342impl SimpleHasher {
343 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
344 const PRIME: u64 = 0x0100_0000_01b3;
345
346 fn new() -> Self {
347 Self {
348 state: Self::OFFSET,
349 }
350 }
351
352 fn write(&mut self, bytes: &[u8]) {
353 for &b in bytes {
354 self.state ^= u64::from(b);
355 self.state = self.state.wrapping_mul(Self::PRIME);
356 }
357 }
358
359 fn finish(&self) -> u64 {
360 self.state
361 }
362}
363
364static OPTIMIZERS: std::sync::OnceLock<
367 Mutex<std::collections::HashMap<String, Arc<Mutex<SessionOptimizer>>>>,
368> = std::sync::OnceLock::new();
369
370fn registry() -> &'static Mutex<std::collections::HashMap<String, Arc<Mutex<SessionOptimizer>>>> {
371 OPTIMIZERS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
372}
373
374pub fn get_or_create(
376 session_id: &str,
377 config: &ResponseOptimizerConfig,
378) -> Arc<Mutex<SessionOptimizer>> {
379 let mut reg = registry()
380 .lock()
381 .unwrap_or_else(std::sync::PoisonError::into_inner);
382 reg.entry(session_id.to_string())
383 .or_insert_with(|| Arc::new(Mutex::new(SessionOptimizer::new(config.clone()))))
384 .clone()
385}
386
387pub fn optimize_response(request: &ResponseOptimizationRequest) -> OptimizationDecision {
389 let config = ResponseOptimizerConfig {
390 enabled: true,
391 ..Default::default()
392 };
393 let optimizer = get_or_create(&request.context.session_id, &config);
394 let mut optimizer = optimizer
395 .lock()
396 .unwrap_or_else(std::sync::PoisonError::into_inner);
397 let cache_key = compute_cache_key("ocla-response", None, &[&request.response_ref]);
398
399 let decision = if optimizer.try_cache_hit(cache_key).is_some() {
400 optimizer.cache_hit_decision(
401 cache_key,
402 request
403 .original_tokens
404 .saturating_sub(request.target_tokens),
405 )
406 } else {
407 optimizer.record_response(
408 cache_key,
409 &request.response_ref,
410 request.target_tokens.min(request.original_tokens),
411 )
412 };
413
414 let delivered_tokens = if decision.cache_hit {
415 0
416 } else {
417 request.target_tokens.min(request.original_tokens)
418 };
419 record_response_measurement(request, delivered_tokens);
420 decision
421}
422
423fn record_response_measurement(request: &ResponseOptimizationRequest, delivered_tokens: u64) {
424 let ledger_disabled = std::env::var("LEAN_CTX_SAVINGS_LEDGER").is_ok_and(|value| {
425 matches!(
426 value.trim().to_ascii_lowercase().as_str(),
427 "off" | "0" | "false" | "no"
428 )
429 });
430 if request.original_tokens <= delivered_tokens || ledger_disabled {
431 return;
432 }
433 let Some(path) = savings_ledger::store::default_path() else {
434 return;
435 };
436
437 let quote = crate::core::gain::model_pricing::ModelPricing::load().quote(None);
438 let saved_tokens = request.original_tokens - delivered_tokens;
439 let event = SavingsEvent {
440 ts: chrono::Utc::now().to_rfc3339(),
441 tool: "proxy_response_optimizer".into(),
442 mechanism: savings_ledger::MECHANISM_COMPRESSION.into(),
443 model_id: quote.model_key.clone(),
444 tokenizer: crate::core::tokens::detect_tokenizer("e.model_key).to_string(),
445 baseline_tokens: request.original_tokens,
446 actual_tokens: delivered_tokens,
447 saved_tokens,
448 bounce_adjustment: 0,
449 unit_price_per_m_usd: quote.cost.input_per_m,
450 saved_usd: saved_tokens as f64 * quote.cost.input_per_m / 1_000_000.0,
451 repo_hash: String::new(),
452 agent_id: request.context.agent_id.clone(),
453 prev_hash: String::new(),
454 entry_hash: String::new(),
455 version: env!("CARGO_PKG_VERSION").into(),
456 intent_tag: None,
457 outcome: None,
458 model_original: None,
459 model_routed: None,
460 routing_savings: None,
461 response_original_tokens: Some(request.original_tokens),
462 response_delivered_tokens: Some(delivered_tokens),
463 agent_chain_id: None,
464 chain_depth: None,
465 measurement_method: Some(savings_ledger::event::MeasurementMethod::DirectCount),
466 evidence_class: Some(savings_ledger::event::EvidenceClass::Measured),
467 confidence: Some(1.0),
468 request_id: None,
469 session_id: None,
470 trace_id: None,
471 quality_signal: None,
472 attribution_group: None,
473 attribution_id: Some(request.response_ref.clone()),
474 baseline_ref: None,
475 price_version: None,
476 customer_approval: None,
477 settlement_status: None,
478 is_first_inject: None,
479 cache_read_per_m_usd: Some(quote.cost.cache_read_per_m),
480 cache_write_per_m_usd: Some(quote.cost.cache_write_per_m),
481 governor: None,
482 solution_decision: None,
483 loc_added: None,
484 loc_removed: None,
485 path: None,
486 lines_added: None,
487 lines_removed: None,
488 net: None,
489 };
490 let _ = savings_ledger::store::append(&path, event);
491}
492
493pub fn remove_session(session_id: &str) {
495 let mut reg = registry()
496 .lock()
497 .unwrap_or_else(std::sync::PoisonError::into_inner);
498 reg.remove(session_id);
499}
500
501pub fn global_stats() -> OptimizerStats {
503 let reg = registry()
504 .lock()
505 .unwrap_or_else(std::sync::PoisonError::into_inner);
506 let mut total = OptimizerStats::default();
507 for opt in reg.values() {
508 let guard = opt
509 .lock()
510 .unwrap_or_else(std::sync::PoisonError::into_inner);
511 total.cache_hits += guard.stats.cache_hits;
512 total.cache_misses += guard.stats.cache_misses;
513 total.dedup_detections += guard.stats.dedup_detections;
514 total.total_tokens_saved += guard.stats.total_tokens_saved;
515 }
516 total
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 fn default_config() -> ResponseOptimizerConfig {
524 ResponseOptimizerConfig {
525 enabled: true,
526 ..Default::default()
527 }
528 }
529
530 #[test]
533 fn cache_stores_and_retrieves() {
534 let mut cache = ResponseCache::new(8, Duration::from_mins(1));
535 cache.put(42, "hello world".to_string(), 5);
536 assert_eq!(cache.get(42), Some("hello world"));
537 }
538
539 #[test]
540 fn cache_miss_returns_none() {
541 let mut cache = ResponseCache::new(8, Duration::from_mins(1));
542 assert_eq!(cache.get(99), None);
543 }
544
545 #[test]
546 fn cache_respects_capacity() {
547 let mut cache = ResponseCache::new(3, Duration::from_mins(1));
548 cache.put(1, "a".into(), 1);
549 cache.put(2, "b".into(), 1);
550 cache.put(3, "c".into(), 1);
551 cache.put(4, "d".into(), 1);
552 assert_eq!(cache.get(1), None);
554 assert_eq!(cache.get(2), Some("b"));
555 assert_eq!(cache.get(4), Some("d"));
556 assert_eq!(cache.len(), 3);
557 }
558
559 #[test]
560 fn cache_updates_existing_key() {
561 let mut cache = ResponseCache::new(8, Duration::from_mins(1));
562 cache.put(1, "old".into(), 5);
563 cache.put(1, "new".into(), 5);
564 assert_eq!(cache.get(1), Some("new"));
565 assert_eq!(cache.len(), 1);
566 }
567
568 #[test]
571 fn dedup_detects_repeated_fingerprint() {
572 let mut dedup = DedupTracker::new(8);
573 assert!(!dedup.record(100), "first occurrence");
574 assert!(!dedup.record(200), "different fingerprint");
575 assert!(dedup.record(100), "repeated");
576 }
577
578 #[test]
579 fn dedup_window_evicts_old_entries() {
580 let mut dedup = DedupTracker::new(3);
581 dedup.record(1);
582 dedup.record(2);
583 dedup.record(3);
584 dedup.record(4);
586 assert!(!dedup.record(1), "1 was evicted from window");
587 assert!(dedup.record(3), "3 still in window");
589 assert!(!dedup.record(2), "2 was evicted when 1 was added");
590 }
591
592 #[test]
595 fn cache_key_is_deterministic() {
596 let k1 = compute_cache_key("gpt-4o", Some("sys"), &["hello", "world"]);
597 let k2 = compute_cache_key("gpt-4o", Some("sys"), &["hello", "world"]);
598 assert_eq!(k1, k2);
599 }
600
601 #[test]
602 fn cache_key_differs_for_different_inputs() {
603 let k1 = compute_cache_key("gpt-4o", Some("sys"), &["hello"]);
604 let k2 = compute_cache_key("gpt-4o", Some("sys"), &["world"]);
605 assert_ne!(k1, k2);
606
607 let k3 = compute_cache_key("gpt-4o", None, &["hello"]);
608 let k4 = compute_cache_key("claude-sonnet-4", None, &["hello"]);
609 assert_ne!(k3, k4);
610 }
611
612 #[test]
613 fn cache_key_order_matters() {
614 let k1 = compute_cache_key("m", None, &["a", "b"]);
615 let k2 = compute_cache_key("m", None, &["b", "a"]);
616 assert_ne!(k1, k2, "message order must affect key");
617 }
618
619 #[test]
622 fn fingerprint_uses_prefix() {
623 let short = "hello";
624 let long = format!("{}{}", "x".repeat(200), "DIFFERENT_TAIL");
625 let long2 = format!("{}{}", "x".repeat(200), "OTHER_TAIL");
626 assert_eq!(fingerprint_response(&long), fingerprint_response(&long2));
628 assert_ne!(fingerprint_response(short), fingerprint_response(&long));
630 }
631
632 #[test]
635 fn session_optimizer_cache_flow() {
636 let mut opt = SessionOptimizer::new(default_config());
637 let key = compute_cache_key("gpt-4o", None, &["what is rust?"]);
638
639 assert!(opt.try_cache_hit(key).is_none());
641 assert_eq!(opt.stats.cache_misses, 1);
642
643 let decision = opt.record_response(key, "Rust is a systems programming language.", 12);
645 assert!(!decision.cache_hit);
646 assert!(!decision.is_duplicate);
647
648 let hit = opt.try_cache_hit(key);
650 assert_eq!(hit, Some("Rust is a systems programming language."));
651 assert_eq!(opt.stats.cache_hits, 1);
652 }
653
654 #[test]
655 fn session_optimizer_dedup_flow() {
656 let mut opt = SessionOptimizer::new(default_config());
657 let key1 = 100;
658 let key2 = 200;
659
660 let response = "Rust is a systems programming language.";
662 let d1 = opt.record_response(key1, response, 12);
663 assert!(!d1.is_duplicate);
664
665 let d2 = opt.record_response(key2, response, 12);
666 assert!(d2.is_duplicate);
667 assert_eq!(d2.source, OptimizationSource::Dedup);
668 assert_eq!(opt.stats.dedup_detections, 1);
669 }
670
671 #[test]
672 fn disabled_optimizer_is_noop() {
673 let config = ResponseOptimizerConfig {
674 enabled: true,
675 cache_enabled: false,
676 dedup_enabled: false,
677 ..Default::default()
678 };
679 let mut opt = SessionOptimizer::new(config);
680 let key = 42;
681
682 assert!(opt.try_cache_hit(key).is_none());
683 let d = opt.record_response(key, "response", 10);
684 assert!(!d.is_duplicate);
685 assert!(opt.cache.is_empty());
687 }
688
689 #[test]
690 fn global_registry_creates_and_retrieves() {
691 let config = default_config();
692 let opt1 = get_or_create("session-test-1", &config);
693 let opt2 = get_or_create("session-test-1", &config);
694 assert!(Arc::ptr_eq(&opt1, &opt2));
696
697 let opt3 = get_or_create("session-test-2", &config);
698 assert!(!Arc::ptr_eq(&opt1, &opt3));
699
700 remove_session("session-test-1");
702 remove_session("session-test-2");
703 }
704
705 #[test]
708 fn optimizer_decisions_are_deterministic() {
709 let mut opt = SessionOptimizer::new(default_config());
710 let key = compute_cache_key("m", None, &["q"]);
711 opt.record_response(key, "answer", 5);
712
713 let h1 = opt.try_cache_hit(key).map(str::to_string);
715 let h2 = opt.try_cache_hit(key).map(str::to_string);
716 assert_eq!(h1, h2);
717 }
718
719 #[tokio::test]
720 async fn ocla_registry_path_measures_response_tokens() {
721 let _isolated = crate::core::data_dir::isolated_data_dir();
722 let registry = crate::core::ocla::registry::OclaRegistry::with_builtins();
723 let request = ResponseOptimizationRequest {
724 context: crate::core::ocla::types::OclaRequestContext {
725 request_id: "response-optimizer-test".into(),
726 session_id: "response-optimizer-test".into(),
727 agent_id: "agent-test".into(),
728 content_ref: "response:test".into(),
729 tenant_id: None,
730 trace_id: "tr-unit".into(),
731 task_id: None,
732 parent_task_id: None,
733 },
734 response_ref: "blake3:response-optimizer-test".into(),
735 original_tokens: 1_000,
736 target_tokens: 400,
737 };
738
739 let result = registry
740 .response_optimizer
741 .optimize_response(request)
742 .await
743 .expect("registry response optimizer must succeed");
744 assert_eq!(result.delivered_tokens, 400);
745
746 let event = savings_ledger::all_events()
747 .into_iter()
748 .find(|event| event.tool == "proxy_response_optimizer")
749 .expect("response optimization must create a ledger event");
750 assert_eq!(event.response_original_tokens, Some(1_000));
751 assert_eq!(event.response_delivered_tokens, Some(400));
752 }
753}