1#![allow(dead_code)]
2use std::collections::HashMap;
22use std::sync::{Arc, Mutex};
23use std::time::Instant;
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27use tracing::info;
28
29#[derive(Debug, Error)]
33pub enum CostOptimizerError {
34 #[error("internal lock poisoned")]
36 LockPoisoned,
37 #[error("invalid configuration: {0}")]
39 InvalidConfig(String),
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CostOptimizerConfig {
47 pub auto_optimize: bool,
49 pub window_size: usize,
51 pub cache_candidate_threshold: f64,
54 pub simple_task_token_threshold: usize,
58 pub model_tiers: Vec<ModelTierEntry>,
60}
61
62impl Default for CostOptimizerConfig {
63 fn default() -> Self {
64 Self {
65 auto_optimize: false,
66 window_size: 200,
67 cache_candidate_threshold: 0.70,
68 simple_task_token_threshold: 512,
69 model_tiers: vec![
70 ModelTierEntry {
71 model: "gpt-4o".to_string(),
72 cost_per_1k_tokens: 0.005,
73 tier: ModelTier::Expensive,
74 },
75 ModelTierEntry {
76 model: "gpt-4o-mini".to_string(),
77 cost_per_1k_tokens: 0.00015,
78 tier: ModelTier::Cheap,
79 },
80 ModelTierEntry {
81 model: "claude-3-5-sonnet".to_string(),
82 cost_per_1k_tokens: 0.003,
83 tier: ModelTier::Expensive,
84 },
85 ModelTierEntry {
86 model: "claude-3-haiku".to_string(),
87 cost_per_1k_tokens: 0.00025,
88 tier: ModelTier::Cheap,
89 },
90 ],
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub enum ModelTier {
98 Expensive,
100 Cheap,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ModelTierEntry {
107 pub model: String,
109 pub cost_per_1k_tokens: f64,
111 pub tier: ModelTier,
113}
114
115#[derive(Debug, Clone)]
119pub struct CostObservation {
120 pub intent: String,
122 pub model: String,
124 pub tokens_approx: usize,
126 pub cost_usd: f64,
128 pub response_fingerprint: String,
130 pub recorded_at: Instant,
132}
133
134fn compute_fingerprint(response: &str) -> String {
135 response
136 .chars()
137 .take(32)
138 .collect::<String>()
139 .to_lowercase()
140 .trim()
141 .to_string()
142}
143
144fn derive_intent(prompt: &str) -> String {
145 prompt.chars().take(64).collect::<String>().to_lowercase()
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub enum SuggestionKind {
153 EnableCaching,
155 DowngradeModel,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct OptimizationSuggestion {
162 pub intent: String,
164 pub kind: SuggestionKind,
166 pub description: String,
168 pub estimated_monthly_savings_usd: f64,
170 pub current_model: Option<String>,
172 pub suggested_model: Option<String>,
174 pub auto_applied: bool,
176}
177
178#[derive(Debug, Clone)]
182pub struct AutoApplyAction {
183 pub suggestion: OptimizationSuggestion,
185 pub applied_at: Instant,
187}
188
189#[derive(Debug)]
192struct IntentWindow {
193 observations: std::collections::VecDeque<CostObservation>,
194 max_size: usize,
195}
196
197impl IntentWindow {
198 fn new(max_size: usize) -> Self {
199 Self {
200 observations: std::collections::VecDeque::new(),
201 max_size: max_size.max(1),
202 }
203 }
204
205 fn push(&mut self, obs: CostObservation) {
206 if self.observations.len() >= self.max_size {
207 self.observations.pop_front();
208 }
209 self.observations.push_back(obs);
210 }
211
212 fn len(&self) -> usize {
213 self.observations.len()
214 }
215
216 fn fingerprint_collision_rate(&self) -> f64 {
218 if self.observations.is_empty() {
219 return 0.0;
220 }
221 let mut counts: HashMap<&str, usize> = HashMap::new();
222 for obs in &self.observations {
223 *counts.entry(obs.response_fingerprint.as_str()).or_insert(0) += 1;
224 }
225 let max_count = counts.values().copied().max().unwrap_or(0);
226 max_count as f64 / self.observations.len() as f64
227 }
228
229 fn latest_model(&self) -> Option<&str> {
231 self.observations.back().map(|o| o.model.as_str())
232 }
233
234 fn avg_tokens(&self) -> f64 {
236 if self.observations.is_empty() {
237 return 0.0;
238 }
239 let sum: usize = self.observations.iter().map(|o| o.tokens_approx).sum();
240 sum as f64 / self.observations.len() as f64
241 }
242
243 fn avg_cost(&self) -> f64 {
245 if self.observations.is_empty() {
246 return 0.0;
247 }
248 let sum: f64 = self.observations.iter().map(|o| o.cost_usd).sum();
249 sum / self.observations.len() as f64
250 }
251
252 fn estimated_calls_per_month(&self) -> f64 {
254 if self.observations.len() < 2 {
255 return 0.0;
256 }
257 let first = self.observations.front().map(|o| o.recorded_at);
258 let last = self.observations.back().map(|o| o.recorded_at);
259 if let (Some(first), Some(last)) = (first, last) {
260 let elapsed = last.duration_since(first);
261 if elapsed.as_secs() == 0 {
262 return 0.0;
263 }
264 let calls_per_sec =
265 (self.observations.len() - 1) as f64 / elapsed.as_secs_f64();
266 calls_per_sec * 86_400.0 * 30.0
267 } else {
268 0.0
269 }
270 }
271}
272
273pub struct CostOptimizer {
277 config: CostOptimizerConfig,
278 inner: Mutex<CostOptimizerInner>,
279}
280
281#[derive(Debug)]
282struct CostOptimizerInner {
283 windows: HashMap<String, IntentWindow>, auto_applied: Vec<AutoApplyAction>,
285 overridden_models: HashMap<String, String>, }
287
288impl CostOptimizer {
289 pub fn new(config: CostOptimizerConfig) -> Result<Arc<Self>, CostOptimizerError> {
295 if config.window_size == 0 {
296 return Err(CostOptimizerError::InvalidConfig(
297 "window_size must be > 0".to_string(),
298 ));
299 }
300 if !(0.0..=1.0).contains(&config.cache_candidate_threshold) {
301 return Err(CostOptimizerError::InvalidConfig(
302 "cache_candidate_threshold must be in [0, 1]".to_string(),
303 ));
304 }
305 Ok(Arc::new(Self {
306 config,
307 inner: Mutex::new(CostOptimizerInner {
308 windows: HashMap::new(),
309 auto_applied: Vec::new(),
310 overridden_models: HashMap::new(),
311 }),
312 }))
313 }
314
315 pub fn record(
317 &self,
318 prompt: &str,
319 model: &str,
320 response: &str,
321 ) -> Result<(), CostOptimizerError> {
322 let intent = derive_intent(prompt);
323 let tokens_approx = (prompt.len() + response.len()) / 4;
324 let cost_usd = self.compute_cost(model, tokens_approx);
325 let obs = CostObservation {
326 intent: intent.clone(),
327 model: model.to_string(),
328 tokens_approx,
329 cost_usd,
330 response_fingerprint: compute_fingerprint(response),
331 recorded_at: Instant::now(),
332 };
333
334 let mut inner = self.inner.lock().map_err(|_| CostOptimizerError::LockPoisoned)?;
335 let window = inner
336 .windows
337 .entry(intent)
338 .or_insert_with(|| IntentWindow::new(self.config.window_size));
339 window.push(obs);
340 Ok(())
341 }
342
343 pub fn suggestions(&self) -> Result<Vec<OptimizationSuggestion>, CostOptimizerError> {
345 let inner = self.inner.lock().map_err(|_| CostOptimizerError::LockPoisoned)?;
346 let mut suggestions = Vec::new();
347
348 for (intent, window) in &inner.windows {
349 if window.len() < 5 {
351 continue;
352 }
353
354 let collision_rate = window.fingerprint_collision_rate();
356 if collision_rate >= self.config.cache_candidate_threshold {
357 let avg_cost = window.avg_cost();
358 let calls_per_month = window.estimated_calls_per_month();
359 let savings = avg_cost * calls_per_month * collision_rate;
360 suggestions.push(OptimizationSuggestion {
361 intent: intent.clone(),
362 kind: SuggestionKind::EnableCaching,
363 description: format!(
364 "Prompt '{intent:.40}…' produces identical responses {:.0}% of the time. \
365 Enable result caching to save ~${savings:.2}/month.",
366 collision_rate * 100.0
367 ),
368 estimated_monthly_savings_usd: savings,
369 current_model: window.latest_model().map(str::to_string),
370 suggested_model: None,
371 auto_applied: false,
372 });
373 }
374
375 if let Some(model) = window.latest_model() {
377 if let Some(tier_entry) = self.config.model_tiers.iter().find(|t| t.model == model) {
378 if tier_entry.tier == ModelTier::Expensive {
379 let avg_tokens = window.avg_tokens();
380 if avg_tokens < self.config.simple_task_token_threshold as f64 {
381 if let Some(cheap) = self
383 .config
384 .model_tiers
385 .iter()
386 .filter(|t| t.tier == ModelTier::Cheap)
387 .min_by(|a, b| {
388 a.cost_per_1k_tokens
389 .partial_cmp(&b.cost_per_1k_tokens)
390 .unwrap_or(std::cmp::Ordering::Equal)
391 })
392 {
393 let current_cost_per_call =
394 avg_tokens / 1000.0 * tier_entry.cost_per_1k_tokens;
395 let cheap_cost_per_call =
396 avg_tokens / 1000.0 * cheap.cost_per_1k_tokens;
397 let calls_per_month = window.estimated_calls_per_month();
398 let savings = (current_cost_per_call - cheap_cost_per_call)
399 * calls_per_month;
400 suggestions.push(OptimizationSuggestion {
401 intent: intent.clone(),
402 kind: SuggestionKind::DowngradeModel,
403 description: format!(
404 "Simple task (avg {avg_tokens:.0} tokens) is using expensive model \
405 '{model}'. Switch to '{}' to save ~${savings:.2}/month.",
406 cheap.model
407 ),
408 estimated_monthly_savings_usd: savings,
409 current_model: Some(model.to_string()),
410 suggested_model: Some(cheap.model.clone()),
411 auto_applied: false,
412 });
413 }
414 }
415 }
416 }
417 }
418 }
419
420 suggestions.sort_by(|a, b| {
422 b.estimated_monthly_savings_usd
423 .partial_cmp(&a.estimated_monthly_savings_usd)
424 .unwrap_or(std::cmp::Ordering::Equal)
425 });
426
427 Ok(suggestions)
428 }
429
430 pub fn auto_apply(&self) -> Result<Vec<AutoApplyAction>, CostOptimizerError> {
434 if !self.config.auto_optimize {
435 return Ok(vec![]);
436 }
437 let pending = self.suggestions()?;
438 let mut inner = self.inner.lock().map_err(|_| CostOptimizerError::LockPoisoned)?;
439 let mut applied = Vec::new();
440
441 for mut suggestion in pending {
442 match suggestion.kind {
443 SuggestionKind::EnableCaching => {
444 info!(
446 intent = %suggestion.intent,
447 savings_usd = suggestion.estimated_monthly_savings_usd,
448 "auto-optimizer: enabling caching for intent"
449 );
450 suggestion.auto_applied = true;
451 let action = AutoApplyAction {
452 suggestion: suggestion.clone(),
453 applied_at: Instant::now(),
454 };
455 inner.auto_applied.push(action.clone());
456 applied.push(action);
457 }
458 SuggestionKind::DowngradeModel => {
459 if let Some(ref cheap_model) = suggestion.suggested_model {
460 info!(
461 intent = %suggestion.intent,
462 from = suggestion.current_model.as_deref().unwrap_or("?"),
463 to = %cheap_model,
464 savings_usd = suggestion.estimated_monthly_savings_usd,
465 "auto-optimizer: overriding model for intent"
466 );
467 inner
468 .overridden_models
469 .insert(suggestion.intent.clone(), cheap_model.clone());
470 suggestion.auto_applied = true;
471 let action = AutoApplyAction {
472 suggestion: suggestion.clone(),
473 applied_at: Instant::now(),
474 };
475 inner.auto_applied.push(action.clone());
476 applied.push(action);
477 }
478 }
479 }
480 }
481
482 Ok(applied)
483 }
484
485 #[must_use]
487 pub fn model_override(&self, prompt: &str) -> Option<String> {
488 let intent = derive_intent(prompt);
489 let Ok(inner) = self.inner.lock() else {
490 return None;
491 };
492 inner.overridden_models.get(&intent).cloned()
493 }
494
495 pub fn total_cost_usd(&self) -> Result<f64, CostOptimizerError> {
497 let inner = self.inner.lock().map_err(|_| CostOptimizerError::LockPoisoned)?;
498 let total: f64 = inner
499 .windows
500 .values()
501 .flat_map(|w| w.observations.iter().map(|o| o.cost_usd))
502 .sum();
503 Ok(total)
504 }
505
506 pub fn intent_count(&self) -> Result<usize, CostOptimizerError> {
508 let inner = self.inner.lock().map_err(|_| CostOptimizerError::LockPoisoned)?;
509 Ok(inner.windows.len())
510 }
511
512 fn compute_cost(&self, model: &str, tokens_approx: usize) -> f64 {
513 let rate = self
514 .config
515 .model_tiers
516 .iter()
517 .find(|t| t.model == model)
518 .map(|t| t.cost_per_1k_tokens)
519 .unwrap_or(0.002); tokens_approx as f64 / 1000.0 * rate
521 }
522}
523
524#[cfg(test)]
527mod tests {
528 use super::*;
529
530 fn make_optimizer(auto: bool) -> Arc<CostOptimizer> {
531 CostOptimizer::new(CostOptimizerConfig {
532 auto_optimize: auto,
533 window_size: 20,
534 cache_candidate_threshold: 0.6,
535 simple_task_token_threshold: 200,
536 ..Default::default()
537 })
538 .expect("optimizer should construct")
539 }
540
541 #[test]
542 fn records_observations() {
543 let opt = make_optimizer(false);
544 opt.record("Tell me a joke", "gpt-4o", "Why did the chicken...").unwrap();
545 opt.record("Tell me a joke", "gpt-4o", "Why did the chicken...").unwrap();
546 assert_eq!(opt.intent_count().unwrap(), 1);
547 assert!(opt.total_cost_usd().unwrap() > 0.0);
548 }
549
550 #[test]
551 fn detects_cache_candidate() {
552 let opt = make_optimizer(false);
553 for _ in 0..8 {
554 opt.record("Repeat after me: hello", "gpt-4o", "hello").unwrap();
555 }
556 let suggestions = opt.suggestions().unwrap();
557 let cache_sugg = suggestions
558 .iter()
559 .find(|s| s.kind == SuggestionKind::EnableCaching);
560 assert!(cache_sugg.is_some(), "should detect cache candidate");
561 }
562
563 #[test]
564 fn detects_model_downgrade() {
565 let opt = make_optimizer(false);
566 for _ in 0..8 {
568 opt.record("Hi", "gpt-4o", "Hello").unwrap();
569 }
570 let suggestions = opt.suggestions().unwrap();
571 let downgrade = suggestions
572 .iter()
573 .find(|s| s.kind == SuggestionKind::DowngradeModel);
574 assert!(downgrade.is_some(), "should suggest model downgrade");
575 assert!(downgrade.unwrap().suggested_model.is_some());
576 }
577
578 #[test]
579 fn auto_apply_writes_override() {
580 let opt = make_optimizer(true);
581 for _ in 0..8 {
582 opt.record("Hi", "gpt-4o", "Hello").unwrap();
583 }
584 let applied = opt.auto_apply().unwrap();
585 assert!(!applied.is_empty());
586 let has_override = opt.model_override("Hi").is_some();
588 assert!(has_override, "model override should be set after auto-apply");
589 }
590
591 #[test]
592 fn invalid_config_rejected() {
593 let result = CostOptimizer::new(CostOptimizerConfig {
594 window_size: 0,
595 ..Default::default()
596 });
597 assert!(result.is_err());
598 }
599}