use serde::Deserialize;
use std::collections::HashSet;
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TokenUsage {
pub input_tokens: u64,
pub cached_input_tokens: u64,
pub output_tokens: u64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelPricing {
pub key: &'static str,
pub label: &'static str,
pub input_credits_per_million: f64,
pub cached_input_credits_per_million: f64,
pub output_credits_per_million: f64,
pub note: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RateCardSource {
pub name: &'static str,
pub checked_at: &'static str,
pub credit_to_usd: &'static str,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CreditCost {
pub priced: bool,
pub pricing_label: String,
pub unpriced_reason: Option<String>,
pub billable_input_tokens: u64,
pub cached_input_tokens: u64,
pub output_tokens: u64,
pub credits: f64,
}
const RATE_CARD_JSON: &str = include_str!("../data/codex-rate-card.json");
static RATE_CARD: LazyLock<RateCard> = LazyLock::new(load_rate_card);
pub static CODEX_RATE_CARD_SOURCE: LazyLock<RateCardSource> = LazyLock::new(|| rate_card().source);
#[derive(Debug, Clone)]
struct RateCard {
source: RateCardSource,
models: Vec<ModelPricing>,
}
#[derive(Debug, Deserialize)]
struct RawRateCard {
source: RawRateCardSource,
models: Vec<RawModelPricing>,
}
#[derive(Debug, Deserialize)]
struct RawRateCardSource {
name: String,
checked_at: String,
credit_to_usd: String,
}
#[derive(Debug, Deserialize)]
struct RawModelPricing {
key: String,
label: String,
input_credits_per_million: f64,
cached_input_credits_per_million: f64,
output_credits_per_million: f64,
note: Option<String>,
}
pub fn normalize_model_name(model: &str) -> String {
model
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
pub fn pricing_key_for_model(model: &str) -> String {
let normalized = normalize_model_name(model);
match normalized.as_str() {
"gpt-5.4 mini" => "gpt-5.4-mini".to_string(),
"gpt-5.3 codex" => "gpt-5.3-codex".to_string(),
"gpt-image-2:image"
| "gpt-image-2-image"
| "gpt-image-2 image"
| "gpt-image-2.0:image"
| "gpt-image-2.0-image"
| "gpt-image-2.0 image"
| "gpt-image-2.0 (image)" => "gpt-image-2 (image)".to_string(),
"gpt-image-2:text"
| "gpt-image-2-text"
| "gpt-image-2 text"
| "gpt-image-2.0:text"
| "gpt-image-2.0-text"
| "gpt-image-2.0 text"
| "gpt-image-2.0 (text)" => "gpt-image-2 (text)".to_string(),
_ => normalized,
}
}
pub fn get_model_pricing(model: &str) -> Option<ModelPricing> {
let key = pricing_key_for_model(model);
rate_card()
.models
.iter()
.copied()
.find(|pricing| pricing.key == key)
}
pub fn list_model_pricing() -> Vec<ModelPricing> {
let mut pricing = rate_card().models.clone();
pricing.sort_by(|left, right| left.key.cmp(right.key));
pricing
}
pub fn list_known_unpriced_models() -> Vec<ModelPricing> {
Vec::new()
}
pub fn calculate_credit_cost(model: &str, usage: TokenUsage) -> CreditCost {
let cached_input_tokens = usage.cached_input_tokens.min(usage.input_tokens);
let billable_input_tokens = usage.input_tokens.saturating_sub(cached_input_tokens);
let pricing = get_model_pricing(model);
match pricing {
Some(pricing) => CreditCost {
priced: true,
pricing_label: pricing.label.to_string(),
unpriced_reason: None,
billable_input_tokens,
cached_input_tokens,
output_tokens: usage.output_tokens,
credits: (billable_input_tokens as f64 * pricing.input_credits_per_million
+ cached_input_tokens as f64 * pricing.cached_input_credits_per_million
+ usage.output_tokens as f64 * pricing.output_credits_per_million)
/ 1_000_000.0,
},
None => CreditCost {
priced: false,
pricing_label: model.to_string(),
unpriced_reason: None,
billable_input_tokens,
cached_input_tokens,
output_tokens: usage.output_tokens,
credits: 0.0,
},
}
}
fn rate_card() -> &'static RateCard {
&RATE_CARD
}
fn load_rate_card() -> RateCard {
let raw: RawRateCard = serde_json::from_str(RATE_CARD_JSON).unwrap_or_else(|error| {
panic!("Failed to parse data/codex-rate-card.json: {error}");
});
validate_rate_card(&raw);
RateCard {
source: RateCardSource {
name: leak_str(raw.source.name),
checked_at: leak_str(raw.source.checked_at),
credit_to_usd: leak_str(raw.source.credit_to_usd),
},
models: raw
.models
.into_iter()
.map(|model| ModelPricing {
key: leak_str(model.key),
label: leak_str(model.label),
input_credits_per_million: model.input_credits_per_million,
cached_input_credits_per_million: model.cached_input_credits_per_million,
output_credits_per_million: model.output_credits_per_million,
note: model.note.map(leak_str),
})
.collect(),
}
}
fn validate_rate_card(raw: &RawRateCard) {
assert_non_empty(&raw.source.name, "source.name");
assert_non_empty(&raw.source.checked_at, "source.checked_at");
assert_non_empty(&raw.source.credit_to_usd, "source.credit_to_usd");
if raw.models.is_empty() {
panic!("data/codex-rate-card.json must define at least one model");
}
let mut keys = HashSet::new();
for model in &raw.models {
assert_non_empty(&model.key, "models[].key");
assert_non_empty(&model.label, "models[].label");
if !keys.insert(model.key.as_str()) {
panic!(
"data/codex-rate-card.json has duplicate model key: {}",
model.key
);
}
assert_non_negative_finite(
model.input_credits_per_million,
"models[].input_credits_per_million",
);
assert_non_negative_finite(
model.cached_input_credits_per_million,
"models[].cached_input_credits_per_million",
);
assert_non_negative_finite(
model.output_credits_per_million,
"models[].output_credits_per_million",
);
}
}
fn assert_non_empty(value: &str, path: &str) {
if value.trim().is_empty() {
panic!("data/codex-rate-card.json field {path} cannot be empty");
}
}
fn assert_non_negative_finite(value: f64, path: &str) {
if !value.is_finite() || value < 0.0 {
panic!("data/codex-rate-card.json field {path} must be finite and non-negative");
}
}
fn leak_str(value: String) -> &'static str {
Box::leak(value.into_boxed_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_model_names_and_aliases() {
assert_eq!(normalize_model_name(" GPT-5.4 MINI "), "gpt-5.4 mini");
assert_eq!(pricing_key_for_model("GPT-5.4 MINI"), "gpt-5.4-mini");
assert_eq!(
get_model_pricing("gpt-image-2.0:image")
.expect("image pricing")
.label,
"GPT-Image-2 (image)"
);
}
#[test]
fn calculates_credit_cost_from_billable_cached_and_output_tokens() {
let cost = calculate_credit_cost(
"gpt-5.5",
TokenUsage {
input_tokens: 1000,
cached_input_tokens: 200,
output_tokens: 300,
},
);
assert!(cost.priced);
assert_eq!(cost.pricing_label, "GPT-5.5");
assert_eq!(cost.billable_input_tokens, 800);
assert_eq!(cost.cached_input_tokens, 200);
assert_eq!(cost.output_tokens, 300);
assert!((cost.credits - 0.3275).abs() < 0.000001);
}
#[test]
fn clamps_cached_input_and_handles_unknown_models() {
let cost = calculate_credit_cost(
"future-model",
TokenUsage {
input_tokens: 100,
cached_input_tokens: 250,
output_tokens: 50,
},
);
assert!(!cost.priced);
assert_eq!(cost.pricing_label, "future-model");
assert_eq!(cost.billable_input_tokens, 0);
assert_eq!(cost.cached_input_tokens, 100);
assert_eq!(cost.credits, 0.0);
}
#[test]
fn spark_model_is_priced_at_zero_credits() {
let cost = calculate_credit_cost(
"gpt-5.3-codex-spark",
TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
output_tokens: 100,
},
);
assert!(cost.priced);
assert_eq!(cost.pricing_label, "GPT-5.3-Codex-Spark");
assert_eq!(cost.credits, 0.0);
}
#[test]
fn pricing_inventory_is_sorted() {
let keys = list_model_pricing()
.into_iter()
.map(|pricing| pricing.key)
.collect::<Vec<_>>();
assert_eq!(keys.first(), Some(&"gpt-5.2"));
assert!(keys.contains(&"gpt-5.5"));
}
#[test]
fn loads_source_metadata_from_static_rate_card() {
assert_eq!(
CODEX_RATE_CARD_SOURCE.name,
"OpenAI Help Center Codex rate card"
);
assert_eq!(CODEX_RATE_CARD_SOURCE.checked_at, "2026-05-13");
assert_eq!(CODEX_RATE_CARD_SOURCE.credit_to_usd, "25 credits = $1");
assert_eq!(list_model_pricing().len(), 8);
}
}