Skip to main content

difflore_core/observability/
cost.rs

1//! Review cost transparency.
2//!
3//! A small pricing table for the LLM models `DiffLore` actually calls, plus
4//! a helper to estimate the USD cost of a single review turn from the
5//! provider's returned `usage` block.
6//!
7//! The table is intentionally conservative: unknown models return `None`
8//! rather than guessing, so downstream code can persist `NULL` and the
9//! cloud aggregation skips the row instead of under-reporting.
10
11/// Per-1K-token pricing for a single LLM model. Mirrors the public pricing
12/// pages for each provider as of the plan date; update when providers
13/// change their rate cards.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct ModelPricing {
16    pub input_usd_per_1k: f64,
17    pub output_usd_per_1k: f64,
18}
19
20/// Look up the per-1K pricing for a model. Returns `None` for unknown
21/// identifiers so callers can record `NULL` and avoid under/over-reporting.
22pub fn pricing_for(model: &str) -> Option<ModelPricing> {
23    match model {
24        // ── Anthropic ──
25        // Sonnet 4 snapshot ids share the same published rate card. Keep both
26        // entries so archived fix_runs.ai_model values still resolve.
27        "claude-sonnet-4-20250514" | "claude-sonnet-4-6" => Some(ModelPricing {
28            input_usd_per_1k: 0.003,
29            output_usd_per_1k: 0.015,
30        }),
31        "claude-haiku-4-5-20251001" | "claude-haiku-4-5" => Some(ModelPricing {
32            input_usd_per_1k: 0.0008,
33            output_usd_per_1k: 0.004,
34        }),
35        "claude-opus-4-6" | "claude-opus-4-7" => Some(ModelPricing {
36            input_usd_per_1k: 0.015,
37            output_usd_per_1k: 0.075,
38        }),
39        // ── OpenAI ──
40        "gpt-4o" => Some(ModelPricing {
41            input_usd_per_1k: 0.005,
42            output_usd_per_1k: 0.015,
43        }),
44        "gpt-4o-mini" => Some(ModelPricing {
45            input_usd_per_1k: 0.00015,
46            output_usd_per_1k: 0.0006,
47        }),
48        _ => None,
49    }
50}
51
52/// Compute the estimated USD cost of a single LLM call.
53///
54/// Returns `None` for unknown models. The arithmetic is deliberately simple
55/// (no rounding, no currency conversion); downstream consumers persist the
56/// value in a `numeric(10, 6)` column where it will be rounded once.
57pub fn estimate_cost_usd(model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
58    let p = pricing_for(model)?;
59    let cost = (f64::from(input_tokens) / 1000.0).mul_add(
60        p.input_usd_per_1k,
61        (f64::from(output_tokens) / 1000.0) * p.output_usd_per_1k,
62    );
63    Some(cost)
64}
65
66#[cfg(test)]
67#[allow(
68    clippy::expect_used,
69    clippy::panic,
70    clippy::unwrap_used,
71    clippy::float_cmp
72)] // reason: tests assert exact values
73mod tests {
74    use super::*;
75
76    #[test]
77    fn pricing_for_known_models_table() {
78        // (model, input_per_1k, output_per_1k). Sonnet 4 aliases share a rate
79        // card so archived fix_runs values still resolve.
80        let cases: &[(&str, f64, f64)] = &[
81            ("claude-sonnet-4-20250514", 0.003, 0.015),
82            ("claude-sonnet-4-6", 0.003, 0.015),
83            ("claude-haiku-4-5-20251001", 0.0008, 0.004),
84            ("claude-opus-4-6", 0.015, 0.075),
85            ("gpt-4o", 0.005, 0.015),
86            ("gpt-4o-mini", 0.00015, 0.0006),
87        ];
88        for (model, input, output) in cases {
89            let p = pricing_for(model).unwrap_or_else(|| panic!("missing: {model}"));
90            assert_eq!(p.input_usd_per_1k, *input, "model: {model}");
91            assert_eq!(p.output_usd_per_1k, *output, "model: {model}");
92        }
93    }
94
95    #[test]
96    fn pricing_for_unknown_or_miscased_model_returns_none() {
97        // Strict lookup: never silently attribute Sonnet pricing to a
98        // miscapitalised tag.
99        for m in ["", "gpt-5-secret", "CLAUDE-SONNET-4-20250514", "GPT-4o"] {
100            assert!(pricing_for(m).is_none(), "unexpectedly priced: {m}");
101        }
102    }
103
104    #[test]
105    fn estimate_cost_usd_linear_and_edge_cases() {
106        // Sonnet: 1000 in + 500 out = 0.003 + 0.5 * 0.015 = 0.0105
107        let cost = estimate_cost_usd("claude-sonnet-4-20250514", 1000, 500).unwrap();
108        assert!((cost - 0.0105).abs() < 1e-9, "sonnet got {cost}");
109
110        // gpt-4o-mini: 10_000 in + 1_000 out = 0.0015 + 0.0006 = 0.0021
111        let cost = estimate_cost_usd("gpt-4o-mini", 10_000, 1_000).unwrap();
112        assert!((cost - 0.0021).abs() < 1e-9, "mini got {cost}");
113
114        // Edge cases: zero tokens, unknown model.
115        assert_eq!(
116            estimate_cost_usd("claude-sonnet-4-20250514", 0, 0).unwrap(),
117            0.0
118        );
119        assert!(estimate_cost_usd("unknown-model", 1000, 500).is_none());
120
121        // Output dominates total for typical review turns (Sonnet rate card).
122        let cost = estimate_cost_usd("claude-sonnet-4-20250514", 2_000, 500).unwrap();
123        let input_cost = (2_000.0 / 1000.0) * 0.003;
124        let output_cost = (500.0 / 1000.0) * 0.015;
125        assert!((cost - (input_cost + output_cost)).abs() < 1e-9);
126        assert!(output_cost > input_cost);
127    }
128}