1use std::collections::HashMap;
17
18pub const JOULES_PER_KWH: f64 = 3_600_000.0;
20
21pub const DEFAULT_CARBON_INTENSITY: f64 = 400.0;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum CloudProvider {
27 Aws,
29 Gcp,
31 Azure,
33 OnPrem,
35}
36
37impl CloudProvider {
38 pub fn name(&self) -> &'static str {
40 match self {
41 Self::Aws => "AWS",
42 Self::Gcp => "GCP",
43 Self::Azure => "Azure",
44 Self::OnPrem => "On-Premise",
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
51pub struct GpuPricing {
52 pub provider: CloudProvider,
54 pub gpu_type: String,
56 pub price_per_hour: f64,
58 pub power_watts: f64,
60}
61
62impl GpuPricing {
63 pub fn new(
65 provider: CloudProvider,
66 gpu_type: &str,
67 price_per_hour: f64,
68 power_watts: f64,
69 ) -> Self {
70 Self {
71 provider,
72 gpu_type: gpu_type.to_string(),
73 price_per_hour,
74 power_watts,
75 }
76 }
77
78 pub fn price_per_second(&self) -> f64 {
80 self.price_per_hour / 3600.0
81 }
82
83 pub fn joules_per_second(&self) -> f64 {
85 self.power_watts
86 }
87}
88
89pub fn default_gpu_pricing() -> Vec<GpuPricing> {
91 vec![
92 GpuPricing::new(CloudProvider::Aws, "A100-40GB", 4.10, 400.0),
94 GpuPricing::new(CloudProvider::Aws, "A100-80GB", 5.12, 400.0),
95 GpuPricing::new(CloudProvider::Aws, "H100", 8.22, 700.0),
96 GpuPricing::new(CloudProvider::Gcp, "A100-40GB", 3.67, 400.0),
98 GpuPricing::new(CloudProvider::Gcp, "A100-80GB", 4.87, 400.0),
99 GpuPricing::new(CloudProvider::Gcp, "H100", 7.65, 700.0),
100 GpuPricing::new(CloudProvider::Azure, "A100-40GB", 3.85, 400.0),
102 GpuPricing::new(CloudProvider::Azure, "A100-80GB", 4.95, 400.0),
103 GpuPricing::new(CloudProvider::Azure, "H100", 8.00, 700.0),
104 GpuPricing::new(CloudProvider::OnPrem, "A100-40GB", 0.04, 400.0),
106 GpuPricing::new(CloudProvider::OnPrem, "H100", 0.07, 700.0),
107 ]
108}
109
110#[derive(Debug, Clone, Default)]
112pub struct EnergyMeasurement {
113 pub joules: f64,
115 pub duration_sec: f64,
117 pub power_watts: f64,
119}
120
121impl EnergyMeasurement {
122 pub fn from_power_duration(power_watts: f64, duration_sec: f64) -> Self {
124 Self {
125 joules: power_watts * duration_sec,
126 duration_sec,
127 power_watts,
128 }
129 }
130
131 pub fn from_joules_duration(joules: f64, duration_sec: f64) -> Self {
133 let power_watts = if duration_sec > 0.0 {
134 joules / duration_sec
135 } else {
136 0.0
137 };
138 Self {
139 joules,
140 duration_sec,
141 power_watts,
142 }
143 }
144
145 pub fn kwh(&self) -> f64 {
147 self.joules / JOULES_PER_KWH
148 }
149}
150
151#[derive(Debug, Clone)]
153pub struct CostResult {
154 pub total_cost: f64,
156 pub cost_per_token: f64,
158 pub cost_per_million_tokens: f64,
160 pub energy_joules: f64,
162 pub energy_kwh: f64,
164 pub carbon_g: f64,
166 pub duration_sec: f64,
168 pub token_count: u64,
170}
171
172impl CostResult {
173 pub fn new(
175 cost: f64,
176 energy_joules: f64,
177 carbon_g: f64,
178 duration_sec: f64,
179 token_count: u64,
180 ) -> Self {
181 let cost_per_token = if token_count > 0 {
182 cost / token_count as f64
183 } else {
184 0.0
185 };
186
187 Self {
188 total_cost: cost,
189 cost_per_token,
190 cost_per_million_tokens: cost_per_token * 1_000_000.0,
191 energy_joules,
192 energy_kwh: energy_joules / JOULES_PER_KWH,
193 carbon_g,
194 duration_sec,
195 token_count,
196 }
197 }
198
199 pub fn to_json(&self) -> String {
201 format!(
202 r#"{{"total_cost":{:.6},"cost_per_million_tokens":{:.4},"energy_kwh":{:.6},"carbon_g":{:.2},"duration_sec":{:.2},"token_count":{}}}"#,
203 self.total_cost,
204 self.cost_per_million_tokens,
205 self.energy_kwh,
206 self.carbon_g,
207 self.duration_sec,
208 self.token_count
209 )
210 }
211}
212
213#[derive(Debug, Clone)]
215pub struct CostComparison {
216 pub baseline: CostResult,
218 pub current: CostResult,
220 pub cost_change_percent: f64,
222 pub energy_change_percent: f64,
224 pub is_regression: bool,
226}
227
228impl CostComparison {
229 pub fn new(baseline: CostResult, current: CostResult) -> Self {
231 let cost_change_percent = if baseline.total_cost > 0.0 {
232 ((current.total_cost - baseline.total_cost) / baseline.total_cost) * 100.0
233 } else {
234 0.0
235 };
236
237 let energy_change_percent = if baseline.energy_joules > 0.0 {
238 ((current.energy_joules - baseline.energy_joules) / baseline.energy_joules) * 100.0
239 } else {
240 0.0
241 };
242
243 Self {
244 is_regression: cost_change_percent > 5.0, baseline,
246 current,
247 cost_change_percent,
248 energy_change_percent,
249 }
250 }
251}
252
253#[derive(Debug, Clone)]
255pub struct BudgetAlert {
256 pub message: String,
258 pub current_spend: f64,
260 pub budget_limit: f64,
262 pub percent_used: f64,
264}
265
266#[derive(Debug)]
268pub struct CostTracker {
269 pricing: HashMap<String, GpuPricing>,
271 current_gpu: String,
273 current_provider: CloudProvider,
275 carbon_intensity: f64,
277 history: Vec<CostResult>,
279 max_history: usize,
281 budget_limit: Option<f64>,
283 total_spend: f64,
285}
286
287impl Default for CostTracker {
288 fn default() -> Self {
289 Self::new()
290 }
291}
292
293impl CostTracker {
294 pub fn new() -> Self {
296 let pricing: HashMap<String, GpuPricing> = default_gpu_pricing()
297 .into_iter()
298 .map(|p| (format!("{}-{}", p.provider.name(), p.gpu_type), p))
299 .collect();
300
301 Self {
302 pricing,
303 current_gpu: "A100-40GB".to_string(),
304 current_provider: CloudProvider::Aws,
305 carbon_intensity: DEFAULT_CARBON_INTENSITY,
306 history: Vec::new(),
307 max_history: 1000,
308 budget_limit: None,
309 total_spend: 0.0,
310 }
311 }
312
313 pub fn with_gpu(mut self, provider: CloudProvider, gpu_type: &str) -> Self {
315 self.current_provider = provider;
316 self.current_gpu = gpu_type.to_string();
317 self
318 }
319
320 pub fn with_carbon_intensity(mut self, intensity: f64) -> Self {
322 self.carbon_intensity = intensity;
323 self
324 }
325
326 pub fn with_budget(mut self, limit: f64) -> Self {
328 self.budget_limit = Some(limit);
329 self
330 }
331
332 fn current_pricing(&self) -> Option<&GpuPricing> {
334 let key = format!("{}-{}", self.current_provider.name(), self.current_gpu);
335 self.pricing.get(&key)
336 }
337
338 pub fn calculate_cost(&mut self, duration_sec: f64, token_count: u64) -> CostResult {
340 let pricing = self.current_pricing().cloned().unwrap_or_else(|| {
341 GpuPricing::new(self.current_provider, &self.current_gpu, 5.0, 400.0)
342 });
343
344 let cost = pricing.price_per_second() * duration_sec;
345 let energy_joules = pricing.joules_per_second() * duration_sec;
346 let energy_kwh = energy_joules / JOULES_PER_KWH;
347 let carbon_g = energy_kwh * self.carbon_intensity;
348
349 let result = CostResult::new(cost, energy_joules, carbon_g, duration_sec, token_count);
350
351 self.total_spend += cost;
353
354 self.history.push(result.clone());
356 while self.history.len() > self.max_history {
357 self.history.remove(0);
358 }
359
360 result
361 }
362
363 pub fn calculate_from_energy(
365 &mut self,
366 energy: &EnergyMeasurement,
367 token_count: u64,
368 ) -> CostResult {
369 let pricing = self.current_pricing().cloned().unwrap_or_else(|| {
370 GpuPricing::new(self.current_provider, &self.current_gpu, 5.0, 400.0)
371 });
372
373 let cost = pricing.price_per_second() * energy.duration_sec;
374 let energy_kwh = energy.kwh();
375 let carbon_g = energy_kwh * self.carbon_intensity;
376
377 let result = CostResult::new(
378 cost,
379 energy.joules,
380 carbon_g,
381 energy.duration_sec,
382 token_count,
383 );
384
385 self.total_spend += cost;
386
387 self.history.push(result.clone());
388 while self.history.len() > self.max_history {
389 self.history.remove(0);
390 }
391
392 result
393 }
394
395 pub fn total_spend(&self) -> f64 {
397 self.total_spend
398 }
399
400 pub fn check_budget(&self) -> Option<BudgetAlert> {
402 let limit = self.budget_limit?;
403
404 let percent_used = (self.total_spend / limit) * 100.0;
405
406 if percent_used >= 80.0 {
407 Some(BudgetAlert {
408 message: format!(
409 "Budget alert: {:.1}% used (${:.2} of ${:.2})",
410 percent_used, self.total_spend, limit
411 ),
412 current_spend: self.total_spend,
413 budget_limit: limit,
414 percent_used,
415 })
416 } else {
417 None
418 }
419 }
420
421 pub fn detect_cost_creep(&self) -> Option<f64> {
423 if self.history.len() < 10 {
424 return None;
425 }
426
427 let recent: f64 = self
429 .history
430 .iter()
431 .rev()
432 .take(10)
433 .map(|r| r.cost_per_million_tokens)
434 .sum::<f64>()
435 / 10.0;
436
437 let older_start = self.history.len().saturating_sub(20);
438 let older: f64 = self.history[older_start..older_start + 10.min(self.history.len() - 10)]
439 .iter()
440 .map(|r| r.cost_per_million_tokens)
441 .sum::<f64>()
442 / 10.0;
443
444 if older > 0.0 {
445 let change = ((recent - older) / older) * 100.0;
446 if change > 10.0 {
447 return Some(change);
448 }
449 }
450
451 None
452 }
453
454 pub fn history(&self) -> &[CostResult] {
456 &self.history
457 }
458
459 pub fn export_csv(&self) -> String {
461 let mut lines = vec![
462 "duration_sec,token_count,total_cost,cost_per_million,energy_kwh,carbon_g".to_string(),
463 ];
464
465 for result in &self.history {
466 lines.push(format!(
467 "{:.2},{},{:.6},{:.4},{:.6},{:.2}",
468 result.duration_sec,
469 result.token_count,
470 result.total_cost,
471 result.cost_per_million_tokens,
472 result.energy_kwh,
473 result.carbon_g
474 ));
475 }
476
477 lines.join("\n")
478 }
479
480 pub fn export_json(&self) -> String {
482 let entries: Vec<String> = self.history.iter().map(|r| r.to_json()).collect();
483 format!("[{}]", entries.join(","))
484 }
485
486 pub fn clear_history(&mut self) {
488 self.history.clear();
489 self.total_spend = 0.0;
490 }
491}
492
493#[cfg(test)]
494mod tests;