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}