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-v4-flash", 0.00044, 0.00132),
134        ModelPricing::new("deepseek-v4-pro", 0.00132, 0.00396),
135    ]
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_model_pricing_new() {
144        let pricing = ModelPricing::new("test-model", 0.001, 0.002);
145        assert_eq!(pricing.model_name, "test-model");
146        assert_eq!(pricing.input_cost_per_1k, 0.001);
147        assert_eq!(pricing.output_cost_per_1k, 0.002);
148    }
149
150    #[test]
151    fn test_default_pricing_not_empty() {
152        let pricing = default_pricing();
153        assert!(!pricing.is_empty());
154    }
155
156    #[test]
157    fn test_default_pricing_includes_gemini() {
158        let pricing = default_pricing();
159        let gemini = pricing.iter().find(|p| p.model_name == "gemini-3.7-flash");
160        assert!(gemini.is_some());
161        let gemini = gemini.unwrap();
162        assert!(gemini.input_cost_per_1k > 0.0);
163        assert!(gemini.output_cost_per_1k > 0.0);
164    }
165
166    #[test]
167    fn test_default_pricing_includes_openai() {
168        let pricing = default_pricing();
169        let gpt = pricing.iter().find(|p| p.model_name == "gpt-5.6-terra");
170        assert!(gpt.is_some());
171        let gpt = gpt.unwrap();
172        assert!(gpt.input_cost_per_1k > 0.0);
173        assert!(gpt.output_cost_per_1k > 0.0);
174    }
175
176    #[test]
177    fn test_default_pricing_includes_anthropic() {
178        let pricing = default_pricing();
179        let claude = pricing.iter().find(|p| p.model_name == "claude-sonnet-5");
180        assert!(claude.is_some());
181        let claude = claude.unwrap();
182        assert!(claude.input_cost_per_1k > 0.0);
183        assert!(claude.output_cost_per_1k > 0.0);
184    }
185
186    /// The table must not carry models the vendors have shut down, and must not
187    /// duplicate a model ID.
188    #[test]
189    fn test_default_pricing_excludes_retired_models() {
190        let pricing = default_pricing();
191        for retired in [
192            "gemini-2.0-flash",
193            "gemini-2.0-flash-lite",
194            "gemini-3-pro-preview",
195            "claude-3-opus",
196            "claude-3-haiku",
197            "claude-3.5-haiku",
198            "claude-sonnet-4-20250514",
199            "deepseek-chat",
200            "deepseek-reasoner",
201            "gpt-4",
202            "gpt-4-turbo",
203            "gpt-3.5-turbo",
204        ] {
205            assert!(
206                !pricing.iter().any(|p| p.model_name == retired),
207                "{retired} is retired and must not be in the default table"
208            );
209        }
210
211        let mut names: Vec<&str> = pricing.iter().map(|p| p.model_name.as_str()).collect();
212        names.sort_unstable();
213        let count = names.len();
214        names.dedup();
215        assert_eq!(names.len(), count, "duplicate model IDs in default pricing");
216    }
217
218    /// Output must never be cheaper than input for these vendors' text models.
219    /// A swapped pair is the most common transcription error.
220    #[test]
221    fn test_output_never_cheaper_than_input() {
222        for model in default_pricing() {
223            assert!(
224                model.output_cost_per_1k >= model.input_cost_per_1k,
225                "{} output {} is below input {}",
226                model.model_name,
227                model.output_cost_per_1k,
228                model.input_cost_per_1k
229            );
230        }
231    }
232
233    #[test]
234    fn test_default_pricing_all_positive_costs() {
235        let pricing = default_pricing();
236        for model in &pricing {
237            assert!(
238                model.input_cost_per_1k >= 0.0,
239                "Model {} has negative input cost",
240                model.model_name
241            );
242            assert!(
243                model.output_cost_per_1k >= 0.0,
244                "Model {} has negative output cost",
245                model.model_name
246            );
247        }
248    }
249
250    #[test]
251    fn test_model_pricing_serialization_roundtrip() {
252        let pricing = ModelPricing::new("test-model", 0.001, 0.002);
253        let json = serde_json::to_string(&pricing).unwrap();
254        let deserialized: ModelPricing = serde_json::from_str(&json).unwrap();
255        assert_eq!(pricing, deserialized);
256    }
257
258    #[test]
259    fn test_default_pricing_unique_model_names() {
260        let pricing = default_pricing();
261        let mut names: Vec<&str> = pricing.iter().map(|p| p.model_name.as_str()).collect();
262        let original_len = names.len();
263        names.sort();
264        names.dedup();
265        assert_eq!(names.len(), original_len, "Default pricing contains duplicate model names");
266    }
267}