Skip to main content

codex_ops/
pricing.rs

1use serde::Deserialize;
2use std::collections::HashSet;
3use std::sync::LazyLock;
4
5#[derive(Debug, Clone, Copy, PartialEq)]
6pub struct TokenUsage {
7    pub input_tokens: u64,
8    pub cached_input_tokens: u64,
9    pub output_tokens: u64,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct ModelPricing {
14    pub key: &'static str,
15    pub label: &'static str,
16    pub input_credits_per_million: f64,
17    pub cached_input_credits_per_million: f64,
18    pub output_credits_per_million: f64,
19    pub note: Option<&'static str>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct RateCardSource {
24    pub name: &'static str,
25    pub checked_at: &'static str,
26    pub credit_to_usd: &'static str,
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct CreditCost {
31    pub priced: bool,
32    pub pricing_label: String,
33    pub unpriced_reason: Option<String>,
34    pub billable_input_tokens: u64,
35    pub cached_input_tokens: u64,
36    pub output_tokens: u64,
37    pub credits: f64,
38}
39
40const RATE_CARD_JSON: &str = include_str!("../data/codex-rate-card.json");
41
42static RATE_CARD: LazyLock<RateCard> = LazyLock::new(load_rate_card);
43pub static CODEX_RATE_CARD_SOURCE: LazyLock<RateCardSource> = LazyLock::new(|| rate_card().source);
44
45#[derive(Debug, Clone)]
46struct RateCard {
47    source: RateCardSource,
48    models: Vec<ModelPricing>,
49}
50
51#[derive(Debug, Deserialize)]
52struct RawRateCard {
53    source: RawRateCardSource,
54    models: Vec<RawModelPricing>,
55}
56
57#[derive(Debug, Deserialize)]
58struct RawRateCardSource {
59    name: String,
60    checked_at: String,
61    credit_to_usd: String,
62}
63
64#[derive(Debug, Deserialize)]
65struct RawModelPricing {
66    key: String,
67    label: String,
68    input_credits_per_million: f64,
69    cached_input_credits_per_million: f64,
70    output_credits_per_million: f64,
71    note: Option<String>,
72}
73
74pub fn normalize_model_name(model: &str) -> String {
75    model
76        .split_whitespace()
77        .collect::<Vec<_>>()
78        .join(" ")
79        .to_lowercase()
80}
81
82pub fn pricing_key_for_model(model: &str) -> String {
83    let normalized = normalize_model_name(model);
84    match normalized.as_str() {
85        "gpt-5.4 mini" => "gpt-5.4-mini".to_string(),
86        "gpt-5.3 codex" => "gpt-5.3-codex".to_string(),
87        "gpt-image-2:image"
88        | "gpt-image-2-image"
89        | "gpt-image-2 image"
90        | "gpt-image-2.0:image"
91        | "gpt-image-2.0-image"
92        | "gpt-image-2.0 image"
93        | "gpt-image-2.0 (image)" => "gpt-image-2 (image)".to_string(),
94        "gpt-image-2:text"
95        | "gpt-image-2-text"
96        | "gpt-image-2 text"
97        | "gpt-image-2.0:text"
98        | "gpt-image-2.0-text"
99        | "gpt-image-2.0 text"
100        | "gpt-image-2.0 (text)" => "gpt-image-2 (text)".to_string(),
101        _ => normalized,
102    }
103}
104
105pub fn get_model_pricing(model: &str) -> Option<ModelPricing> {
106    let key = pricing_key_for_model(model);
107    rate_card()
108        .models
109        .iter()
110        .copied()
111        .find(|pricing| pricing.key == key)
112}
113
114pub fn list_model_pricing() -> Vec<ModelPricing> {
115    let mut pricing = rate_card().models.clone();
116    pricing.sort_by(|left, right| left.key.cmp(right.key));
117    pricing
118}
119
120pub fn list_known_unpriced_models() -> Vec<ModelPricing> {
121    Vec::new()
122}
123
124pub fn calculate_credit_cost(model: &str, usage: TokenUsage) -> CreditCost {
125    let cached_input_tokens = usage.cached_input_tokens.min(usage.input_tokens);
126    let billable_input_tokens = usage.input_tokens.saturating_sub(cached_input_tokens);
127    let pricing = get_model_pricing(model);
128
129    match pricing {
130        Some(pricing) => CreditCost {
131            priced: true,
132            pricing_label: pricing.label.to_string(),
133            unpriced_reason: None,
134            billable_input_tokens,
135            cached_input_tokens,
136            output_tokens: usage.output_tokens,
137            credits: (billable_input_tokens as f64 * pricing.input_credits_per_million
138                + cached_input_tokens as f64 * pricing.cached_input_credits_per_million
139                + usage.output_tokens as f64 * pricing.output_credits_per_million)
140                / 1_000_000.0,
141        },
142        None => CreditCost {
143            priced: false,
144            pricing_label: model.to_string(),
145            unpriced_reason: None,
146            billable_input_tokens,
147            cached_input_tokens,
148            output_tokens: usage.output_tokens,
149            credits: 0.0,
150        },
151    }
152}
153
154fn rate_card() -> &'static RateCard {
155    &RATE_CARD
156}
157
158fn load_rate_card() -> RateCard {
159    let raw: RawRateCard = serde_json::from_str(RATE_CARD_JSON).unwrap_or_else(|error| {
160        panic!("Failed to parse data/codex-rate-card.json: {error}");
161    });
162    validate_rate_card(&raw);
163
164    RateCard {
165        source: RateCardSource {
166            name: leak_str(raw.source.name),
167            checked_at: leak_str(raw.source.checked_at),
168            credit_to_usd: leak_str(raw.source.credit_to_usd),
169        },
170        models: raw
171            .models
172            .into_iter()
173            .map(|model| ModelPricing {
174                key: leak_str(model.key),
175                label: leak_str(model.label),
176                input_credits_per_million: model.input_credits_per_million,
177                cached_input_credits_per_million: model.cached_input_credits_per_million,
178                output_credits_per_million: model.output_credits_per_million,
179                note: model.note.map(leak_str),
180            })
181            .collect(),
182    }
183}
184
185fn validate_rate_card(raw: &RawRateCard) {
186    assert_non_empty(&raw.source.name, "source.name");
187    assert_non_empty(&raw.source.checked_at, "source.checked_at");
188    assert_non_empty(&raw.source.credit_to_usd, "source.credit_to_usd");
189
190    if raw.models.is_empty() {
191        panic!("data/codex-rate-card.json must define at least one model");
192    }
193
194    let mut keys = HashSet::new();
195    for model in &raw.models {
196        assert_non_empty(&model.key, "models[].key");
197        assert_non_empty(&model.label, "models[].label");
198        if !keys.insert(model.key.as_str()) {
199            panic!(
200                "data/codex-rate-card.json has duplicate model key: {}",
201                model.key
202            );
203        }
204        assert_non_negative_finite(
205            model.input_credits_per_million,
206            "models[].input_credits_per_million",
207        );
208        assert_non_negative_finite(
209            model.cached_input_credits_per_million,
210            "models[].cached_input_credits_per_million",
211        );
212        assert_non_negative_finite(
213            model.output_credits_per_million,
214            "models[].output_credits_per_million",
215        );
216    }
217}
218
219fn assert_non_empty(value: &str, path: &str) {
220    if value.trim().is_empty() {
221        panic!("data/codex-rate-card.json field {path} cannot be empty");
222    }
223}
224
225fn assert_non_negative_finite(value: f64, path: &str) {
226    if !value.is_finite() || value < 0.0 {
227        panic!("data/codex-rate-card.json field {path} must be finite and non-negative");
228    }
229}
230
231fn leak_str(value: String) -> &'static str {
232    Box::leak(value.into_boxed_str())
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn normalizes_model_names_and_aliases() {
241        assert_eq!(normalize_model_name("  GPT-5.4   MINI "), "gpt-5.4 mini");
242        assert_eq!(pricing_key_for_model("GPT-5.4   MINI"), "gpt-5.4-mini");
243        assert_eq!(
244            get_model_pricing("gpt-image-2.0:image")
245                .expect("image pricing")
246                .label,
247            "GPT-Image-2 (image)"
248        );
249    }
250
251    #[test]
252    fn calculates_credit_cost_from_billable_cached_and_output_tokens() {
253        let cost = calculate_credit_cost(
254            "gpt-5.5",
255            TokenUsage {
256                input_tokens: 1000,
257                cached_input_tokens: 200,
258                output_tokens: 300,
259            },
260        );
261
262        assert!(cost.priced);
263        assert_eq!(cost.pricing_label, "GPT-5.5");
264        assert_eq!(cost.billable_input_tokens, 800);
265        assert_eq!(cost.cached_input_tokens, 200);
266        assert_eq!(cost.output_tokens, 300);
267        assert!((cost.credits - 0.3275).abs() < 0.000001);
268    }
269
270    #[test]
271    fn clamps_cached_input_and_handles_unknown_models() {
272        let cost = calculate_credit_cost(
273            "future-model",
274            TokenUsage {
275                input_tokens: 100,
276                cached_input_tokens: 250,
277                output_tokens: 50,
278            },
279        );
280
281        assert!(!cost.priced);
282        assert_eq!(cost.pricing_label, "future-model");
283        assert_eq!(cost.billable_input_tokens, 0);
284        assert_eq!(cost.cached_input_tokens, 100);
285        assert_eq!(cost.credits, 0.0);
286    }
287
288    #[test]
289    fn spark_model_is_priced_at_zero_credits() {
290        let cost = calculate_credit_cost(
291            "gpt-5.3-codex-spark",
292            TokenUsage {
293                input_tokens: 500,
294                cached_input_tokens: 0,
295                output_tokens: 100,
296            },
297        );
298
299        assert!(cost.priced);
300        assert_eq!(cost.pricing_label, "GPT-5.3-Codex-Spark");
301        assert_eq!(cost.credits, 0.0);
302    }
303
304    #[test]
305    fn pricing_inventory_is_sorted() {
306        let keys = list_model_pricing()
307            .into_iter()
308            .map(|pricing| pricing.key)
309            .collect::<Vec<_>>();
310
311        assert_eq!(keys.first(), Some(&"gpt-5.2"));
312        assert!(keys.contains(&"gpt-5.5"));
313    }
314
315    #[test]
316    fn loads_source_metadata_from_static_rate_card() {
317        assert_eq!(
318            CODEX_RATE_CARD_SOURCE.name,
319            "OpenAI Help Center Codex rate card"
320        );
321        assert_eq!(CODEX_RATE_CARD_SOURCE.checked_at, "2026-05-13");
322        assert_eq!(CODEX_RATE_CARD_SOURCE.credit_to_usd, "25 credits = $1");
323        assert_eq!(list_model_pricing().len(), 8);
324    }
325}