Skip to main content

adk_eval/
pricing.rs

1//! Per-model pricing configuration for cost estimation.
2//!
3//! This module provides pricing tables used by the [`CostTracker`](crate::CostTracker)
4//! to compute estimated dollar costs from token usage. Pricing is specified as
5//! cost per 1,000 tokens for both input and output.
6//!
7//! # Example
8//!
9//! ```rust
10//! use adk_eval::pricing::{ModelPricing, default_pricing};
11//!
12//! // Use built-in pricing for common models
13//! let pricing = default_pricing();
14//! assert!(!pricing.is_empty());
15//!
16//! // Create custom pricing for a specific model
17//! let custom = ModelPricing {
18//!     model_name: "my-custom-model".to_string(),
19//!     input_cost_per_1k: 0.001,
20//!     output_cost_per_1k: 0.002,
21//! };
22//! ```
23
24use serde::{Deserialize, Serialize};
25
26/// Per-model pricing configuration.
27///
28/// Defines the cost per 1,000 input and output tokens for a specific model.
29/// Used by [`CostTracker`](crate::CostTracker) to compute estimated dollar
30/// costs from token counts extracted during evaluation.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32pub struct ModelPricing {
33    /// Model identifier (e.g., "gemini-2.5-flash", "gpt-4o")
34    pub model_name: String,
35    /// Cost per 1,000 input tokens in USD
36    pub input_cost_per_1k: f64,
37    /// Cost per 1,000 output tokens in USD
38    pub output_cost_per_1k: f64,
39}
40
41impl ModelPricing {
42    /// Create a new `ModelPricing` entry.
43    ///
44    /// # Arguments
45    ///
46    /// * `model_name` - Model identifier string
47    /// * `input_cost_per_1k` - Cost per 1K input tokens (USD)
48    /// * `output_cost_per_1k` - Cost per 1K output tokens (USD)
49    ///
50    /// # Example
51    ///
52    /// ```rust
53    /// use adk_eval::pricing::ModelPricing;
54    ///
55    /// let pricing = ModelPricing::new("gpt-4o", 0.0025, 0.01);
56    /// assert_eq!(pricing.model_name, "gpt-4o");
57    /// ```
58    pub fn new(
59        model_name: impl Into<String>,
60        input_cost_per_1k: f64,
61        output_cost_per_1k: f64,
62    ) -> Self {
63        Self { model_name: model_name.into(), input_cost_per_1k, output_cost_per_1k }
64    }
65}
66
67/// Returns default pricing tables for current LLM models.
68///
69/// Rates are the vendors' published standard-tier list prices, converted to USD
70/// per 1,000 tokens, verified 2026-08-23 against:
71///
72/// - <https://ai.google.dev/gemini-api/docs/pricing>
73/// - <https://developers.openai.com/api/docs/pricing>
74/// - <https://docs.claude.com/en/docs/about-claude/pricing>
75/// - <https://api-docs.deepseek.com/quick_start/pricing>
76///
77/// Only text input and output are modelled. Prompt-cache reads, batch discounts,
78/// long-context tiers, audio and image rates, and DeepSeek's off-peak halving are
79/// not represented; use the provider crates' pricing modules for those.
80///
81/// # Example
82///
83/// ```rust
84/// use adk_eval::pricing::default_pricing;
85///
86/// let pricing = default_pricing();
87/// let flash = pricing.iter().find(|p| p.model_name == "gemini-3.7-flash");
88/// assert!(flash.is_some());
89/// ```
90pub fn default_pricing() -> Vec<ModelPricing> {
91    vec![
92        // Google Gemini. 3.7 and 3.6 Flash carry introductory rates that double
93        // on 2027-01-01.
94        ModelPricing::new("gemini-3.7-flash", 0.00075, 0.00375),
95        ModelPricing::new("gemini-3.6-flash", 0.00075, 0.00375),
96        ModelPricing::new("gemini-3.5-flash", 0.0015, 0.009),
97        ModelPricing::new("gemini-3.5-flash-lite", 0.0003, 0.0025),
98        ModelPricing::new("gemini-3.1-pro-preview", 0.002, 0.012),
99        ModelPricing::new("gemini-3.1-flash-lite", 0.00025, 0.0015),
100        ModelPricing::new("gemini-3-flash-preview", 0.0005, 0.003),
101        ModelPricing::new("gemini-2.5-pro", 0.00125, 0.01),
102        ModelPricing::new("gemini-2.5-flash", 0.0003, 0.0025),
103        ModelPricing::new("gemini-2.5-flash-lite", 0.0001, 0.0004),
104        // OpenAI
105        ModelPricing::new("gpt-5.6-sol", 0.004, 0.02),
106        ModelPricing::new("gpt-5.6-terra", 0.002, 0.012),
107        ModelPricing::new("gpt-5.6-luna", 0.0002, 0.0012),
108        ModelPricing::new("gpt-5.5", 0.005, 0.03),
109        ModelPricing::new("gpt-5.4", 0.0025, 0.015),
110        ModelPricing::new("gpt-5.4-mini", 0.00075, 0.0045),
111        ModelPricing::new("gpt-5.4-nano", 0.0002, 0.00125),
112        ModelPricing::new("gpt-5.3-codex", 0.00175, 0.014),
113        ModelPricing::new("gpt-5.2", 0.00175, 0.014),
114        ModelPricing::new("gpt-5.1", 0.00125, 0.01),
115        ModelPricing::new("gpt-5", 0.00125, 0.01),
116        ModelPricing::new("gpt-5-mini", 0.00025, 0.002),
117        ModelPricing::new("gpt-5-nano", 0.00005, 0.0004),
118        ModelPricing::new("gpt-4.1", 0.002, 0.008),
119        ModelPricing::new("gpt-4.1-mini", 0.0004, 0.0016),
120        ModelPricing::new("gpt-4o", 0.0025, 0.01),
121        ModelPricing::new("gpt-4o-mini", 0.00015, 0.0006),
122        ModelPricing::new("o3", 0.002, 0.008),
123        ModelPricing::new("o4-mini", 0.0011, 0.0044),
124        // Anthropic Claude
125        ModelPricing::new("claude-fable-5", 0.01, 0.05),
126        ModelPricing::new("claude-mythos-5", 0.01, 0.05),
127        ModelPricing::new("claude-opus-5", 0.005, 0.025),
128        ModelPricing::new("claude-opus-4-8", 0.005, 0.025),
129        ModelPricing::new("claude-sonnet-5", 0.002, 0.01),
130        ModelPricing::new("claude-sonnet-4-6", 0.003, 0.015),
131        ModelPricing::new("claude-haiku-4-5", 0.001, 0.005),
132        // DeepSeek. Peak (cache-miss) rates; off-peak is half.
133        ModelPricing::new("deepseek-flash", 0.00044, 0.00132),
134        // Legacy alias: still accepted by the API and served as deepseek-flash.
135        ModelPricing::new("deepseek-v4-flash", 0.00044, 0.00132),
136        ModelPricing::new("deepseek-v4-pro", 0.00132, 0.00396),
137    ]
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_model_pricing_new() {
146        let pricing = ModelPricing::new("test-model", 0.001, 0.002);
147        assert_eq!(pricing.model_name, "test-model");
148        assert_eq!(pricing.input_cost_per_1k, 0.001);
149        assert_eq!(pricing.output_cost_per_1k, 0.002);
150    }
151
152    #[test]
153    fn test_default_pricing_not_empty() {
154        let pricing = default_pricing();
155        assert!(!pricing.is_empty());
156    }
157
158    #[test]
159    fn test_default_pricing_includes_gemini() {
160        let pricing = default_pricing();
161        let gemini = pricing.iter().find(|p| p.model_name == "gemini-3.7-flash");
162        assert!(gemini.is_some());
163        let gemini = gemini.unwrap();
164        assert!(gemini.input_cost_per_1k > 0.0);
165        assert!(gemini.output_cost_per_1k > 0.0);
166    }
167
168    #[test]
169    fn test_default_pricing_includes_openai() {
170        let pricing = default_pricing();
171        let gpt = pricing.iter().find(|p| p.model_name == "gpt-5.6-terra");
172        assert!(gpt.is_some());
173        let gpt = gpt.unwrap();
174        assert!(gpt.input_cost_per_1k > 0.0);
175        assert!(gpt.output_cost_per_1k > 0.0);
176    }
177
178    #[test]
179    fn test_default_pricing_includes_anthropic() {
180        let pricing = default_pricing();
181        let claude = pricing.iter().find(|p| p.model_name == "claude-sonnet-5");
182        assert!(claude.is_some());
183        let claude = claude.unwrap();
184        assert!(claude.input_cost_per_1k > 0.0);
185        assert!(claude.output_cost_per_1k > 0.0);
186    }
187
188    /// The table must not carry models the vendors have shut down, and must not
189    /// duplicate a model ID.
190    #[test]
191    fn test_default_pricing_excludes_retired_models() {
192        let pricing = default_pricing();
193        for retired in [
194            "gemini-2.0-flash",
195            "gemini-2.0-flash-lite",
196            "gemini-3-pro-preview",
197            "claude-3-opus",
198            "claude-3-haiku",
199            "claude-3.5-haiku",
200            "claude-sonnet-4-20250514",
201            "deepseek-chat",
202            "deepseek-reasoner",
203            "gpt-4",
204            "gpt-4-turbo",
205            "gpt-3.5-turbo",
206        ] {
207            assert!(
208                !pricing.iter().any(|p| p.model_name == retired),
209                "{retired} is retired and must not be in the default table"
210            );
211        }
212
213        let mut names: Vec<&str> = pricing.iter().map(|p| p.model_name.as_str()).collect();
214        names.sort_unstable();
215        let count = names.len();
216        names.dedup();
217        assert_eq!(names.len(), count, "duplicate model IDs in default pricing");
218    }
219
220    /// Output must never be cheaper than input for these vendors' text models.
221    /// A swapped pair is the most common transcription error.
222    #[test]
223    fn test_output_never_cheaper_than_input() {
224        for model in default_pricing() {
225            assert!(
226                model.output_cost_per_1k >= model.input_cost_per_1k,
227                "{} output {} is below input {}",
228                model.model_name,
229                model.output_cost_per_1k,
230                model.input_cost_per_1k
231            );
232        }
233    }
234
235    #[test]
236    fn test_default_pricing_all_positive_costs() {
237        let pricing = default_pricing();
238        for model in &pricing {
239            assert!(
240                model.input_cost_per_1k >= 0.0,
241                "Model {} has negative input cost",
242                model.model_name
243            );
244            assert!(
245                model.output_cost_per_1k >= 0.0,
246                "Model {} has negative output cost",
247                model.model_name
248            );
249        }
250    }
251
252    #[test]
253    fn test_model_pricing_serialization_roundtrip() {
254        let pricing = ModelPricing::new("test-model", 0.001, 0.002);
255        let json = serde_json::to_string(&pricing).unwrap();
256        let deserialized: ModelPricing = serde_json::from_str(&json).unwrap();
257        assert_eq!(pricing, deserialized);
258    }
259
260    #[test]
261    fn test_default_pricing_unique_model_names() {
262        let pricing = default_pricing();
263        let mut names: Vec<&str> = pricing.iter().map(|p| p.model_name.as_str()).collect();
264        let original_len = names.len();
265        names.sort();
266        names.dedup();
267        assert_eq!(names.len(), original_len, "Default pricing contains duplicate model names");
268    }
269}