1use async_trait::async_trait;
7use chrono::{DateTime, Datelike, Utc};
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11use tokio::sync::RwLock;
12use tracing::{debug, info, trace, warn};
13use uuid::Uuid;
14
15use super::stats::{
16 calculate_percentiles, is_outlier_zscore, mean, percentage_change, std_dev, CircularBuffer,
17 ExponentialMovingAverage,
18};
19use super::traits::{
20 Analyzer, AnalyzerConfig, AnalyzerError, AnalyzerResult, AnalyzerState,
21};
22use super::types::{
23 Action, ActionType, Alert, AlertStatus, AnalysisReport, AnalyzerEvent, AnalyzerStats,
24 Confidence, Evidence, EvidenceType, Impact, ImpactMetric, Insight, InsightCategory,
25 Priority, Recommendation, ReportSummary, RiskLevel, Severity, Threshold, ThresholdOperator,
26};
27
28#[derive(Debug, Clone)]
30pub struct ModelPricing {
31 pub prompt_cost_per_1k: f64,
32 pub completion_cost_per_1k: f64,
33}
34
35impl ModelPricing {
36 pub fn for_model(model: &str) -> Self {
38 let model_lower = model.to_lowercase();
39
40 if model_lower.contains("gpt-4") {
41 Self {
42 prompt_cost_per_1k: 0.03,
43 completion_cost_per_1k: 0.06,
44 }
45 } else if model_lower.contains("gpt-3.5") {
46 Self {
47 prompt_cost_per_1k: 0.001,
48 completion_cost_per_1k: 0.002,
49 }
50 } else if model_lower.contains("claude-3-opus") || model_lower.contains("claude-opus") {
51 Self {
52 prompt_cost_per_1k: 0.015,
53 completion_cost_per_1k: 0.075,
54 }
55 } else if model_lower.contains("claude-3-sonnet") || model_lower.contains("claude-sonnet") {
56 Self {
57 prompt_cost_per_1k: 0.003,
58 completion_cost_per_1k: 0.015,
59 }
60 } else {
61 Self {
63 prompt_cost_per_1k: 0.001,
64 completion_cost_per_1k: 0.002,
65 }
66 }
67 }
68
69 pub fn calculate_cost(&self, prompt_tokens: u32, completion_tokens: u32) -> f64 {
71 let prompt_cost = (prompt_tokens as f64 / 1000.0) * self.prompt_cost_per_1k;
72 let completion_cost = (completion_tokens as f64 / 1000.0) * self.completion_cost_per_1k;
73 prompt_cost + completion_cost
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct CostAnalyzerConfig {
80 pub base: AnalyzerConfig,
82
83 pub daily_budget_usd: f64,
85
86 pub monthly_budget_usd: f64,
88
89 pub budget_warning_threshold_pct: f64,
91
92 pub budget_critical_threshold_pct: f64,
94
95 pub cost_spike_threshold_pct: f64,
97
98 pub cost_window_size: usize,
100
101 pub enable_optimization_recommendations: bool,
103
104 pub min_cost_difference_for_recommendation: f64,
106}
107
108impl Default for CostAnalyzerConfig {
109 fn default() -> Self {
110 Self {
111 base: AnalyzerConfig::default(),
112 daily_budget_usd: 100.0,
113 monthly_budget_usd: 3000.0,
114 budget_warning_threshold_pct: 80.0,
115 budget_critical_threshold_pct: 90.0,
116 cost_spike_threshold_pct: 50.0,
117 cost_window_size: 1000,
118 enable_optimization_recommendations: true,
119 min_cost_difference_for_recommendation: 0.01,
120 }
121 }
122}
123
124#[derive(Debug, Clone)]
126struct ModelCostMetrics {
127 total_cost: f64,
128 request_count: u64,
129 prompt_tokens: u64,
130 completion_tokens: u64,
131 cost_samples: CircularBuffer<f64>,
132}
133
134impl ModelCostMetrics {
135 fn new(window_size: usize) -> Self {
136 Self {
137 total_cost: 0.0,
138 request_count: 0,
139 prompt_tokens: 0,
140 completion_tokens: 0,
141 cost_samples: CircularBuffer::new(window_size),
142 }
143 }
144
145 fn record_request(&mut self, cost: f64, prompt_tokens: u32, completion_tokens: u32) {
146 self.total_cost += cost;
147 self.request_count += 1;
148 self.prompt_tokens += prompt_tokens as u64;
149 self.completion_tokens += completion_tokens as u64;
150 self.cost_samples.push(cost);
151 }
152
153 fn average_cost_per_request(&self) -> f64 {
154 if self.request_count == 0 {
155 return 0.0;
156 }
157 self.total_cost / self.request_count as f64
158 }
159}
160
161#[derive(Debug, Clone)]
163struct CostMetrics {
164 model_costs: HashMap<String, ModelCostMetrics>,
166
167 total_cost: f64,
169
170 daily_cost: f64,
172 daily_reset_time: DateTime<Utc>,
173
174 monthly_cost: f64,
176 monthly_reset_time: DateTime<Utc>,
177
178 cost_per_request: CircularBuffer<f64>,
180
181 request_times: CircularBuffer<DateTime<Utc>>,
183
184 cost_ema: ExponentialMovingAverage,
186
187 baseline_daily_cost: Option<f64>,
189 baseline_cost_per_request: Option<f64>,
190}
191
192impl CostMetrics {
193 fn new(window_size: usize) -> Self {
194 let now = Utc::now();
195 Self {
196 model_costs: HashMap::new(),
197 total_cost: 0.0,
198 daily_cost: 0.0,
199 daily_reset_time: now,
200 monthly_cost: 0.0,
201 monthly_reset_time: now,
202 cost_per_request: CircularBuffer::new(window_size),
203 request_times: CircularBuffer::new(window_size),
204 cost_ema: ExponentialMovingAverage::new(0.2),
205 baseline_daily_cost: None,
206 baseline_cost_per_request: None,
207 }
208 }
209
210 fn check_and_reset_periods(&mut self) {
211 let now = Utc::now();
212
213 if now.date_naive() != self.daily_reset_time.date_naive() {
215 self.baseline_daily_cost = Some(self.daily_cost);
216 self.daily_cost = 0.0;
217 self.daily_reset_time = now;
218 }
219
220 if now.date_naive().month() != self.monthly_reset_time.date_naive().month()
222 || now.date_naive().year() != self.monthly_reset_time.date_naive().year() {
223 self.monthly_cost = 0.0;
224 self.monthly_reset_time = now;
225 }
226 }
227
228 fn record_cost(
229 &mut self,
230 model: &str,
231 cost: f64,
232 prompt_tokens: u32,
233 completion_tokens: u32,
234 timestamp: DateTime<Utc>,
235 ) {
236 self.check_and_reset_periods();
237
238 self.total_cost += cost;
240 self.daily_cost += cost;
241 self.monthly_cost += cost;
242 self.cost_per_request.push(cost);
243 self.request_times.push(timestamp);
244 self.cost_ema.update(cost);
245
246 let model_metrics = self.model_costs
248 .entry(model.to_string())
249 .or_insert_with(|| ModelCostMetrics::new(100));
250 model_metrics.record_request(cost, prompt_tokens, completion_tokens);
251 }
252
253 fn daily_budget_utilization(&self, daily_budget: f64) -> f64 {
254 if daily_budget == 0.0 {
255 return 0.0;
256 }
257 (self.daily_cost / daily_budget) * 100.0
258 }
259
260 fn monthly_budget_utilization(&self, monthly_budget: f64) -> f64 {
261 if monthly_budget == 0.0 {
262 return 0.0;
263 }
264 (self.monthly_cost / monthly_budget) * 100.0
265 }
266
267 fn average_cost_per_request(&self) -> f64 {
268 let costs: Vec<f64> = self.cost_per_request.to_vec();
269 if costs.is_empty() {
270 return 0.0;
271 }
272 mean(&costs)
273 }
274
275 fn is_cost_trending_up(&self) -> bool {
276 if let Some(ema) = self.cost_ema.value() {
277 let avg = self.average_cost_per_request();
278 if avg > 0.0 {
279 return ema > avg * 1.1; }
281 }
282 false
283 }
284}
285
286pub struct CostAnalyzer {
288 config: CostAnalyzerConfig,
289 state: AnalyzerState,
290 metrics: Arc<RwLock<CostMetrics>>,
291 stats: Arc<RwLock<AnalyzerStats>>,
292 start_time: Instant,
293 last_report_time: Arc<RwLock<Instant>>,
294}
295
296impl CostAnalyzer {
297 pub fn new(config: CostAnalyzerConfig) -> Self {
299 let window_size = config.cost_window_size;
300
301 Self {
302 config,
303 state: AnalyzerState::Initialized,
304 metrics: Arc::new(RwLock::new(CostMetrics::new(window_size))),
305 stats: Arc::new(RwLock::new(AnalyzerStats {
306 analyzer: "cost".to_string(),
307 ..Default::default()
308 })),
309 start_time: Instant::now(),
310 last_report_time: Arc::new(RwLock::new(Instant::now())),
311 }
312 }
313
314 pub fn with_defaults() -> Self {
316 Self::new(CostAnalyzerConfig::default())
317 }
318
319 async fn process_request(&mut self, event: AnalyzerEvent) -> AnalyzerResult<()> {
321 if let AnalyzerEvent::Request {
322 timestamp,
323 model,
324 prompt_tokens,
325 ..
326 } = event
327 {
328 trace!(
330 "Processed request: model={}, prompt_tokens={}",
331 model,
332 prompt_tokens
333 );
334 }
335
336 Ok(())
337 }
338
339 async fn process_response(&mut self, event: AnalyzerEvent) -> AnalyzerResult<()> {
341 if let AnalyzerEvent::Response {
342 timestamp,
343 model,
344 completion_tokens,
345 total_tokens,
346 success,
347 ..
348 } = event
349 {
350 if !success {
351 return Ok(()); }
353
354 let prompt_tokens = total_tokens.saturating_sub(completion_tokens);
355 let pricing = ModelPricing::for_model(&model);
356 let cost = pricing.calculate_cost(prompt_tokens, completion_tokens);
357
358 let mut metrics = self.metrics.write().await;
359 metrics.record_cost(&model, cost, prompt_tokens, completion_tokens, timestamp);
360
361 trace!(
362 "Recorded cost: model={}, cost=${:.4}, prompt_tokens={}, completion_tokens={}",
363 model,
364 cost,
365 prompt_tokens,
366 completion_tokens
367 );
368 }
369
370 Ok(())
371 }
372
373 async fn process_cost(&mut self, event: AnalyzerEvent) -> AnalyzerResult<()> {
375 if let AnalyzerEvent::Cost {
376 timestamp,
377 model,
378 tokens,
379 cost_usd,
380 ..
381 } = event
382 {
383 let mut metrics = self.metrics.write().await;
384 metrics.record_cost(&model, cost_usd, tokens, 0, timestamp);
385
386 trace!(
387 "Recorded direct cost: model={}, cost=${:.4}",
388 model,
389 cost_usd
390 );
391 }
392
393 Ok(())
394 }
395
396 async fn analyze_metrics(&self) -> Vec<Insight> {
398 let metrics = self.metrics.read().await;
399 let mut insights = Vec::new();
400
401 let daily_utilization = metrics.daily_budget_utilization(self.config.daily_budget_usd);
403 if daily_utilization >= self.config.budget_critical_threshold_pct {
404 insights.push(self.create_budget_alert_insight(
405 daily_utilization,
406 self.config.daily_budget_usd,
407 metrics.daily_cost,
408 "daily",
409 Severity::Critical,
410 ));
411 } else if daily_utilization >= self.config.budget_warning_threshold_pct {
412 insights.push(self.create_budget_alert_insight(
413 daily_utilization,
414 self.config.daily_budget_usd,
415 metrics.daily_cost,
416 "daily",
417 Severity::Warning,
418 ));
419 }
420
421 let monthly_utilization = metrics.monthly_budget_utilization(self.config.monthly_budget_usd);
422 if monthly_utilization >= self.config.budget_critical_threshold_pct {
423 insights.push(self.create_budget_alert_insight(
424 monthly_utilization,
425 self.config.monthly_budget_usd,
426 metrics.monthly_cost,
427 "monthly",
428 Severity::Critical,
429 ));
430 } else if monthly_utilization >= self.config.budget_warning_threshold_pct {
431 insights.push(self.create_budget_alert_insight(
432 monthly_utilization,
433 self.config.monthly_budget_usd,
434 metrics.monthly_cost,
435 "monthly",
436 Severity::Warning,
437 ));
438 }
439
440 if let Some(baseline) = metrics.baseline_cost_per_request {
442 let current = metrics.average_cost_per_request();
443 if current > 0.0 {
444 let change_pct = percentage_change(baseline, current);
445 if change_pct > self.config.cost_spike_threshold_pct {
446 insights.push(self.create_cost_spike_insight(baseline, current, change_pct));
447 }
448 }
449 }
450
451 if metrics.is_cost_trending_up() {
453 insights.push(self.create_cost_trend_insight(&metrics));
454 }
455
456 let cost_samples: Vec<f64> = metrics.cost_per_request.to_vec();
458 if cost_samples.len() >= 10 {
459 let avg = mean(&cost_samples);
460 let std = std_dev(&cost_samples);
461
462 let recent_costs: Vec<f64> = cost_samples.iter().rev().take(5).copied().collect();
464 for cost in recent_costs {
465 if is_outlier_zscore(cost, avg, std, 3.0) {
466 insights.push(self.create_cost_anomaly_insight(cost, avg, std));
467 break; }
469 }
470 }
471
472 insights
473 }
474
475 fn create_budget_alert_insight(
477 &self,
478 utilization_pct: f64,
479 budget: f64,
480 current_cost: f64,
481 period: &str,
482 severity: Severity,
483 ) -> Insight {
484 let remaining = budget - current_cost;
485
486 Insight {
487 id: Uuid::new_v4().to_string(),
488 analyzer: "cost".to_string(),
489 timestamp: Utc::now(),
490 severity,
491 confidence: Confidence::new(0.99),
492 title: format!("{} Budget at {:.1}%",
493 period.chars().next().unwrap().to_uppercase().to_string() + &period[1..],
494 utilization_pct
495 ),
496 description: format!(
497 "Current {} cost is ${:.2}, which is {:.1}% of the ${:.2} budget. Remaining budget: ${:.2}. {}",
498 period,
499 current_cost,
500 utilization_pct,
501 budget,
502 remaining,
503 if severity == Severity::Critical {
504 "Immediate action required to prevent budget overrun."
505 } else {
506 "Monitor closely to avoid exceeding budget."
507 }
508 ),
509 category: InsightCategory::Cost,
510 evidence: vec![Evidence {
511 evidence_type: EvidenceType::Threshold,
512 description: format!("Budget utilization: {:.1}%", utilization_pct),
513 data: serde_json::json!({
514 "period": period,
515 "current_cost": current_cost,
516 "budget": budget,
517 "utilization_pct": utilization_pct,
518 "remaining": remaining
519 }),
520 timestamp: Utc::now(),
521 }],
522 metrics: {
523 let mut m = HashMap::new();
524 m.insert(format!("{}_cost_usd", period), current_cost);
525 m.insert(format!("{}_budget_usd", period), budget);
526 m.insert(format!("{}_utilization_pct", period), utilization_pct);
527 m.insert(format!("{}_remaining_usd", period), remaining);
528 m
529 },
530 tags: vec!["budget".to_string(), "cost".to_string(), period.to_string()],
531 }
532 }
533
534 fn create_cost_spike_insight(
536 &self,
537 baseline: f64,
538 current: f64,
539 change_pct: f64,
540 ) -> Insight {
541 Insight {
542 id: Uuid::new_v4().to_string(),
543 analyzer: "cost".to_string(),
544 timestamp: Utc::now(),
545 severity: if change_pct > 100.0 {
546 Severity::Critical
547 } else {
548 Severity::Warning
549 },
550 confidence: Confidence::new(0.85),
551 title: format!("Cost Spike Detected: +{:.1}%", change_pct),
552 description: format!(
553 "Average cost per request has increased by {:.1}% from baseline ${:.4} to current ${:.4}. This unexpected spike may indicate inefficient usage or increased model complexity.",
554 change_pct, baseline, current
555 ),
556 category: InsightCategory::Anomaly,
557 evidence: vec![Evidence {
558 evidence_type: EvidenceType::Comparison,
559 description: "Baseline vs current cost comparison".to_string(),
560 data: serde_json::json!({
561 "baseline_cost": baseline,
562 "current_cost": current,
563 "change_pct": change_pct
564 }),
565 timestamp: Utc::now(),
566 }],
567 metrics: {
568 let mut m = HashMap::new();
569 m.insert("baseline_cost_per_request".to_string(), baseline);
570 m.insert("current_cost_per_request".to_string(), current);
571 m.insert("cost_increase_pct".to_string(), change_pct);
572 m
573 },
574 tags: vec!["spike".to_string(), "anomaly".to_string(), "cost".to_string()],
575 }
576 }
577
578 fn create_cost_trend_insight(&self, metrics: &CostMetrics) -> Insight {
580 let avg = metrics.average_cost_per_request();
581 let ema = metrics.cost_ema.value().unwrap_or(avg);
582 let trend_increase = ((ema - avg) / avg) * 100.0;
583
584 Insight {
585 id: Uuid::new_v4().to_string(),
586 analyzer: "cost".to_string(),
587 timestamp: Utc::now(),
588 severity: Severity::Info,
589 confidence: Confidence::new(0.75),
590 title: "Increasing Cost Trend Detected".to_string(),
591 description: format!(
592 "Costs are trending upward. Recent requests are averaging {:.1}% higher than the baseline. Consider reviewing usage patterns and model selection.",
593 trend_increase
594 ),
595 category: InsightCategory::Pattern,
596 evidence: vec![Evidence {
597 evidence_type: EvidenceType::TimeSeries,
598 description: "Cost trend analysis".to_string(),
599 data: serde_json::json!({
600 "average_cost": avg,
601 "recent_trend": ema,
602 "trend_increase_pct": trend_increase
603 }),
604 timestamp: Utc::now(),
605 }],
606 metrics: {
607 let mut m = HashMap::new();
608 m.insert("average_cost_per_request".to_string(), avg);
609 m.insert("trend_cost_per_request".to_string(), ema);
610 m.insert("trend_increase_pct".to_string(), trend_increase);
611 m
612 },
613 tags: vec!["trend".to_string(), "cost".to_string()],
614 }
615 }
616
617 fn create_cost_anomaly_insight(&self, cost: f64, mean: f64, std_dev: f64) -> Insight {
619 let z_score = super::stats::z_score(cost, mean, std_dev);
620
621 Insight {
622 id: Uuid::new_v4().to_string(),
623 analyzer: "cost".to_string(),
624 timestamp: Utc::now(),
625 severity: Severity::Warning,
626 confidence: Confidence::new(0.90),
627 title: format!("Unusual Cost Detected: ${:.4}", cost),
628 description: format!(
629 "A request with cost ${:.4} is significantly higher than the average ${:.4} (z-score: {:.2}). This may indicate an unusually complex request or inefficient prompt design.",
630 cost, mean, z_score
631 ),
632 category: InsightCategory::Anomaly,
633 evidence: vec![Evidence {
634 evidence_type: EvidenceType::Anomaly,
635 description: format!("Cost outlier detected (z-score: {:.2})", z_score),
636 data: serde_json::json!({
637 "cost": cost,
638 "mean": mean,
639 "std_dev": std_dev,
640 "z_score": z_score
641 }),
642 timestamp: Utc::now(),
643 }],
644 metrics: {
645 let mut m = HashMap::new();
646 m.insert("anomalous_cost".to_string(), cost);
647 m.insert("mean_cost".to_string(), mean);
648 m.insert("z_score".to_string(), z_score);
649 m
650 },
651 tags: vec!["anomaly".to_string(), "outlier".to_string(), "cost".to_string()],
652 }
653 }
654
655 fn generate_recommendations(&self, insights: &[Insight], metrics: &CostMetrics) -> Vec<Recommendation> {
657 let mut recommendations = Vec::new();
658
659 for insight in insights {
661 if insight.title.contains("Budget") && insight.severity.is_urgent() {
662 recommendations.push(Recommendation {
663 id: Uuid::new_v4().to_string(),
664 analyzer: "cost".to_string(),
665 timestamp: Utc::now(),
666 priority: Priority::Urgent,
667 title: "Reduce usage to stay within budget".to_string(),
668 description: "Budget is critically low. Implement rate limiting or pause non-essential requests.".to_string(),
669 action: Action {
670 action_type: ActionType::ConfigChange,
671 instructions: "1. Enable rate limiting to control request volume\n2. Pause or defer non-critical batch jobs\n3. Review and increase budget if justified".to_string(),
672 parameters: HashMap::new(),
673 estimated_effort: Some(1.0),
674 risk_level: RiskLevel::Medium,
675 },
676 expected_impact: Impact {
677 performance: None,
678 cost: Some(ImpactMetric {
679 name: "daily_cost".to_string(),
680 current: insight.metrics.get("daily_cost_usd").copied().unwrap_or(0.0),
681 expected: insight.metrics.get("daily_budget_usd").copied().unwrap_or(0.0) * 0.9,
682 unit: "USD".to_string(),
683 improvement_pct: 10.0,
684 }),
685 quality: None,
686 description: "Prevent budget overrun".to_string(),
687 },
688 confidence: Confidence::new(0.95),
689 related_insights: vec![insight.id.clone()],
690 tags: vec!["budget".to_string(), "urgent".to_string()],
691 });
692 }
693 }
694
695 if self.config.enable_optimization_recommendations {
697 recommendations.extend(self.generate_model_optimization_recommendations(metrics));
698 }
699
700 if metrics.model_costs.values().any(|m| m.request_count > 100) {
702 recommendations.push(Recommendation {
703 id: Uuid::new_v4().to_string(),
704 analyzer: "cost".to_string(),
705 timestamp: Utc::now(),
706 priority: Priority::Medium,
707 title: "Implement caching to reduce costs".to_string(),
708 description: "High request volume detected. Implementing response caching for common queries can significantly reduce costs.".to_string(),
709 action: Action {
710 action_type: ActionType::Optimize,
711 instructions: "1. Implement semantic caching for similar prompts\n2. Cache responses for frequently requested queries\n3. Set appropriate TTL based on data freshness requirements".to_string(),
712 parameters: {
713 let mut p = HashMap::new();
714 p.insert("cache_ttl".to_string(), serde_json::json!(3600));
715 p.insert("similarity_threshold".to_string(), serde_json::json!(0.95));
716 p
717 },
718 estimated_effort: Some(8.0),
719 risk_level: RiskLevel::Low,
720 },
721 expected_impact: Impact {
722 performance: Some(ImpactMetric {
723 name: "latency".to_string(),
724 current: 1000.0,
725 expected: 50.0,
726 unit: "ms".to_string(),
727 improvement_pct: 95.0,
728 }),
729 cost: Some(ImpactMetric {
730 name: "daily_cost".to_string(),
731 current: metrics.daily_cost,
732 expected: metrics.daily_cost * 0.7,
733 unit: "USD".to_string(),
734 improvement_pct: 30.0,
735 }),
736 quality: None,
737 description: "30% cost reduction with 95% faster responses for cached queries".to_string(),
738 },
739 confidence: Confidence::new(0.80),
740 related_insights: vec![],
741 tags: vec!["optimization".to_string(), "caching".to_string(), "cost".to_string()],
742 });
743 }
744
745 recommendations
746 }
747
748 fn generate_model_optimization_recommendations(&self, metrics: &CostMetrics) -> Vec<Recommendation> {
750 let mut recommendations = Vec::new();
751
752 if let Some((expensive_model, expensive_metrics)) = metrics.model_costs
754 .iter()
755 .max_by(|a, b| a.1.average_cost_per_request().partial_cmp(&b.1.average_cost_per_request()).unwrap())
756 {
757 let expensive_avg = expensive_metrics.average_cost_per_request();
758
759 for (cheaper_model, cheaper_metrics) in &metrics.model_costs {
761 if cheaper_model == expensive_model {
762 continue;
763 }
764
765 let cheaper_avg = cheaper_metrics.average_cost_per_request();
766 let cost_diff = expensive_avg - cheaper_avg;
767
768 if cost_diff > self.config.min_cost_difference_for_recommendation {
769 let savings_pct = (cost_diff / expensive_avg) * 100.0;
770 let potential_savings = cost_diff * expensive_metrics.request_count as f64;
771
772 recommendations.push(Recommendation {
773 id: Uuid::new_v4().to_string(),
774 analyzer: "cost".to_string(),
775 timestamp: Utc::now(),
776 priority: if savings_pct > 50.0 { Priority::High } else { Priority::Medium },
777 title: format!("Consider switching from {} to {}", expensive_model, cheaper_model),
778 description: format!(
779 "Switching from {} to {} could save ${:.4} per request ({:.1}% reduction). Potential total savings: ${:.2}",
780 expensive_model, cheaper_model, cost_diff, savings_pct, potential_savings
781 ),
782 action: Action {
783 action_type: ActionType::Review,
784 instructions: format!(
785 "1. Evaluate if {} can handle your use case\n2. Run quality tests comparing {} vs {}\n3. Implement gradual rollout if quality is acceptable\n4. Monitor quality metrics closely",
786 cheaper_model, expensive_model, cheaper_model
787 ),
788 parameters: {
789 let mut p = HashMap::new();
790 p.insert("current_model".to_string(), serde_json::json!(expensive_model));
791 p.insert("recommended_model".to_string(), serde_json::json!(cheaper_model));
792 p.insert("cost_savings_per_request".to_string(), serde_json::json!(cost_diff));
793 p
794 },
795 estimated_effort: Some(16.0),
796 risk_level: RiskLevel::Medium,
797 },
798 expected_impact: Impact {
799 performance: None,
800 cost: Some(ImpactMetric {
801 name: "cost_per_request".to_string(),
802 current: expensive_avg,
803 expected: cheaper_avg,
804 unit: "USD".to_string(),
805 improvement_pct: savings_pct,
806 }),
807 quality: None,
808 description: format!("{:.1}% cost reduction (quality validation required)", savings_pct),
809 },
810 confidence: Confidence::new(0.70),
811 related_insights: vec![],
812 tags: vec!["optimization".to_string(), "model-switch".to_string(), "cost".to_string()],
813 });
814 break; }
816 }
817 }
818
819 recommendations
820 }
821}
822
823#[async_trait]
824impl Analyzer for CostAnalyzer {
825 fn name(&self) -> &str {
826 "cost"
827 }
828
829 fn config(&self) -> &AnalyzerConfig {
830 &self.config.base
831 }
832
833 fn state(&self) -> AnalyzerState {
834 self.state
835 }
836
837 async fn start(&mut self) -> AnalyzerResult<()> {
838 if self.state != AnalyzerState::Initialized && self.state != AnalyzerState::Stopped {
839 return Err(AnalyzerError::InvalidState(format!(
840 "Cannot start from state: {:?}",
841 self.state
842 )));
843 }
844
845 self.state = AnalyzerState::Starting;
846 info!("Starting Cost Analyzer");
847
848 self.state = AnalyzerState::Running;
851 info!("Cost Analyzer started successfully");
852
853 Ok(())
854 }
855
856 async fn stop(&mut self) -> AnalyzerResult<()> {
857 if !matches!(
858 self.state,
859 AnalyzerState::Running | AnalyzerState::Draining
860 ) {
861 return Err(AnalyzerError::InvalidState(format!(
862 "Cannot stop from state: {:?}",
863 self.state
864 )));
865 }
866
867 self.state = AnalyzerState::Draining;
868 info!("Stopping Cost Analyzer");
869
870 self.state = AnalyzerState::Stopped;
873 info!("Cost Analyzer stopped");
874
875 Ok(())
876 }
877
878 async fn process_event(&mut self, event: AnalyzerEvent) -> AnalyzerResult<()> {
879 if !self.state.can_accept_events() {
880 return Err(AnalyzerError::InvalidState(format!(
881 "Cannot process events in state: {:?}",
882 self.state
883 )));
884 }
885
886 let start = Instant::now();
887
888 let result = match &event {
889 AnalyzerEvent::Request { .. } => self.process_request(event).await,
890 AnalyzerEvent::Response { .. } => self.process_response(event).await,
891 AnalyzerEvent::Cost { .. } => self.process_cost(event).await,
892 _ => Ok(()), };
894
895 let mut stats = self.stats.write().await;
897 stats.events_processed += 1;
898 let elapsed = start.elapsed().as_micros() as u64;
899 stats.analysis_time_ms += elapsed / 1000;
900 stats.avg_analysis_time_us =
901 (stats.analysis_time_ms * 1000) as f64 / stats.events_processed as f64;
902
903 if result.is_err() {
904 stats.error_count += 1;
905 stats.last_error = result.as_ref().err().map(|e| e.to_string());
906 }
907
908 result
909 }
910
911 async fn generate_report(&self) -> AnalyzerResult<AnalysisReport> {
912 let metrics = self.metrics.read().await;
913 let stats = self.stats.read().await;
914 let last_report = *self.last_report_time.read().await;
915
916 let insights = self.analyze_metrics().await;
917 let recommendations = self.generate_recommendations(&insights, &metrics);
918 let alerts = Vec::new();
919
920 let mut report_metrics = HashMap::new();
921 report_metrics.insert("total_cost_usd".to_string(), metrics.total_cost);
922 report_metrics.insert("daily_cost_usd".to_string(), metrics.daily_cost);
923 report_metrics.insert("monthly_cost_usd".to_string(), metrics.monthly_cost);
924 report_metrics.insert("average_cost_per_request".to_string(), metrics.average_cost_per_request());
925 report_metrics.insert("daily_budget_utilization_pct".to_string(),
926 metrics.daily_budget_utilization(self.config.daily_budget_usd));
927 report_metrics.insert("monthly_budget_utilization_pct".to_string(),
928 metrics.monthly_budget_utilization(self.config.monthly_budget_usd));
929
930 for (model, model_metrics) in &metrics.model_costs {
932 report_metrics.insert(
933 format!("model_{}_total_cost", model.replace('-', "_")),
934 model_metrics.total_cost,
935 );
936 report_metrics.insert(
937 format!("model_{}_requests", model.replace('-', "_")),
938 model_metrics.request_count as f64,
939 );
940 }
941
942 let report = AnalysisReport {
943 analyzer: "cost".to_string(),
944 timestamp: Utc::now(),
945 period_start: Utc::now() - chrono::Duration::from_std(last_report.elapsed()).unwrap(),
946 period_end: Utc::now(),
947 summary: ReportSummary {
948 events_processed: stats.events_processed,
949 events_per_second: stats.events_processed as f64
950 / last_report.elapsed().as_secs_f64(),
951 insights_count: insights.len() as u64,
952 recommendations_count: recommendations.len() as u64,
953 alerts_count: alerts.len() as u64,
954 analysis_duration_ms: stats.analysis_time_ms,
955 highlights: vec![
956 format!("Total cost: ${:.2}", metrics.total_cost),
957 format!("Daily cost: ${:.2} ({:.1}% of budget)",
958 metrics.daily_cost,
959 metrics.daily_budget_utilization(self.config.daily_budget_usd)),
960 format!("Monthly cost: ${:.2} ({:.1}% of budget)",
961 metrics.monthly_cost,
962 metrics.monthly_budget_utilization(self.config.monthly_budget_usd)),
963 format!("Avg cost/request: ${:.4}", metrics.average_cost_per_request()),
964 ],
965 },
966 insights,
967 recommendations,
968 alerts,
969 metrics: report_metrics,
970 };
971
972 *self.last_report_time.write().await = Instant::now();
974
975 Ok(report)
976 }
977
978 fn get_stats(&self) -> AnalyzerStats {
979 AnalyzerStats {
980 analyzer: "cost".to_string(),
981 uptime_seconds: self.start_time.elapsed().as_secs(),
982 ..Default::default()
983 }
984 }
985
986 async fn reset(&mut self) -> AnalyzerResult<()> {
987 let mut metrics = self.metrics.write().await;
988 *metrics = CostMetrics::new(self.config.cost_window_size);
989
990 let mut stats = self.stats.write().await;
991 *stats = AnalyzerStats {
992 analyzer: "cost".to_string(),
993 ..Default::default()
994 };
995
996 info!("Cost Analyzer reset");
997 Ok(())
998 }
999
1000 async fn health_check(&self) -> AnalyzerResult<()> {
1001 let stats = self.stats.read().await;
1002
1003 if stats.events_processed > 0 {
1005 let error_rate = stats.error_count as f64 / stats.events_processed as f64;
1006 if error_rate > 0.1 {
1007 return Err(AnalyzerError::InternalError(format!(
1008 "High error rate: {:.2}%",
1009 error_rate * 100.0
1010 )));
1011 }
1012 }
1013
1014 if stats.memory_usage_bytes > self.config.base.max_memory_bytes {
1016 return Err(AnalyzerError::ResourceExhausted(format!(
1017 "Memory usage {} exceeds limit {}",
1018 stats.memory_usage_bytes, self.config.base.max_memory_bytes
1019 )));
1020 }
1021
1022 Ok(())
1023 }
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use super::*;
1029
1030 fn create_test_response_event(
1031 model: &str,
1032 prompt_tokens: u32,
1033 completion_tokens: u32,
1034 success: bool,
1035 ) -> AnalyzerEvent {
1036 AnalyzerEvent::Response {
1037 timestamp: Utc::now(),
1038 request_id: Uuid::new_v4().to_string(),
1039 model: model.to_string(),
1040 completion_tokens,
1041 total_tokens: prompt_tokens + completion_tokens,
1042 latency_ms: 500,
1043 success,
1044 error: if success { None } else { Some("Test error".to_string()) },
1045 metadata: HashMap::new(),
1046 }
1047 }
1048
1049 fn create_test_cost_event(model: &str, tokens: u32, cost: f64) -> AnalyzerEvent {
1050 AnalyzerEvent::Cost {
1051 timestamp: Utc::now(),
1052 model: model.to_string(),
1053 tokens,
1054 cost_usd: cost,
1055 metadata: HashMap::new(),
1056 }
1057 }
1058
1059 #[test]
1060 fn test_model_pricing_gpt4() {
1061 let pricing = ModelPricing::for_model("gpt-4");
1062 assert_eq!(pricing.prompt_cost_per_1k, 0.03);
1063 assert_eq!(pricing.completion_cost_per_1k, 0.06);
1064
1065 let cost = pricing.calculate_cost(1000, 500);
1066 assert!((cost - 0.06).abs() < 0.001); }
1068
1069 #[test]
1070 fn test_model_pricing_gpt35() {
1071 let pricing = ModelPricing::for_model("gpt-3.5-turbo");
1072 assert_eq!(pricing.prompt_cost_per_1k, 0.001);
1073 assert_eq!(pricing.completion_cost_per_1k, 0.002);
1074
1075 let cost = pricing.calculate_cost(1000, 1000);
1076 assert!((cost - 0.003).abs() < 0.0001);
1077 }
1078
1079 #[test]
1080 fn test_model_pricing_claude_opus() {
1081 let pricing = ModelPricing::for_model("claude-3-opus");
1082 assert_eq!(pricing.prompt_cost_per_1k, 0.015);
1083 assert_eq!(pricing.completion_cost_per_1k, 0.075);
1084 }
1085
1086 #[test]
1087 fn test_model_pricing_claude_sonnet() {
1088 let pricing = ModelPricing::for_model("claude-3-sonnet");
1089 assert_eq!(pricing.prompt_cost_per_1k, 0.003);
1090 assert_eq!(pricing.completion_cost_per_1k, 0.015);
1091 }
1092
1093 #[tokio::test]
1094 async fn test_analyzer_lifecycle() {
1095 let mut analyzer = CostAnalyzer::with_defaults();
1096
1097 assert_eq!(analyzer.state(), AnalyzerState::Initialized);
1098
1099 analyzer.start().await.unwrap();
1100 assert_eq!(analyzer.state(), AnalyzerState::Running);
1101
1102 analyzer.stop().await.unwrap();
1103 assert_eq!(analyzer.state(), AnalyzerState::Stopped);
1104 }
1105
1106 #[tokio::test]
1107 async fn test_process_response_event() {
1108 let mut analyzer = CostAnalyzer::with_defaults();
1109 analyzer.start().await.unwrap();
1110
1111 let event = create_test_response_event("gpt-4", 1000, 500, true);
1112 analyzer.process_event(event).await.unwrap();
1113
1114 let metrics = analyzer.metrics.read().await;
1115 assert!(metrics.total_cost > 0.0);
1116 assert_eq!(metrics.model_costs.len(), 1);
1117 assert!(metrics.model_costs.contains_key("gpt-4"));
1118 }
1119
1120 #[tokio::test]
1121 async fn test_cost_calculation_accuracy() {
1122 let mut analyzer = CostAnalyzer::with_defaults();
1123 analyzer.start().await.unwrap();
1124
1125 let event = create_test_response_event("gpt-4", 1000, 500, true);
1126 analyzer.process_event(event).await.unwrap();
1127
1128 let metrics = analyzer.metrics.read().await;
1129 let expected_cost = 0.06; assert!((metrics.total_cost - expected_cost).abs() < 0.001);
1131 }
1132
1133 #[tokio::test]
1134 async fn test_budget_utilization_tracking() {
1135 let config = CostAnalyzerConfig {
1136 daily_budget_usd: 1.0,
1137 monthly_budget_usd: 30.0,
1138 ..Default::default()
1139 };
1140 let mut analyzer = CostAnalyzer::new(config);
1141 analyzer.start().await.unwrap();
1142
1143 for _ in 0..5 {
1145 let event = create_test_cost_event("gpt-4", 1000, 0.1);
1146 analyzer.process_event(event).await.unwrap();
1147 }
1148
1149 let metrics = analyzer.metrics.read().await;
1150 assert!((metrics.daily_cost - 0.5).abs() < 0.001);
1151 let utilization = metrics.daily_budget_utilization(1.0);
1152 assert!((utilization - 50.0).abs() < 0.1);
1153 }
1154
1155 #[tokio::test]
1156 async fn test_budget_alert_generation() {
1157 let config = CostAnalyzerConfig {
1158 daily_budget_usd: 1.0,
1159 budget_warning_threshold_pct: 80.0,
1160 ..Default::default()
1161 };
1162 let mut analyzer = CostAnalyzer::new(config);
1163 analyzer.start().await.unwrap();
1164
1165 let event = create_test_cost_event("gpt-4", 1000, 0.85);
1167 analyzer.process_event(event).await.unwrap();
1168
1169 let insights = analyzer.analyze_metrics().await;
1170 assert!(insights.iter().any(|i| i.title.contains("Budget")));
1171 }
1172
1173 #[tokio::test]
1174 async fn test_cost_spike_detection() {
1175 let mut analyzer = CostAnalyzer::with_defaults();
1176 analyzer.start().await.unwrap();
1177
1178 for _ in 0..50 {
1180 let event = create_test_cost_event("gpt-4", 1000, 0.05);
1181 analyzer.process_event(event).await.unwrap();
1182 }
1183
1184 {
1186 let mut metrics = analyzer.metrics.write().await;
1187 metrics.baseline_cost_per_request = Some(0.05);
1188 }
1189
1190 for _ in 0..10 {
1192 let event = create_test_cost_event("gpt-4", 1000, 0.15); analyzer.process_event(event).await.unwrap();
1194 }
1195
1196 let insights = analyzer.analyze_metrics().await;
1197 assert!(insights.iter().any(|i| i.title.contains("Spike")));
1198 }
1199
1200 #[tokio::test]
1201 async fn test_per_model_cost_tracking() {
1202 let mut analyzer = CostAnalyzer::with_defaults();
1203 analyzer.start().await.unwrap();
1204
1205 let event1 = create_test_response_event("gpt-4", 1000, 500, true);
1206 let event2 = create_test_response_event("gpt-3.5-turbo", 1000, 500, true);
1207
1208 analyzer.process_event(event1).await.unwrap();
1209 analyzer.process_event(event2).await.unwrap();
1210
1211 let metrics = analyzer.metrics.read().await;
1212 assert_eq!(metrics.model_costs.len(), 2);
1213 assert!(metrics.model_costs.contains_key("gpt-4"));
1214 assert!(metrics.model_costs.contains_key("gpt-3.5-turbo"));
1215
1216 let gpt4_cost = metrics.model_costs.get("gpt-4").unwrap().total_cost;
1217 let gpt35_cost = metrics.model_costs.get("gpt-3.5-turbo").unwrap().total_cost;
1218 assert!(gpt4_cost > gpt35_cost); }
1220
1221 #[tokio::test]
1222 async fn test_generate_report() {
1223 let mut analyzer = CostAnalyzer::with_defaults();
1224 analyzer.start().await.unwrap();
1225
1226 for _ in 0..10 {
1227 let event = create_test_cost_event("gpt-4", 1000, 0.05);
1228 analyzer.process_event(event).await.unwrap();
1229 }
1230
1231 let report = analyzer.generate_report().await.unwrap();
1232 assert_eq!(report.analyzer, "cost");
1233 assert_eq!(report.summary.events_processed, 10);
1234 assert!(!report.metrics.is_empty());
1235 assert!(report.metrics.contains_key("total_cost_usd"));
1236 assert!(report.metrics.contains_key("daily_cost_usd"));
1237 }
1238
1239 #[tokio::test]
1240 async fn test_reset() {
1241 let mut analyzer = CostAnalyzer::with_defaults();
1242 analyzer.start().await.unwrap();
1243
1244 let event = create_test_cost_event("gpt-4", 1000, 0.05);
1245 analyzer.process_event(event).await.unwrap();
1246
1247 analyzer.reset().await.unwrap();
1248
1249 let metrics = analyzer.metrics.read().await;
1250 assert_eq!(metrics.total_cost, 0.0);
1251 assert_eq!(metrics.model_costs.len(), 0);
1252 }
1253
1254 #[tokio::test]
1255 async fn test_model_optimization_recommendation() {
1256 let config = CostAnalyzerConfig {
1257 enable_optimization_recommendations: true,
1258 ..Default::default()
1259 };
1260 let mut analyzer = CostAnalyzer::new(config);
1261 analyzer.start().await.unwrap();
1262
1263 for _ in 0..50 {
1265 let event = create_test_response_event("gpt-4", 1000, 500, true);
1266 analyzer.process_event(event).await.unwrap();
1267 }
1268
1269 for _ in 0..50 {
1271 let event = create_test_response_event("gpt-3.5-turbo", 1000, 500, true);
1272 analyzer.process_event(event).await.unwrap();
1273 }
1274
1275 let metrics = analyzer.metrics.read().await;
1276 let insights = analyzer.analyze_metrics().await;
1277 let recommendations = analyzer.generate_recommendations(&insights, &metrics);
1278
1279 assert!(recommendations.iter().any(|r| r.title.contains("switching")));
1280 }
1281}