Skip to main content

sqz_engine/
cost_calculator.rs

1use std::collections::HashMap;
2
3use crate::types::{ModelFamily, SessionState};
4
5// ---------------------------------------------------------------------------
6// Pricing configuration
7// ---------------------------------------------------------------------------
8
9/// Per-model pricing rates.
10#[derive(Debug, Clone)]
11pub struct ModelPricing {
12    /// USD cost per 1 000 input tokens (non-cached).
13    pub input_per_1k: f64,
14    /// USD cost per 1 000 output tokens.
15    pub output_per_1k: f64,
16    /// Fraction of the input price saved on cached tokens.
17    /// 0.9 → 90 % discount (Anthropic), 0.5 → 50 % discount (OpenAI),
18    /// 0.0 → no cache discount (Gemini / Local).
19    pub cache_read_discount: f64,
20}
21
22/// Top-level pricing configuration passed to `CostCalculator::new`.
23#[derive(Debug, Clone, Default)]
24pub struct PricingConfig {
25    pub models: HashMap<ModelFamily, ModelPricing>,
26}
27
28impl PricingConfig {
29    /// Returns a `PricingConfig` pre-populated with the default rates for
30    /// Anthropic Claude, OpenAI GPT, and Google Gemini.
31    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// ---------------------------------------------------------------------------
62// Token usage
63// ---------------------------------------------------------------------------
64
65/// Token counts for a single request / tool call.
66#[derive(Debug, Clone, Default)]
67pub struct TokenUsage {
68    /// Non-cached input tokens.
69    pub input: u32,
70    /// Output tokens.
71    pub output: u32,
72    /// Input tokens served from the provider's prompt cache.
73    pub cached_input: u32,
74}
75
76// ---------------------------------------------------------------------------
77// Cost breakdown
78// ---------------------------------------------------------------------------
79
80/// Per-tool cost record.
81#[derive(Debug, Clone)]
82pub struct ToolCost {
83    pub tokens_input: u32,
84    pub tokens_output: u32,
85    pub cost_usd: f64,
86}
87
88/// Full cost breakdown for a single `compute_cost` call.
89#[derive(Debug, Clone)]
90pub struct CostBreakdown {
91    /// Total USD cost (after cache discount).
92    pub total_usd: f64,
93    /// Per-tool breakdown keyed by tool name.
94    pub per_tool: HashMap<String, ToolCost>,
95    /// USD saved because of prompt-cache hits.
96    pub cache_savings_usd: f64,
97    /// USD saved because of sqz compression (estimated from token reduction).
98    pub compression_savings_usd: f64,
99}
100
101/// Session-level cost summary.
102#[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
110// ---------------------------------------------------------------------------
111// CostCalculator
112// ---------------------------------------------------------------------------
113
114/// Computes real-time USD cost with per-tool breakdown and cache discount.
115pub struct CostCalculator {
116    pricing: HashMap<ModelFamily, ModelPricing>,
117}
118
119impl CostCalculator {
120    /// Create a new `CostCalculator` from the supplied pricing configuration.
121    pub fn new(pricing_config: &PricingConfig) -> Self {
122        CostCalculator {
123            pricing: pricing_config.models.clone(),
124        }
125    }
126
127    /// Create a `CostCalculator` with the built-in default pricing.
128    pub fn with_defaults() -> Self {
129        Self::new(&PricingConfig::default_pricing())
130    }
131
132    /// Compute the cost for a single `TokenUsage` against the given model.
133    ///
134    /// If the model is not found in the pricing table the cost is zero.
135    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        // Cached tokens are charged at (1 - discount) × normal input rate.
152        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(), // populated by session_summary
161            cache_savings_usd: cache_savings,
162            compression_savings_usd: 0.0, // set by caller when compression ratio is known
163        }
164    }
165
166    /// Produce a session-level cost summary from a `SessionState`.
167    ///
168    /// The session's `budget.model_family` determines which pricing to use.
169    /// Per-tool costs are derived from `session.tool_usage`.
170    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            // Estimate cache savings from the record's stored cost vs full price.
185            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        // Compression savings: tokens saved × input rate.
198        // We approximate from the conversation: original tokens vs consumed.
199        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// ---------------------------------------------------------------------------
226// Cache-aware savings estimate
227// ---------------------------------------------------------------------------
228
229/// Assumptions behind [`estimate_saved_cost`]. Token reduction is not the
230/// same thing as billed-cost reduction (arXiv:2607.12161): with provider
231/// prompt caching, a tool-output token is written to the cache once at a
232/// write premium and then re-read at a steep discount on every subsequent
233/// turn. A *saved* token therefore avoids one cache write plus N cache
234/// reads — not one list-price input token.
235#[derive(Debug, Clone)]
236pub struct SavingsAssumptions {
237    /// USD per 1M input tokens at the list price.
238    pub price_in_per_mtok: f64,
239    /// Cache-write premium over the input price (Anthropic: 1.25).
240    pub cache_write_mult: f64,
241    /// Cache-read discount multiplier (Anthropic: 0.10).
242    pub cache_read_mult: f64,
243    /// Expected number of subsequent turns that re-read the saved tokens
244    /// before the session ends or compacts.
245    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/// Output of [`estimate_saved_cost`].
260#[derive(Debug, Clone, PartialEq)]
261pub struct SavedCostEstimate {
262    /// Avoided cache-write cost (tokens enter the context once).
263    pub write_usd: f64,
264    /// Avoided cache-read cost over the assumed re-read turns.
265    pub reread_usd: f64,
266    /// write + reread.
267    pub total_usd: f64,
268}
269
270/// Estimate the billed cost avoided by `tokens_saved` under a
271/// prompt-cached serving model. This deliberately models the
272/// caching-enabled case, which is the conservative one: without caching
273/// every re-read bills at full input price and the figure would be much
274/// larger. Direct token cost only — trajectory effects (extra agent
275/// turns caused by missing detail) are not modeled here; the regret
276/// metrics in `sqz stats` exist to watch for those.
277pub 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// ---------------------------------------------------------------------------
289// Tests
290// ---------------------------------------------------------------------------
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::types::ToolUsageRecord;
296    use proptest::prelude::*;
297
298    // -----------------------------------------------------------------------
299    // Helpers / strategies
300    // -----------------------------------------------------------------------
301
302    #[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    // -----------------------------------------------------------------------
322    // Property 29: Cost calculation per-tool invariant
323    // Validates: Requirements 22.1, 22.2, 22.4
324    // -----------------------------------------------------------------------
325
326    proptest! {
327        /// **Validates: Requirements 22.1, 22.2, 22.4**
328        ///
329        /// Property 29: Cost calculation per-tool invariant.
330        ///
331        /// For any set of tool usage records in a session, the sum of per-tool
332        /// USD costs SHALL equal the total session USD cost.  The session cost
333        /// summary SHALL include total tokens, total USD, cache savings, and
334        /// compression savings.
335        #[test]
336        fn prop_cost_per_tool_invariant(
337            records in prop::collection::vec(
338                (
339                    "[a-z_]{1,16}",          // tool name
340                    0u32..=10_000u32,        // tokens_input
341                    0u32..=5_000u32,         // tokens_output
342                ),
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            // Build tool usage records with realistic costs.
356            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            // Total tokens must match sum of per-record tokens.
399            prop_assert_eq!(summary.total_tokens, expected_total_tokens);
400
401            // Total USD must match sum of per-record costs (within floating-point tolerance).
402            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    // -----------------------------------------------------------------------
412    // Property 4: Cache discount in cost calculation
413    // Validates: Requirements 4.3, 22.3
414    // -----------------------------------------------------------------------
415
416    proptest! {
417        /// **Validates: Requirements 4.3, 22.3**
418        ///
419        /// Property 4: Cache discount in cost calculation.
420        ///
421        /// For any token usage with an active prompt cache boundary, the
422        /// Cost_Calculator SHALL apply the provider-specific discount (90% for
423        /// Anthropic, 50% for OpenAI) to cached tokens, resulting in a lower
424        /// total cost than the same usage without caching.
425        #[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,  // at least 1 cached token
430            model in arb_model(),
431        ) {
432            let calc = CostCalculator::with_defaults();
433
434            // Usage WITH cache hits.
435            let with_cache = TokenUsage { input, output, cached_input };
436            // Same usage but all tokens treated as non-cached.
437            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                // With a non-zero discount, cached cost must be strictly lower.
451                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                // cache_savings_usd must be positive.
459                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                // Gemini: no discount → costs are equal.
466                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    // -----------------------------------------------------------------------
477    // Unit tests
478    // -----------------------------------------------------------------------
479
480    #[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        // 1k input @ $0.003 + 1k output @ $0.015 = $0.018
490        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        // 1k cached @ $0.003 × (1 - 0.9) = $0.0003
504        assert!((breakdown.total_usd - 0.0003).abs() < 1e-9);
505        // savings = $0.003 - $0.0003 = $0.0027
506        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        // 1k cached @ $0.002 × (1 - 0.5) = $0.001
519        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        // No discount: 2k input @ $0.001 = $0.002
533        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        // 1M tokens × $3/MTok × 1.25 write premium = $3.75
562        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        // write: $3.75; rereads: 1M × $3 × 0.10 × 10 turns = $3.00
571        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}