diff --git a/src/models.rs b/src/models.rs index ceb4906..22adb9c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -123,6 +123,9 @@ pub struct DatedPricing { pub valid_until: NaiveDate, pub pricing: PricingStructure, pub caching: CachingSupport, + /// Optional service-tier rates for the same historical window. + #[serde(default)] + pub service_tiers: HashMap, } /// How a provider reports input tokens relative to cache reads. @@ -195,10 +198,12 @@ impl Registry { .service_tiers .values() .all(|tier| Self::validate_pricing_and_caching(&tier.pricing, &tier.caching)) - && info - .dated_pricing - .iter() - .all(|dated| Self::validate_pricing_and_caching(&dated.pricing, &dated.caching)) + && info.dated_pricing.iter().all(|dated| { + Self::validate_pricing_and_caching(&dated.pricing, &dated.caching) + && dated.service_tiers.values().all(|tier| { + Self::validate_pricing_and_caching(&tier.pricing, &tier.caching) + }) + }) } fn validate_pricing_and_caching(pricing: &PricingStructure, caching: &CachingSupport) -> bool { @@ -312,6 +317,7 @@ fn populate_defaults( valid_until: $valid_until, pricing: $pricing, caching: $caching, + service_tiers: HashMap::new(), }); model_info .dated_pricing @@ -320,6 +326,26 @@ fn populate_defaults( }; } + macro_rules! add_dated_service_tier_pricing { + ($name:expr, $valid_until:expr, $service_tier:expr, $pricing:expr, $caching:expr) => { + if let Some(model_info) = index.get_mut($name) + && let Some(model_info) = Arc::get_mut(model_info) + && let Some(dated) = model_info + .dated_pricing + .iter_mut() + .find(|dated| dated.valid_until == $valid_until) + { + dated.service_tiers.insert( + $service_tier, + ServiceTierPricing { + pricing: $pricing, + caching: $caching, + }, + ); + } + }; + } + macro_rules! add_service_tier_pricing { ($name:expr, $service_tier:expr, $pricing:expr, $caching:expr) => { if let Some(model_info) = index.get_mut($name) @@ -363,23 +389,6 @@ fn populate_defaults( }; } - macro_rules! add_flat_service_tier_pricing_with_cache_writes { - ($name:expr, $service_tier:expr, $input:expr, $cache_write:expr, $cache_read:expr, $output:expr) => { - add_service_tier_pricing!( - $name, - $service_tier, - PricingStructure::Flat { - input_per_1m: $input, - output_per_1m: $output, - }, - CachingSupport::OpenAIWithWrites { - cache_write_per_1m: $cache_write, - cache_read_per_1m: $cache_read, - } - ); - }; - } - macro_rules! add_tiered_service_tier_pricing_with_cache_writes { ( $name:expr, @@ -958,51 +967,139 @@ fn populate_defaults( ); add_model!( "gpt-5.6-terra", - PricingStructure::Flat { - input_per_1m: 2.0, - output_per_1m: 12.0 - }, - CachingSupport::OpenAIWithWrites { - cache_write_per_1m: 2.5, - cache_read_per_1m: 0.20 - }, + PricingStructure::Tiered(TieredPricing { + tiers: vec![ + PricingTier { + max_tokens: Some(272_000), + input_per_1m: 2.0, + output_per_1m: 12.0 + }, + PricingTier { + max_tokens: None, + input_per_1m: 4.0, + output_per_1m: 18.0 + }, + ], + bracket_pricing: true, + }), + CachingSupport::TieredWithWrites(TieredCachingWithWrites { + tiers: vec![ + CachingTierWithWrites { + max_tokens: Some(272_000), + cache_write_per_1m: 2.5, + cache_read_per_1m: 0.20 + }, + CachingTierWithWrites { + max_tokens: None, + cache_write_per_1m: 5.0, + cache_read_per_1m: 0.40 + }, + ], + bracket_pricing: true, + }), false ); add_dated_pricing!( "gpt-5.6-terra", NaiveDate::from_ymd_opt(2026, 7, 30).expect("valid date"), - PricingStructure::Flat { - input_per_1m: 2.50, - output_per_1m: 15.0 - }, - CachingSupport::OpenAIWithWrites { - cache_write_per_1m: 3.125, - cache_read_per_1m: 0.25 - } + PricingStructure::Tiered(TieredPricing { + tiers: vec![ + PricingTier { + max_tokens: Some(272_000), + input_per_1m: 2.50, + output_per_1m: 15.0 + }, + PricingTier { + max_tokens: None, + input_per_1m: 5.0, + output_per_1m: 22.50 + }, + ], + bracket_pricing: true, + }), + CachingSupport::TieredWithWrites(TieredCachingWithWrites { + tiers: vec![ + CachingTierWithWrites { + max_tokens: Some(272_000), + cache_write_per_1m: 3.125, + cache_read_per_1m: 0.25 + }, + CachingTierWithWrites { + max_tokens: None, + cache_write_per_1m: 6.25, + cache_read_per_1m: 0.50 + }, + ], + bracket_pricing: true, + }) ); add_model!( "gpt-5.6-luna", - PricingStructure::Flat { - input_per_1m: 0.20, - output_per_1m: 1.20 - }, - CachingSupport::OpenAIWithWrites { - cache_write_per_1m: 0.25, - cache_read_per_1m: 0.02 - }, + PricingStructure::Tiered(TieredPricing { + tiers: vec![ + PricingTier { + max_tokens: Some(272_000), + input_per_1m: 0.20, + output_per_1m: 1.20 + }, + PricingTier { + max_tokens: None, + input_per_1m: 0.40, + output_per_1m: 1.80 + }, + ], + bracket_pricing: true, + }), + CachingSupport::TieredWithWrites(TieredCachingWithWrites { + tiers: vec![ + CachingTierWithWrites { + max_tokens: Some(272_000), + cache_write_per_1m: 0.25, + cache_read_per_1m: 0.02 + }, + CachingTierWithWrites { + max_tokens: None, + cache_write_per_1m: 0.50, + cache_read_per_1m: 0.04 + }, + ], + bracket_pricing: true, + }), false ); add_dated_pricing!( "gpt-5.6-luna", NaiveDate::from_ymd_opt(2026, 7, 30).expect("valid date"), - PricingStructure::Flat { - input_per_1m: 1.0, - output_per_1m: 6.0 - }, - CachingSupport::OpenAIWithWrites { - cache_write_per_1m: 1.25, - cache_read_per_1m: 0.10 - } + PricingStructure::Tiered(TieredPricing { + tiers: vec![ + PricingTier { + max_tokens: Some(272_000), + input_per_1m: 1.0, + output_per_1m: 6.0 + }, + PricingTier { + max_tokens: None, + input_per_1m: 2.0, + output_per_1m: 9.0 + }, + ], + bracket_pricing: true, + }), + CachingSupport::TieredWithWrites(TieredCachingWithWrites { + tiers: vec![ + CachingTierWithWrites { + max_tokens: Some(272_000), + cache_write_per_1m: 1.25, + cache_read_per_1m: 0.10 + }, + CachingTierWithWrites { + max_tokens: None, + cache_write_per_1m: 2.50, + cache_read_per_1m: 0.20 + }, + ], + bracket_pricing: true, + }) ); add_model!( @@ -1052,22 +1149,174 @@ fn populate_defaults( 2.0, 90.0 ); - add_flat_service_tier_pricing_with_cache_writes!( + add_tiered_service_tier_pricing_with_cache_writes!( "gpt-5.6-terra", ServiceTier::Priority, + 4.0, 5.0, - 6.25, + 0.40, + 24.0, + 8.0, + 10.0, + 0.80, + 36.0 + ); + add_tiered_service_tier_pricing_with_cache_writes!( + "gpt-5.6-luna", + ServiceTier::Priority, + 0.40, 0.50, - 30.0 + 0.04, + 2.40, + 0.80, + 1.0, + 0.08, + 3.60 + ); + let pre_cut = NaiveDate::from_ymd_opt(2026, 7, 30).expect("valid date"); + add_dated_service_tier_pricing!( + "gpt-5.6-terra", + pre_cut, + ServiceTier::Priority, + PricingStructure::Tiered(TieredPricing { + tiers: vec![ + PricingTier { + max_tokens: Some(272_000), + input_per_1m: 5.0, + output_per_1m: 30.0, + }, + PricingTier { + max_tokens: None, + input_per_1m: 10.0, + output_per_1m: 45.0, + }, + ], + bracket_pricing: true, + }), + CachingSupport::TieredWithWrites(TieredCachingWithWrites { + tiers: vec![ + CachingTierWithWrites { + max_tokens: Some(272_000), + cache_write_per_1m: 6.25, + cache_read_per_1m: 0.50, + }, + CachingTierWithWrites { + max_tokens: None, + cache_write_per_1m: 12.50, + cache_read_per_1m: 1.0, + }, + ], + bracket_pricing: true, + }) ); - add_flat_service_tier_pricing_with_cache_writes!( + add_dated_service_tier_pricing!( "gpt-5.6-luna", + pre_cut, ServiceTier::Priority, - 2.0, - 2.50, - 0.20, - 12.0 + PricingStructure::Tiered(TieredPricing { + tiers: vec![ + PricingTier { + max_tokens: Some(272_000), + input_per_1m: 2.0, + output_per_1m: 12.0, + }, + PricingTier { + max_tokens: None, + input_per_1m: 4.0, + output_per_1m: 18.0, + }, + ], + bracket_pricing: true, + }), + CachingSupport::TieredWithWrites(TieredCachingWithWrites { + tiers: vec![ + CachingTierWithWrites { + max_tokens: Some(272_000), + cache_write_per_1m: 2.50, + cache_read_per_1m: 0.20, + }, + CachingTierWithWrites { + max_tokens: None, + cache_write_per_1m: 5.0, + cache_read_per_1m: 0.40, + }, + ], + bracket_pricing: true, + }) ); + for service_tier in [ServiceTier::Flex, ServiceTier::Batch] { + add_dated_service_tier_pricing!( + "gpt-5.6-terra", + pre_cut, + service_tier, + PricingStructure::Tiered(TieredPricing { + tiers: vec![ + PricingTier { + max_tokens: Some(272_000), + input_per_1m: 1.25, + output_per_1m: 7.50, + }, + PricingTier { + max_tokens: None, + input_per_1m: 2.50, + output_per_1m: 11.25, + }, + ], + bracket_pricing: true, + }), + CachingSupport::TieredWithWrites(TieredCachingWithWrites { + tiers: vec![ + CachingTierWithWrites { + max_tokens: Some(272_000), + cache_write_per_1m: 1.5625, + cache_read_per_1m: 0.125, + }, + CachingTierWithWrites { + max_tokens: None, + cache_write_per_1m: 3.125, + cache_read_per_1m: 0.25, + }, + ], + bracket_pricing: true, + }) + ); + add_dated_service_tier_pricing!( + "gpt-5.6-luna", + pre_cut, + service_tier, + PricingStructure::Tiered(TieredPricing { + tiers: vec![ + PricingTier { + max_tokens: Some(272_000), + input_per_1m: 0.50, + output_per_1m: 3.0, + }, + PricingTier { + max_tokens: None, + input_per_1m: 1.0, + output_per_1m: 4.50, + }, + ], + bracket_pricing: true, + }), + CachingSupport::TieredWithWrites(TieredCachingWithWrites { + tiers: vec![ + CachingTierWithWrites { + max_tokens: Some(272_000), + cache_write_per_1m: 0.625, + cache_read_per_1m: 0.05, + }, + CachingTierWithWrites { + max_tokens: None, + cache_write_per_1m: 1.25, + cache_read_per_1m: 0.10, + }, + ], + bracket_pricing: true, + }) + ); + } + add_flat_service_tier_pricing!("gpt-5.5", ServiceTier::Priority, 12.50, 1.25, 75.0); add_flat_service_tier_pricing!("gpt-5.4", ServiceTier::Priority, 5.0, 0.50, 30.0); add_flat_service_tier_pricing!("gpt-5.4-mini", ServiceTier::Priority, 1.50, 0.15, 9.0); @@ -1085,21 +1334,29 @@ fn populate_defaults( 0.50, 22.50 ); - add_flat_service_tier_pricing_with_cache_writes!( + add_tiered_service_tier_pricing_with_cache_writes!( "gpt-5.6-terra", service_tier, + 1.0, 1.25, - 1.5625, - 0.125, - 7.50 + 0.10, + 6.0, + 2.0, + 2.50, + 0.20, + 9.0 ); - add_flat_service_tier_pricing_with_cache_writes!( + add_tiered_service_tier_pricing_with_cache_writes!( "gpt-5.6-luna", service_tier, - 0.50, - 0.625, - 0.05, - 3.0 + 0.10, + 0.125, + 0.01, + 0.60, + 0.20, + 0.25, + 0.02, + 0.90 ); add_tiered_service_tier_pricing!( "gpt-5.5", @@ -2412,28 +2669,30 @@ pub fn is_model_estimated(model_name: &str) -> bool { .unwrap_or(false) } +fn dated_pricing_for_date( + model_info: &ModelInfo, + effective_at: Option>, +) -> Option<&DatedPricing> { + let usage_date = effective_at?.date_naive(); + // Pick the *nearest* matching override rather than the first one in + // the vector. Built-in models are kept sorted by `add_dated_pricing!`, + // but externally-merged `ModelInfo.dated_pricing` (from + // `Registry::merge`) is only validated, not sorted, so `.find(...)` + // could otherwise pick the wrong override depending on input order. + model_info + .dated_pricing + .iter() + .filter(|dated| usage_date < dated.valid_until) + .min_by_key(|dated| dated.valid_until) +} + fn standard_pricing_for_date( model_info: &ModelInfo, effective_at: Option>, ) -> (&PricingStructure, &CachingSupport) { - if let Some(effective_at) = effective_at { - let usage_date = effective_at.date_naive(); - // Pick the *nearest* matching override rather than the first one in - // the vector. Built-in models are kept sorted by `add_dated_pricing!`, - // but externally-merged `ModelInfo.dated_pricing` (from - // `Registry::merge`) is only validated, not sorted, so `.find(...)` - // could otherwise pick the wrong override depending on input order. - if let Some(dated) = model_info - .dated_pricing - .iter() - .filter(|dated| usage_date < dated.valid_until) - .min_by_key(|dated| dated.valid_until) - { - return (&dated.pricing, &dated.caching); - } - } - - (&model_info.pricing, &model_info.caching) + dated_pricing_for_date(model_info, effective_at) + .map(|dated| (&dated.pricing, &dated.caching)) + .unwrap_or((&model_info.pricing, &model_info.caching)) } fn pricing_for_service_tier( @@ -2447,9 +2706,9 @@ fn pricing_for_service_tier( return standard; } - model_info - .service_tiers - .get(&service_tier) + dated_pricing_for_date(model_info, effective_at) + .and_then(|dated| dated.service_tiers.get(&service_tier)) + .or_else(|| model_info.service_tiers.get(&service_tier)) .map(|tier| (&tier.pricing, &tier.caching)) .unwrap_or(standard) } @@ -2916,12 +3175,12 @@ mod tests { CachingSupport, CachingTier, CachingTierWithWrites, InputTokenSemantics, ModelInfo, PricingStructure, PricingTier, Registry, ServiceTier, TieredCaching, TieredCachingWithWrites, TieredPricing, calculate_cache_cost, - calculate_cache_cost_for_service_tier, calculate_cache_cost_for_service_tier_at, - calculate_input_cost, calculate_input_cost_for_service_tier, - calculate_input_cost_for_service_tier_at, calculate_output_cost, - calculate_output_cost_for_service_tier, calculate_output_cost_for_service_tier_at, - calculate_total_cost_for_context_at, calculate_total_cost_for_service_tier_at, - get_model_info, get_registry_lock, init_external_models, + calculate_cache_cost_for_service_tier, calculate_input_cost, + calculate_input_cost_for_service_tier, calculate_input_cost_for_service_tier_at, + calculate_output_cost, calculate_output_cost_for_service_tier, + calculate_output_cost_for_service_tier_at, calculate_total_cost_for_context_at, + calculate_total_cost_for_service_tier_at, get_model_info, get_registry_lock, + init_external_models, }; use chrono::{TimeZone, Utc}; @@ -3384,6 +3643,22 @@ mod tests { } } + #[test] + fn gpt_5_6_terra_and_luna_use_long_context_pricing_for_full_request() { + for (model, expected) in [("gpt-5.6-terra", 0.66), ("gpt-5.6-luna", 0.066)] { + let cost = calculate_total_cost_for_service_tier_at( + model, + ServiceTier::Standard, + 100_000, + 10_000, + 0, + 200_000, + None, + ); + approx_eq(cost, expected); + } + } + #[test] fn gpt_5_6_pricing_is_available() { let sol_info = get_model_info("gpt-5.6-sol").expect("model should exist"); @@ -3398,76 +3673,47 @@ mod tests { approx_eq(calculate_cache_cost("gpt-5.6-sol", 0, 200_000), 0.10); approx_eq(calculate_cache_cost("gpt-5.6-sol", 100_000, 100_000), 0.675); - approx_eq(calculate_input_cost("gpt-5.6-terra", 1_000_000), 2.0); - approx_eq(calculate_output_cost("gpt-5.6-terra", 1_000_000), 12.0); - approx_eq(calculate_cache_cost("gpt-5.6-terra", 0, 1_000_000), 0.20); + approx_eq(calculate_input_cost("gpt-5.6-terra", 1_000_000), 4.0); + approx_eq(calculate_output_cost("gpt-5.6-terra", 1_000_000), 18.0); + approx_eq(calculate_cache_cost("gpt-5.6-terra", 0, 1_000_000), 0.40); approx_eq( calculate_cache_cost("gpt-5.6-terra", 1_000_000, 1_000_000), - 2.70, + 5.40, ); - approx_eq(calculate_input_cost("gpt-5.6-luna", 1_000_000), 0.20); - approx_eq(calculate_output_cost("gpt-5.6-luna", 1_000_000), 1.20); - approx_eq(calculate_cache_cost("gpt-5.6-luna", 0, 1_000_000), 0.02); + approx_eq(calculate_input_cost("gpt-5.6-luna", 1_000_000), 0.40); + approx_eq(calculate_output_cost("gpt-5.6-luna", 1_000_000), 1.80); + approx_eq(calculate_cache_cost("gpt-5.6-luna", 0, 1_000_000), 0.04); approx_eq( calculate_cache_cost("gpt-5.6-luna", 1_000_000, 1_000_000), - 0.27, + 0.54, ); } - /// Usage from before the 2026-07-30 cut must keep the price it was actually - /// billed at. Without the dated overrides the new rates are applied - /// retroactively, and every Luna session recorded before the cut is - /// suddenly reported at a fifth of what it cost. + /// Usage from before the 2026-07-30 cut must keep both the historical + /// sticker price and its long-context multiplier across every service tier. #[test] fn gpt_5_6_terra_and_luna_keep_pre_cut_pricing_for_older_usage() { let before_cut = Utc.with_ymd_and_hms(2026, 7, 29, 12, 0, 0).unwrap(); - for (model, input, output, cache_read, cache_write_and_read) in [ - ("gpt-5.6-terra", 2.50, 15.0, 0.25, 3.375), - ("gpt-5.6-luna", 1.0, 6.0, 0.10, 1.35), + for (service_tier, terra, luna) in [ + (ServiceTier::Standard, 0.825, 0.33), + (ServiceTier::Priority, 1.65, 0.66), + (ServiceTier::Flex, 0.4125, 0.165), + (ServiceTier::Batch, 0.4125, 0.165), ] { - approx_eq( - calculate_input_cost_for_service_tier_at( + for (model, expected) in [("gpt-5.6-terra", terra), ("gpt-5.6-luna", luna)] { + let cost = calculate_total_cost_for_service_tier_at( model, - ServiceTier::Standard, - 1_000_000, - Some(before_cut), - ), - input, - ); - approx_eq( - calculate_output_cost_for_service_tier_at( - model, - ServiceTier::Standard, - 1_000_000, - Some(before_cut), - ), - output, - ); - approx_eq( - calculate_cache_cost_for_service_tier_at( - model, - ServiceTier::Standard, + service_tier, + 100_000, + 10_000, 0, - 1_000_000, + 200_000, Some(before_cut), - ), - cache_read, - ); - // Cache writes are the other half of the historical cost, and the - // dated override carries its own `CachingSupport`. Without this the - // write rate could silently fall through to the post-cut value. - approx_eq( - calculate_cache_cost_for_service_tier_at( - model, - ServiceTier::Standard, - 1_000_000, - 1_000_000, - Some(before_cut), - ), - cache_write_and_read, - ); + ); + approx_eq(cost, expected); + } } } @@ -3477,7 +3723,7 @@ mod tests { fn gpt_5_6_terra_and_luna_use_new_pricing_from_the_cut_date() { let cut_day = Utc.with_ymd_and_hms(2026, 7, 30, 0, 0, 0).unwrap(); - for (model, input, output) in [("gpt-5.6-terra", 2.0, 12.0), ("gpt-5.6-luna", 0.20, 1.20)] { + for (model, input, output) in [("gpt-5.6-terra", 4.0, 18.0), ("gpt-5.6-luna", 0.40, 1.80)] { approx_eq( calculate_input_cost_for_service_tier_at( model, @@ -3544,7 +3790,7 @@ mod tests { ServiceTier::Priority, 1_000_000, ), - 5.0, + 8.0, ); approx_eq( calculate_output_cost_for_service_tier( @@ -3552,7 +3798,7 @@ mod tests { ServiceTier::Priority, 1_000_000, ), - 30.0, + 36.0, ); approx_eq( calculate_cache_cost_for_service_tier( @@ -3561,7 +3807,7 @@ mod tests { 0, 1_000_000, ), - 0.50, + 0.80, ); approx_eq( calculate_cache_cost_for_service_tier( @@ -3570,12 +3816,12 @@ mod tests { 1_000_000, 1_000_000, ), - 6.75, + 10.80, ); approx_eq( calculate_input_cost_for_service_tier("gpt-5.6-luna", ServiceTier::Priority, 1_000_000), - 2.0, + 0.80, ); approx_eq( calculate_output_cost_for_service_tier( @@ -3583,7 +3829,7 @@ mod tests { ServiceTier::Priority, 1_000_000, ), - 12.0, + 3.60, ); approx_eq( calculate_cache_cost_for_service_tier( @@ -3592,7 +3838,7 @@ mod tests { 0, 1_000_000, ), - 0.20, + 0.08, ); approx_eq( calculate_cache_cost_for_service_tier( @@ -3601,7 +3847,7 @@ mod tests { 1_000_000, 1_000_000, ), - 2.70, + 1.08, ); approx_eq( @@ -3680,15 +3926,15 @@ mod tests { approx_eq( calculate_input_cost_for_service_tier("gpt-5.6-terra", service_tier, 1_000_000), - 1.25, + 2.0, ); approx_eq( calculate_output_cost_for_service_tier("gpt-5.6-terra", service_tier, 1_000_000), - 7.50, + 9.0, ); approx_eq( calculate_cache_cost_for_service_tier("gpt-5.6-terra", service_tier, 0, 1_000_000), - 0.125, + 0.20, ); approx_eq( calculate_cache_cost_for_service_tier( @@ -3697,20 +3943,20 @@ mod tests { 1_000_000, 1_000_000, ), - 1.6875, + 2.70, ); approx_eq( calculate_input_cost_for_service_tier("gpt-5.6-luna", service_tier, 1_000_000), - 0.50, + 0.20, ); approx_eq( calculate_output_cost_for_service_tier("gpt-5.6-luna", service_tier, 1_000_000), - 3.0, + 0.90, ); approx_eq( calculate_cache_cost_for_service_tier("gpt-5.6-luna", service_tier, 0, 1_000_000), - 0.05, + 0.02, ); approx_eq( calculate_cache_cost_for_service_tier( @@ -3719,7 +3965,7 @@ mod tests { 1_000_000, 1_000_000, ), - 0.675, + 0.27, ); approx_eq(