Skip to main content

llm_optimizer_processor/analyzer/
cost_analyzer.rs

1//! Cost Analyzer
2//!
3//! Analyzes cost metrics including token costs by model, budget utilization,
4//! cost per request, and detects cost anomalies and optimization opportunities.
5
6use 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/// Model pricing information (per 1K tokens)
29#[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    /// Get pricing for a model
37    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            // Default to GPT-3.5 pricing for unknown models
62            Self {
63                prompt_cost_per_1k: 0.001,
64                completion_cost_per_1k: 0.002,
65            }
66        }
67    }
68
69    /// Calculate cost for given token counts
70    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/// Configuration specific to the Cost Analyzer
78#[derive(Debug, Clone)]
79pub struct CostAnalyzerConfig {
80    /// Base analyzer configuration
81    pub base: AnalyzerConfig,
82
83    /// Daily budget limit in USD
84    pub daily_budget_usd: f64,
85
86    /// Monthly budget limit in USD
87    pub monthly_budget_usd: f64,
88
89    /// Budget warning threshold (percentage)
90    pub budget_warning_threshold_pct: f64,
91
92    /// Budget critical threshold (percentage)
93    pub budget_critical_threshold_pct: f64,
94
95    /// Cost spike detection threshold (percentage increase)
96    pub cost_spike_threshold_pct: f64,
97
98    /// Window size for cost tracking
99    pub cost_window_size: usize,
100
101    /// Enable cost optimization recommendations
102    pub enable_optimization_recommendations: bool,
103
104    /// Minimum cost difference to recommend model switching (USD)
105    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/// Per-model cost tracking
125#[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/// Cost metrics tracked over time
162#[derive(Debug, Clone)]
163struct CostMetrics {
164    /// Per-model costs
165    model_costs: HashMap<String, ModelCostMetrics>,
166
167    /// Total cost across all models
168    total_cost: f64,
169
170    /// Daily costs (reset each day)
171    daily_cost: f64,
172    daily_reset_time: DateTime<Utc>,
173
174    /// Monthly costs (reset each month)
175    monthly_cost: f64,
176    monthly_reset_time: DateTime<Utc>,
177
178    /// Cost per request samples
179    cost_per_request: CircularBuffer<f64>,
180
181    /// Request timestamps for trend analysis
182    request_times: CircularBuffer<DateTime<Utc>>,
183
184    /// Cost trend tracker
185    cost_ema: ExponentialMovingAverage,
186
187    /// Historical baseline for anomaly detection
188    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        // Check daily reset
214        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        // Check monthly reset
221        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        // Update totals
239        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        // Update per-model metrics
247        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; // EMA is 10% higher than average
280            }
281        }
282        false
283    }
284}
285
286/// Cost Analyzer implementation
287pub 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    /// Create a new Cost Analyzer
298    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    /// Create with default configuration
315    pub fn with_defaults() -> Self {
316        Self::new(CostAnalyzerConfig::default())
317    }
318
319    /// Process a request event to calculate cost
320    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            // Store for later matching with response
329            trace!(
330                "Processed request: model={}, prompt_tokens={}",
331                model,
332                prompt_tokens
333            );
334        }
335
336        Ok(())
337    }
338
339    /// Process a response event to calculate cost
340    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(()); // Don't charge for failed requests
352            }
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    /// Process a cost event
374    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    /// Analyze current metrics and generate insights
397    async fn analyze_metrics(&self) -> Vec<Insight> {
398        let metrics = self.metrics.read().await;
399        let mut insights = Vec::new();
400
401        // Check budget utilization
402        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        // Check for cost spikes
441        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        // Check for cost trends
452        if metrics.is_cost_trending_up() {
453            insights.push(self.create_cost_trend_insight(&metrics));
454        }
455
456        // Detect anomalies in per-request costs
457        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            // Check recent costs for outliers
463            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; // Only report one anomaly per analysis
468                }
469            }
470        }
471
472        insights
473    }
474
475    /// Create insight for budget threshold violations
476    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    /// Create insight for cost spikes
535    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    /// Create insight for cost trends
579    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    /// Create insight for cost anomalies
618    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    /// Generate recommendations based on insights
656    fn generate_recommendations(&self, insights: &[Insight], metrics: &CostMetrics) -> Vec<Recommendation> {
657        let mut recommendations = Vec::new();
658
659        // Budget recommendations
660        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        // Model optimization recommendations
696        if self.config.enable_optimization_recommendations {
697            recommendations.extend(self.generate_model_optimization_recommendations(metrics));
698        }
699
700        // Caching recommendations
701        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    /// Generate model optimization recommendations
749    fn generate_model_optimization_recommendations(&self, metrics: &CostMetrics) -> Vec<Recommendation> {
750        let mut recommendations = Vec::new();
751
752        // Find the most expensive model
753        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            // Check if there's a significantly cheaper alternative
760            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; // Only recommend one switch
815                }
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        // Initialization logic here
849
850        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        // Cleanup logic here
871
872        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(()), // Ignore other event types
893        };
894
895        // Update stats
896        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        // Per-model costs
931        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        // Update last report time
973        *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        // Check error rate
1004        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        // Check memory usage
1015        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); // 1000 * 0.03/1000 + 500 * 0.06/1000 = 0.06
1067    }
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; // 1000 * 0.03/1000 + 500 * 0.06/1000
1130        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        // Add events totaling $0.5
1144        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        // Spend 85% of budget
1166        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        // Establish baseline
1179        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        // Set baseline
1185        {
1186            let mut metrics = analyzer.metrics.write().await;
1187            metrics.baseline_cost_per_request = Some(0.05);
1188        }
1189
1190        // Add spike
1191        for _ in 0..10 {
1192            let event = create_test_cost_event("gpt-4", 1000, 0.15); // 3x higher
1193            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); // GPT-4 should be more expensive
1219    }
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        // Use expensive model
1264        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        // Use cheaper model
1270        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}