1use std::collections::HashMap;
2
3use crate::types::{ModelFamily, SessionState};
4
5#[derive(Debug, Clone)]
11pub struct ModelPricing {
12 pub input_per_1k: f64,
14 pub output_per_1k: f64,
16 pub cache_read_discount: f64,
20}
21
22#[derive(Debug, Clone, Default)]
24pub struct PricingConfig {
25 pub models: HashMap<ModelFamily, ModelPricing>,
26}
27
28impl PricingConfig {
29 pub fn default_pricing() -> Self {
32 let mut models = HashMap::new();
33 models.insert(
34 ModelFamily::AnthropicClaude,
35 ModelPricing {
36 input_per_1k: 0.003,
37 output_per_1k: 0.015,
38 cache_read_discount: 0.9,
39 },
40 );
41 models.insert(
42 ModelFamily::OpenAiGpt,
43 ModelPricing {
44 input_per_1k: 0.002,
45 output_per_1k: 0.008,
46 cache_read_discount: 0.5,
47 },
48 );
49 models.insert(
50 ModelFamily::GoogleGemini,
51 ModelPricing {
52 input_per_1k: 0.001,
53 output_per_1k: 0.004,
54 cache_read_discount: 0.0,
55 },
56 );
57 PricingConfig { models }
58 }
59}
60
61#[derive(Debug, Clone, Default)]
67pub struct TokenUsage {
68 pub input: u32,
70 pub output: u32,
72 pub cached_input: u32,
74}
75
76#[derive(Debug, Clone)]
82pub struct ToolCost {
83 pub tokens_input: u32,
84 pub tokens_output: u32,
85 pub cost_usd: f64,
86}
87
88#[derive(Debug, Clone)]
90pub struct CostBreakdown {
91 pub total_usd: f64,
93 pub per_tool: HashMap<String, ToolCost>,
95 pub cache_savings_usd: f64,
97 pub compression_savings_usd: f64,
99}
100
101#[derive(Debug, Clone)]
103pub struct SessionCostSummary {
104 pub total_tokens: u32,
105 pub total_usd: f64,
106 pub cache_savings_usd: f64,
107 pub compression_savings_usd: f64,
108}
109
110pub struct CostCalculator {
116 pricing: HashMap<ModelFamily, ModelPricing>,
117}
118
119impl CostCalculator {
120 pub fn new(pricing_config: &PricingConfig) -> Self {
122 CostCalculator {
123 pricing: pricing_config.models.clone(),
124 }
125 }
126
127 pub fn with_defaults() -> Self {
129 Self::new(&PricingConfig::default_pricing())
130 }
131
132 pub fn compute_cost(&self, model: &ModelFamily, tokens: &TokenUsage) -> CostBreakdown {
136 let pricing = match self.pricing.get(model) {
137 Some(p) => p,
138 None => {
139 return CostBreakdown {
140 total_usd: 0.0,
141 per_tool: HashMap::new(),
142 cache_savings_usd: 0.0,
143 compression_savings_usd: 0.0,
144 }
145 }
146 };
147
148 let input_cost = (tokens.input as f64 / 1_000.0) * pricing.input_per_1k;
149 let output_cost = (tokens.output as f64 / 1_000.0) * pricing.output_per_1k;
150
151 let cached_full_cost = (tokens.cached_input as f64 / 1_000.0) * pricing.input_per_1k;
153 let cached_actual_cost = cached_full_cost * (1.0 - pricing.cache_read_discount);
154 let cache_savings = cached_full_cost - cached_actual_cost;
155
156 let total_usd = input_cost + output_cost + cached_actual_cost;
157
158 CostBreakdown {
159 total_usd,
160 per_tool: HashMap::new(), cache_savings_usd: cache_savings,
162 compression_savings_usd: 0.0, }
164 }
165
166 pub fn session_summary(&self, session: &SessionState) -> SessionCostSummary {
171 let model = &session.budget.model_family;
172 let pricing = self.pricing.get(model);
173
174 let mut total_tokens: u32 = 0;
175 let mut total_usd: f64 = 0.0;
176 let mut cache_savings_usd: f64 = 0.0;
177
178 for record in &session.tool_usage {
179 total_tokens = total_tokens
180 .saturating_add(record.tokens_input)
181 .saturating_add(record.tokens_output);
182 total_usd += record.cost_usd;
183
184 if let Some(p) = pricing {
186 let full_input_cost =
187 (record.tokens_input as f64 / 1_000.0) * p.input_per_1k;
188 let full_output_cost =
189 (record.tokens_output as f64 / 1_000.0) * p.output_per_1k;
190 let full_cost = full_input_cost + full_output_cost;
191 if full_cost > record.cost_usd {
192 cache_savings_usd += full_cost - record.cost_usd;
193 }
194 }
195 }
196
197 let compression_savings_usd = if let Some(p) = pricing {
200 let original_tokens: u32 = session
201 .conversation
202 .iter()
203 .map(|t| t.tokens)
204 .sum();
205 let consumed = session.budget.consumed;
206 if original_tokens > consumed {
207 let saved = original_tokens - consumed;
208 (saved as f64 / 1_000.0) * p.input_per_1k
209 } else {
210 0.0
211 }
212 } else {
213 0.0
214 };
215
216 SessionCostSummary {
217 total_tokens,
218 total_usd,
219 cache_savings_usd,
220 compression_savings_usd,
221 }
222 }
223}
224
225#[derive(Debug, Clone)]
236pub struct SavingsAssumptions {
237 pub price_in_per_mtok: f64,
239 pub cache_write_mult: f64,
241 pub cache_read_mult: f64,
243 pub reread_turns: f64,
246}
247
248impl Default for SavingsAssumptions {
249 fn default() -> Self {
250 SavingsAssumptions {
251 price_in_per_mtok: 3.0,
252 cache_write_mult: 1.25,
253 cache_read_mult: 0.10,
254 reread_turns: 10.0,
255 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq)]
261pub struct SavedCostEstimate {
262 pub write_usd: f64,
264 pub reread_usd: f64,
266 pub total_usd: f64,
268}
269
270pub fn estimate_saved_cost(tokens_saved: u64, a: &SavingsAssumptions) -> SavedCostEstimate {
278 let mtok = tokens_saved as f64 / 1_000_000.0;
279 let write_usd = mtok * a.price_in_per_mtok * a.cache_write_mult;
280 let reread_usd = mtok * a.price_in_per_mtok * a.cache_read_mult * a.reread_turns;
281 SavedCostEstimate {
282 write_usd,
283 reread_usd,
284 total_usd: write_usd + reread_usd,
285 }
286}
287
288#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::types::ToolUsageRecord;
296 use proptest::prelude::*;
297
298 #[allow(dead_code)]
303 fn arb_token_usage() -> impl Strategy<Value = TokenUsage> {
304 (0u32..=100_000u32, 0u32..=50_000u32, 0u32..=100_000u32).prop_map(
305 |(input, output, cached_input)| TokenUsage {
306 input,
307 output,
308 cached_input,
309 },
310 )
311 }
312
313 fn arb_model() -> impl Strategy<Value = ModelFamily> {
314 prop_oneof![
315 Just(ModelFamily::AnthropicClaude),
316 Just(ModelFamily::OpenAiGpt),
317 Just(ModelFamily::GoogleGemini),
318 ]
319 }
320
321 proptest! {
327 #[test]
336 fn prop_cost_per_tool_invariant(
337 records in prop::collection::vec(
338 (
339 "[a-z_]{1,16}", 0u32..=10_000u32, 0u32..=5_000u32, ),
343 0..=20,
344 ),
345 model in arb_model(),
346 ) {
347 use chrono::Utc;
348 use crate::types::{BudgetState, CorrectionLog, SessionState};
349 use std::path::PathBuf;
350
351 let calc = CostCalculator::with_defaults();
352 let pricing = PricingConfig::default_pricing();
353 let p = pricing.models.get(&model).unwrap();
354
355 let tool_usage: Vec<ToolUsageRecord> = records
357 .iter()
358 .map(|(name, ti, to)| {
359 let cost = (*ti as f64 / 1_000.0) * p.input_per_1k
360 + (*to as f64 / 1_000.0) * p.output_per_1k;
361 ToolUsageRecord {
362 tool_name: name.clone(),
363 tokens_input: *ti,
364 tokens_output: *to,
365 cost_usd: cost,
366 timestamp: Utc::now(),
367 }
368 })
369 .collect();
370
371 let expected_total_usd: f64 = tool_usage.iter().map(|r| r.cost_usd).sum();
372 let expected_total_tokens: u32 = tool_usage
373 .iter()
374 .map(|r| r.tokens_input.saturating_add(r.tokens_output))
375 .sum();
376
377 let session = SessionState {
378 id: "test".to_string(),
379 project_dir: PathBuf::from("/tmp"),
380 conversation: vec![],
381 corrections: CorrectionLog::default(),
382 pins: vec![],
383 learnings: vec![],
384 compressed_summary: String::new(),
385 budget: BudgetState {
386 window_size: 200_000,
387 consumed: 0,
388 pinned: 0,
389 model_family: model,
390 },
391 tool_usage,
392 created_at: Utc::now(),
393 updated_at: Utc::now(),
394 };
395
396 let summary = calc.session_summary(&session);
397
398 prop_assert_eq!(summary.total_tokens, expected_total_tokens);
400
401 prop_assert!(
403 (summary.total_usd - expected_total_usd).abs() < 1e-9,
404 "total_usd mismatch: {} vs {}",
405 summary.total_usd,
406 expected_total_usd
407 );
408 }
409 }
410
411 proptest! {
417 #[test]
426 fn prop_cache_discount_lowers_cost(
427 input in 1u32..=100_000u32,
428 output in 0u32..=50_000u32,
429 cached_input in 1u32..=100_000u32, model in arb_model(),
431 ) {
432 let calc = CostCalculator::with_defaults();
433
434 let with_cache = TokenUsage { input, output, cached_input };
436 let without_cache = TokenUsage {
438 input: input + cached_input,
439 output,
440 cached_input: 0,
441 };
442
443 let cost_with = calc.compute_cost(&model, &with_cache);
444 let cost_without = calc.compute_cost(&model, &without_cache);
445
446 let pricing = PricingConfig::default_pricing();
447 let p = pricing.models.get(&model).unwrap();
448
449 if p.cache_read_discount > 0.0 {
450 prop_assert!(
452 cost_with.total_usd < cost_without.total_usd,
453 "expected cost_with ({}) < cost_without ({}) for model {:?}",
454 cost_with.total_usd,
455 cost_without.total_usd,
456 model
457 );
458 prop_assert!(
460 cost_with.cache_savings_usd > 0.0,
461 "expected positive cache_savings_usd, got {}",
462 cost_with.cache_savings_usd
463 );
464 } else {
465 prop_assert!(
467 (cost_with.total_usd - cost_without.total_usd).abs() < 1e-12,
468 "expected equal costs for zero-discount model, got {} vs {}",
469 cost_with.total_usd,
470 cost_without.total_usd
471 );
472 }
473 }
474 }
475
476 #[test]
481 fn test_anthropic_pricing_defaults() {
482 let calc = CostCalculator::with_defaults();
483 let usage = TokenUsage {
484 input: 1_000,
485 output: 1_000,
486 cached_input: 0,
487 };
488 let breakdown = calc.compute_cost(&ModelFamily::AnthropicClaude, &usage);
489 assert!((breakdown.total_usd - 0.018).abs() < 1e-9);
491 assert_eq!(breakdown.cache_savings_usd, 0.0);
492 }
493
494 #[test]
495 fn test_anthropic_cache_discount() {
496 let calc = CostCalculator::with_defaults();
497 let usage = TokenUsage {
498 input: 0,
499 output: 0,
500 cached_input: 1_000,
501 };
502 let breakdown = calc.compute_cost(&ModelFamily::AnthropicClaude, &usage);
503 assert!((breakdown.total_usd - 0.0003).abs() < 1e-9);
505 assert!((breakdown.cache_savings_usd - 0.0027).abs() < 1e-9);
507 }
508
509 #[test]
510 fn test_openai_cache_discount() {
511 let calc = CostCalculator::with_defaults();
512 let usage = TokenUsage {
513 input: 0,
514 output: 0,
515 cached_input: 1_000,
516 };
517 let breakdown = calc.compute_cost(&ModelFamily::OpenAiGpt, &usage);
518 assert!((breakdown.total_usd - 0.001).abs() < 1e-9);
520 assert!((breakdown.cache_savings_usd - 0.001).abs() < 1e-9);
521 }
522
523 #[test]
524 fn test_gemini_no_cache_discount() {
525 let calc = CostCalculator::with_defaults();
526 let usage = TokenUsage {
527 input: 1_000,
528 output: 0,
529 cached_input: 1_000,
530 };
531 let breakdown = calc.compute_cost(&ModelFamily::GoogleGemini, &usage);
532 assert!((breakdown.total_usd - 0.002).abs() < 1e-9);
534 assert_eq!(breakdown.cache_savings_usd, 0.0);
535 }
536
537 #[test]
538 fn test_unknown_model_returns_zero() {
539 let calc = CostCalculator::with_defaults();
540 let usage = TokenUsage {
541 input: 1_000,
542 output: 1_000,
543 cached_input: 0,
544 };
545 let breakdown = calc.compute_cost(&ModelFamily::Local("custom".to_string()), &usage);
546 assert_eq!(breakdown.total_usd, 0.0);
547 }
548
549 #[test]
550 fn saved_cost_zero_tokens_is_zero() {
551 let est = estimate_saved_cost(0, &SavingsAssumptions::default());
552 assert_eq!(est.total_usd, 0.0);
553 assert_eq!(est.write_usd, 0.0);
554 assert_eq!(est.reread_usd, 0.0);
555 }
556
557 #[test]
558 fn saved_cost_no_rereads_is_write_only() {
559 let a = SavingsAssumptions { reread_turns: 0.0, ..Default::default() };
560 let est = estimate_saved_cost(1_000_000, &a);
561 assert!((est.write_usd - 3.75).abs() < 1e-9);
563 assert_eq!(est.reread_usd, 0.0);
564 assert!((est.total_usd - 3.75).abs() < 1e-9);
565 }
566
567 #[test]
568 fn saved_cost_defaults_math() {
569 let est = estimate_saved_cost(1_000_000, &SavingsAssumptions::default());
570 assert!((est.write_usd - 3.75).abs() < 1e-9);
572 assert!((est.reread_usd - 3.0).abs() < 1e-9);
573 assert!((est.total_usd - 6.75).abs() < 1e-9);
574 }
575
576 proptest! {
577 #[test]
578 fn prop_saved_cost_monotonic_in_tokens(t1 in 0u64..10_000_000, t2 in 0u64..10_000_000) {
579 let a = SavingsAssumptions::default();
580 let (lo, hi) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
581 let e_lo = estimate_saved_cost(lo, &a);
582 let e_hi = estimate_saved_cost(hi, &a);
583 prop_assert!(e_lo.total_usd <= e_hi.total_usd + 1e-12);
584 }
585 }
586}