diff --git a/rust/src/codex_costs.rs b/rust/src/codex_costs.rs index cba2a1d717..e36f50251d 100644 --- a/rust/src/codex_costs.rs +++ b/rust/src/codex_costs.rs @@ -3,14 +3,11 @@ #[cfg(test)] use chrono::Local; use chrono::{Duration, NaiveDate}; -use std::collections::HashMap; use std::path::Path; -use crate::core::{CostUsageDayRange, CostUsagePricing, JsonlScanner}; +use crate::core::{CodexUsageRecord, CostUsageDayRange, CostUsagePricing, JsonlScanner}; use crate::cost_scanner::{CostSummary, ModelTokenCounts}; -pub(crate) type CodexDays = HashMap>>; - pub(crate) fn codex_period_start(today: NaiveDate, days: u32) -> NaiveDate { today - Duration::days(days.saturating_sub(1) as i64) } @@ -30,47 +27,21 @@ pub(crate) fn codex_scan_dates(range: &CostUsageDayRange) -> Vec { dates } -pub(crate) fn add_codex_days_to_summary( +pub(crate) fn add_codex_records_to_summary( summary: &mut CostSummary, - days: &CodexDays, + records: &[CodexUsageRecord], range: &CostUsageDayRange, ) -> (f64, bool) { let mut total_cost = 0.0; let mut has_tokens = false; - for models in codex_days_in_range(days, range) { - for (model, packed) in models { - let tokens = CodexTokenCounts::from_packed(packed); - if tokens.is_empty() { - continue; - } - - let cost = codex_cost_usd(model, tokens.input, tokens.cached, tokens.output); + for record in records.iter().filter(|record| { + CostUsageDayRange::is_in_range(&record.day_key, &range.since_key, &range.until_key) + }) { + let tokens = CodexTokenCounts::from_values(record.input, record.cached, record.output); + if let Some(cost) = add_codex_tokens_to_summary(summary, &record.model, tokens) { total_cost += cost; has_tokens = true; - - summary.input_tokens += tokens.input; - summary.cached_tokens += tokens.cached; - summary.output_tokens += tokens.output; - *summary.by_model.entry(model.clone()).or_insert(0.0) += cost; - - let speed_bucket = codex_speed_bucket(model); - *summary - .by_speed - .entry(speed_bucket.to_string()) - .or_insert(0.0) += cost; - - add_tokens( - summary.by_model_tokens.entry(model.clone()).or_default(), - tokens, - ); - add_tokens( - summary - .by_speed_tokens - .entry(speed_bucket.to_string()) - .or_default(), - tokens, - ); } } @@ -83,7 +54,7 @@ pub(crate) fn scan_codex_file_cost_for_range(path: &Path, range: &CostUsageDayRa Err(_) => return 0.0, }; - codex_days_cost(&parse_result.days, range) + codex_records_cost(&parse_result.records, range) } #[cfg(test)] @@ -101,12 +72,12 @@ struct CodexTokenCounts { } impl CodexTokenCounts { - fn from_packed(packed: &[i32]) -> Self { - let input = packed.first().copied().unwrap_or(0).max(0) as u64; + fn from_values(input: i32, cached: i32, output: i32) -> Self { + let input = input.max(0) as u64; Self { input, - cached: (packed.get(1).copied().unwrap_or(0).max(0) as u64).min(input), - output: packed.get(2).copied().unwrap_or(0).max(0) as u64, + cached: (cached.max(0) as u64).min(input), + output: output.max(0) as u64, } } @@ -121,34 +92,58 @@ fn add_tokens(summary: &mut ModelTokenCounts, tokens: CodexTokenCounts) { summary.cached_tokens += tokens.cached; } -fn codex_days_cost(days: &CodexDays, range: &CostUsageDayRange) -> f64 { - let mut total_cost = 0.0; +fn add_codex_tokens_to_summary( + summary: &mut CostSummary, + model: &str, + tokens: CodexTokenCounts, +) -> Option { + if tokens.is_empty() { + return None; + } - for models in codex_days_in_range(days, range) { - for (model, packed) in models { - let tokens = CodexTokenCounts::from_packed(packed); - if tokens.is_empty() { - continue; - } + let cost = codex_cost_usd(model, tokens.input, tokens.cached, tokens.output); + summary.input_tokens += tokens.input; + summary.cached_tokens += tokens.cached; + summary.output_tokens += tokens.output; + *summary.by_model.entry(model.to_string()).or_insert(0.0) += cost; + + let speed_bucket = codex_speed_bucket(model); + *summary + .by_speed + .entry(speed_bucket.to_string()) + .or_insert(0.0) += cost; + add_tokens( + summary + .by_model_tokens + .entry(model.to_string()) + .or_default(), + tokens, + ); + add_tokens( + summary + .by_speed_tokens + .entry(speed_bucket.to_string()) + .or_default(), + tokens, + ); + Some(cost) +} + +fn codex_records_cost(records: &[CodexUsageRecord], range: &CostUsageDayRange) -> f64 { + let mut total_cost = 0.0; - total_cost += codex_cost_usd(model, tokens.input, tokens.cached, tokens.output); + for record in records.iter().filter(|record| { + CostUsageDayRange::is_in_range(&record.day_key, &range.since_key, &range.until_key) + }) { + let tokens = CodexTokenCounts::from_values(record.input, record.cached, record.output); + if !tokens.is_empty() { + total_cost += codex_cost_usd(&record.model, tokens.input, tokens.cached, tokens.output); } } total_cost } -fn codex_days_in_range<'a>( - days: &'a CodexDays, - range: &'a CostUsageDayRange, -) -> impl Iterator>> + 'a { - days.iter() - .filter(move |(day_key, _)| { - CostUsageDayRange::is_in_range(day_key, &range.since_key, &range.until_key) - }) - .map(|(_, models)| models) -} - fn codex_speed_bucket(model: &str) -> &'static str { let normalized = model.to_ascii_lowercase(); if normalized.contains("fast") @@ -189,6 +184,7 @@ fn codex_cost_usd(model: &str, input: u64, cached: u64, output: u64) -> f64 { #[cfg(test)] mod tests { use super::*; + use crate::core::CodexUsageRecord; #[test] fn test_codex_pricing() { @@ -208,52 +204,45 @@ mod tests { } #[test] - fn test_codex_speed_bucket() { - assert_eq!(codex_speed_bucket("gpt-5.5-fast"), "fast"); - assert_eq!(codex_speed_bucket("gpt-5.3-codex-spark"), "fast"); - assert_eq!(codex_speed_bucket("gpt-5-codex"), "standard"); - } - - #[test] - fn codex_summary_filters_expanded_scan_days_to_requested_range() { + fn codex_summary_prices_gpt56_usage_records_individually() { let target = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); let range = CostUsageDayRange::new(target, target); - let mut days = CodexDays::new(); - for (day, input) in [ - ("2026-05-30", 1_000), - ("2026-05-31", 2_000), - ("2026-06-01", 4_000), - ] { - days.entry(day.to_string()) - .or_default() - .insert("gpt-5".to_string(), vec![input, 0, 0]); - } - + let records = vec![ + CodexUsageRecord { + day_key: "2026-05-31".to_string(), + model: "gpt-5.6-sol".to_string(), + input: 200_000, + cached: 0, + output: 0, + }, + CodexUsageRecord { + day_key: "2026-05-31".to_string(), + model: "gpt-5.6-sol".to_string(), + input: 200_000, + cached: 0, + output: 0, + }, + CodexUsageRecord { + day_key: "2026-05-30".to_string(), + model: "gpt-5.6-sol".to_string(), + input: 200_000, + cached: 0, + output: 0, + }, + ]; let mut summary = CostSummary::default(); - let (cost, has_tokens) = add_codex_days_to_summary(&mut summary, &days, &range); - let expected = codex_cost_usd("gpt-5", 2_000, 0, 0); + let (cost, has_tokens) = add_codex_records_to_summary(&mut summary, &records, &range); + assert!(has_tokens); - assert_eq!(summary.input_tokens, 2_000); - assert_eq!(summary.output_tokens, 0); - assert!((cost - expected).abs() < f64::EPSILON); + assert_eq!(summary.input_tokens, 400_000); + assert!((cost - 2.0).abs() < f64::EPSILON); } #[test] - fn codex_daily_cost_filters_adjacent_scan_padding_days() { - let target = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); - let range = CostUsageDayRange::new(target, target); - let mut days = CodexDays::new(); - days.entry("2026-05-30".to_string()) - .or_default() - .insert("gpt-5".to_string(), vec![1_000, 0, 0]); - days.entry("2026-05-31".to_string()) - .or_default() - .insert("gpt-5".to_string(), vec![2_000, 0, 0]); - - let cost = codex_days_cost(&days, &range); - let expected = codex_cost_usd("gpt-5", 2_000, 0, 0); - - assert!((cost - expected).abs() < f64::EPSILON); + fn test_codex_speed_bucket() { + assert_eq!(codex_speed_bucket("gpt-5.5-fast"), "fast"); + assert_eq!(codex_speed_bucket("gpt-5.3-codex-spark"), "fast"); + assert_eq!(codex_speed_bucket("gpt-5-codex"), "standard"); } } diff --git a/rust/src/core/cost_pricing.rs b/rust/src/core/cost_pricing.rs index 3d4b58e971..39e7eb29c3 100755 --- a/rust/src/core/cost_pricing.rs +++ b/rust/src/core/cost_pricing.rs @@ -8,6 +8,14 @@ use std::collections::HashMap; use std::sync::LazyLock; +/// Whole-request Codex rates for input above the model context threshold. +#[derive(Debug, Clone, Copy)] +pub struct CodexLongContextRates { + pub input_cost_per_token: f64, + pub output_cost_per_token: f64, + pub cache_read_input_cost_per_token: f64, +} + /// Codex (OpenAI) model pricing #[derive(Debug, Clone, Copy)] pub struct CodexPricing { @@ -19,6 +27,8 @@ pub struct CodexPricing { pub cache_read_input_cost_per_token: f64, /// Optional display label override (e.g. "Research Preview") pub display_label: Option<&'static str>, + /// Whole-request rates above the Codex long-context threshold. + pub long_context: Option, } /// Claude (Anthropic) model pricing with optional tiered pricing @@ -56,6 +66,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1e-5, cache_read_input_cost_per_token: 1.25e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -65,6 +76,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1e-5, cache_read_input_cost_per_token: 1.25e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -74,6 +86,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 2e-6, cache_read_input_cost_per_token: 2.5e-8, display_label: None, + long_context: None, }, ); m.insert( @@ -83,6 +96,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 4e-7, cache_read_input_cost_per_token: 5e-9, display_label: None, + long_context: None, }, ); m.insert( @@ -92,6 +106,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.2e-4, cache_read_input_cost_per_token: 1.5e-5, display_label: None, + long_context: None, }, ); m.insert( @@ -101,6 +116,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1e-5, cache_read_input_cost_per_token: 1.25e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -110,6 +126,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1e-5, cache_read_input_cost_per_token: 1.25e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -119,6 +136,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1e-5, cache_read_input_cost_per_token: 1.25e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -128,6 +146,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 2e-6, cache_read_input_cost_per_token: 2.5e-8, display_label: None, + long_context: None, }, ); m.insert( @@ -137,6 +156,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.4e-5, cache_read_input_cost_per_token: 1.75e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -146,6 +166,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.4e-5, cache_read_input_cost_per_token: 1.75e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -155,6 +176,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.68e-4, cache_read_input_cost_per_token: 2.1e-5, display_label: None, + long_context: None, }, ); m.insert( @@ -164,6 +186,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.4e-5, cache_read_input_cost_per_token: 1.75e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -173,6 +196,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 0.0, cache_read_input_cost_per_token: 0.0, display_label: Some("Research Preview"), + long_context: None, }, ); @@ -184,6 +208,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.5e-5, cache_read_input_cost_per_token: 2.5e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -193,6 +218,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.5e-5, cache_read_input_cost_per_token: 2.5e-7, display_label: None, + long_context: None, }, ); @@ -204,6 +230,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 4.5e-6, cache_read_input_cost_per_token: 7.5e-8, display_label: None, + long_context: None, }, ); m.insert( @@ -213,6 +240,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 4.5e-6, cache_read_input_cost_per_token: 7.5e-8, display_label: None, + long_context: None, }, ); @@ -224,6 +252,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.25e-6, cache_read_input_cost_per_token: 2e-8, display_label: None, + long_context: None, }, ); m.insert( @@ -233,6 +262,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.25e-6, cache_read_input_cost_per_token: 2e-8, display_label: None, + long_context: None, }, ); @@ -244,6 +274,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.8e-4, cache_read_input_cost_per_token: 3e-5, display_label: None, + long_context: None, }, ); m.insert( @@ -253,6 +284,7 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 3e-5, cache_read_input_cost_per_token: 5e-7, display_label: None, + long_context: None, }, ); m.insert( @@ -262,12 +294,57 @@ static CODEX_PRICING: LazyLock> = LazyLock:: output_cost_per_token: 1.8e-4, cache_read_input_cost_per_token: 3e-5, display_label: None, + long_context: None, + }, + ); + m.insert( + "gpt-5.6-sol", + CodexPricing { + input_cost_per_token: 5e-6, + output_cost_per_token: 3e-5, + cache_read_input_cost_per_token: 5e-7, + display_label: None, + long_context: Some(CodexLongContextRates { + input_cost_per_token: 1e-5, + output_cost_per_token: 4.5e-5, + cache_read_input_cost_per_token: 1e-6, + }), + }, + ); + m.insert( + "gpt-5.6-terra", + CodexPricing { + input_cost_per_token: 2.5e-6, + output_cost_per_token: 1.5e-5, + cache_read_input_cost_per_token: 2.5e-7, + display_label: None, + long_context: Some(CodexLongContextRates { + input_cost_per_token: 5e-6, + output_cost_per_token: 2.25e-5, + cache_read_input_cost_per_token: 5e-7, + }), + }, + ); + m.insert( + "gpt-5.6-luna", + CodexPricing { + input_cost_per_token: 1e-6, + output_cost_per_token: 6e-6, + cache_read_input_cost_per_token: 1e-7, + display_label: None, + long_context: Some(CodexLongContextRates { + input_cost_per_token: 2e-6, + output_cost_per_token: 9e-6, + cache_read_input_cost_per_token: 2e-7, + }), }, ); m }); +const CODEX_LONG_CONTEXT_THRESHOLD: u64 = 272_000; + /// Claude model pricing table static CLAUDE_PRICING: LazyLock> = LazyLock::new(|| { let mut m = HashMap::new(); @@ -521,19 +598,23 @@ impl CostUsagePricing { // Check if base model (without -codex suffix) exists in pricing if let Some(idx) = trimmed.find("-codex") { let base = &trimmed[..idx]; - if CODEX_PRICING.contains_key(base) { - return base.to_string(); + if CODEX_PRICING.contains_key(base) || base == "gpt-5.6" { + trimmed = base.to_string(); } } let date_pattern = regex_lite::Regex::new(r"-\d{4}-\d{2}-\d{2}$").unwrap(); if let Some(mat) = date_pattern.find(&trimmed) { let base = &trimmed[..mat.start()]; - if CODEX_PRICING.contains_key(base) { - return base.to_string(); + if CODEX_PRICING.contains_key(base) || base == "gpt-5.6" { + trimmed = base.to_string(); } } + if trimmed == "gpt-5.6" { + return "gpt-5.6-sol".to_string(); + } + trimmed } @@ -589,13 +670,35 @@ impl CostUsagePricing { ) -> Option { let key = Self::normalize_codex_model(model); let pricing = CODEX_PRICING.get(key.as_str())?; + let (input_rate, cache_read_rate, output_rate) = + if input_tokens > CODEX_LONG_CONTEXT_THRESHOLD { + if let Some(long_context) = pricing.long_context { + ( + long_context.input_cost_per_token, + long_context.cache_read_input_cost_per_token, + long_context.output_cost_per_token, + ) + } else { + ( + pricing.input_cost_per_token, + pricing.cache_read_input_cost_per_token, + pricing.output_cost_per_token, + ) + } + } else { + ( + pricing.input_cost_per_token, + pricing.cache_read_input_cost_per_token, + pricing.output_cost_per_token, + ) + }; let cached = cached_input_tokens.min(input_tokens); let non_cached = input_tokens.saturating_sub(cached); - let cost = (non_cached as f64) * pricing.input_cost_per_token - + (cached as f64) * pricing.cache_read_input_cost_per_token - + (output_tokens as f64) * pricing.output_cost_per_token; + let cost = (non_cached as f64) * input_rate + + (cached as f64) * cache_read_rate + + (output_tokens as f64) * output_rate; Some(cost) } @@ -865,6 +968,59 @@ mod tests { assert!((cost.unwrap() - 0.075).abs() < 1e-10); } + #[test] + fn test_gpt56_standard_pricing() { + for (model, expected) in [ + ("gpt-5.6-sol", 0.0332), + ("gpt-5.6-terra", 0.0166), + ("gpt-5.6-luna", 0.00664), + ] { + let cost = CostUsagePricing::codex_cost_usd(model, 1_000, 400, 1_000); + assert!((cost.unwrap() - expected).abs() < 1e-10, "{model}"); + } + } + + #[test] + fn test_gpt56_long_context_pricing() { + for (model, expected) in [ + ("gpt-5.6-sol", 45.272001), + ("gpt-5.6-terra", 22.6360005), + ("gpt-5.6-luna", 9.0544002), + ] { + let cost = CostUsagePricing::codex_cost_usd(model, 272_001, 272_001, 1_000_000); + assert!((cost.unwrap() - expected).abs() < 1e-10, "{model}"); + } + } + + #[test] + fn test_gpt56_context_threshold_is_exclusive() { + for (model, expected) in [ + ("gpt-5.6-sol", 0.136), + ("gpt-5.6-terra", 0.068), + ("gpt-5.6-luna", 0.0272), + ] { + let cost = CostUsagePricing::codex_cost_usd(model, 272_000, 272_000, 0); + assert!((cost.unwrap() - expected).abs() < 1e-10, "{model}"); + } + } + + #[test] + fn test_normalize_gpt56_aliases() { + for model in [ + "gpt-5.6", + "openai/gpt-5.6", + "gpt-5.6-codex", + "gpt-5.6-2099-01-01", + "openai/gpt-5.6-codex-2099-01-01", + ] { + assert_eq!( + CostUsagePricing::normalize_codex_model(model), + "gpt-5.6-sol", + "{model}" + ); + } + } + #[test] fn test_codex_display_label() { assert_eq!( diff --git a/rust/src/core/jsonl_scanner.rs b/rust/src/core/jsonl_scanner.rs index c868b054b2..31daff28c4 100755 --- a/rust/src/core/jsonl_scanner.rs +++ b/rust/src/core/jsonl_scanner.rs @@ -53,8 +53,8 @@ pub struct CodexTotals { /// Result of parsing a Codex file #[derive(Debug)] pub struct CodexParseResult { - /// Daily usage: day_key -> model -> [input, cached, output] - pub days: HashMap>>, + /// Individual token-count deltas used for per-request pricing. + pub records: Vec, /// Bytes parsed pub parsed_bytes: i64, /// Last model seen @@ -63,6 +63,16 @@ pub struct CodexParseResult { pub last_totals: Option, } +/// A billable Codex token-count delta. +#[derive(Debug, Clone)] +pub struct CodexUsageRecord { + pub day_key: String, + pub model: String, + pub input: i32, + pub cached: i32, + pub output: i32, +} + /// Day range for scanning pub struct CostUsageDayRange { pub since_key: String, @@ -103,7 +113,7 @@ pub struct JsonlScanner; struct CodexParserState { current_model: Option, previous_totals: Option, - days: HashMap>>, + records: Vec, } #[derive(Debug, Deserialize)] @@ -179,7 +189,7 @@ impl CodexParserState { Self { current_model: initial_model, previous_totals: initial_totals, - days: HashMap::new(), + records: Vec::new(), } } @@ -260,17 +270,7 @@ impl CodexParserState { let info = payload.get("info"); let model = self.token_model(info, payload, obj); - let norm_model = CostUsagePricing::normalize_codex_model(&model); - let packed = self - .days - .entry(day_key) - .or_default() - .entry(norm_model) - .or_insert_with(|| vec![0, 0, 0]); - - packed[0] += delta_input; - packed[1] += delta_cached.min(delta_input); - packed[2] += delta_output; + self.record_usage(day_key, &model, delta_input, delta_cached, delta_output); } fn record_fast_token_count(&mut self, payload: CodexFastPayload<'_>, day_key: String) { @@ -288,18 +288,19 @@ impl CodexParserState { .and_then(|info| info.model.or(info.model_name)) .or(payload.model) .or(self.current_model.as_deref()) - .unwrap_or("gpt-5"); - let norm_model = CostUsagePricing::normalize_codex_model(model); - let packed = self - .days - .entry(day_key) - .or_default() - .entry(norm_model) - .or_insert_with(|| vec![0, 0, 0]); + .unwrap_or("gpt-5") + .to_string(); + self.record_usage(day_key, &model, delta_input, delta_cached, delta_output); + } - packed[0] += delta_input; - packed[1] += delta_cached.min(delta_input); - packed[2] += delta_output; + fn record_usage(&mut self, day_key: String, model: &str, input: i32, cached: i32, output: i32) { + self.records.push(CodexUsageRecord { + day_key, + model: CostUsagePricing::normalize_codex_model(model), + input, + cached: cached.min(input), + output, + }); } fn token_model(&self, info: Option<&Value>, payload: &Value, obj: &Value) -> String { @@ -607,7 +608,7 @@ impl JsonlScanner { } Ok(CodexParseResult { - days: parser.days, + records: parser.records, parsed_bytes: file_size.max(parsed_bytes), last_model: parser.current_model, last_totals: parser.previous_totals, @@ -732,9 +733,11 @@ mod tests { &range, ); - let day = parser.days.get("2026-05-31").expect("day usage"); - let usage = day.get("gpt-5.5").expect("model usage"); - assert_eq!(usage, &vec![120, 40, 9]); + assert_eq!(parser.records.len(), 1); + let record = &parser.records[0]; + assert_eq!(record.day_key, "2026-05-31"); + assert_eq!(record.model, "gpt-5.5"); + assert_eq!((record.input, record.cached, record.output), (120, 40, 9)); assert_eq!(parser.current_model.as_deref(), Some("gpt-5.5")); } @@ -755,9 +758,15 @@ mod tests { &range, ); - let day = parser.days.get("2026-05-31").expect("day usage"); - let usage = day.get("gpt-5").expect("model usage"); - assert_eq!(usage, &vec![1250, 260, 90]); + assert_eq!(parser.records.len(), 2); + assert_eq!( + parser + .records + .iter() + .map(|record| (record.input, record.cached, record.output)) + .collect::>(), + vec![(1_000, 200, 50), (250, 60, 40)] + ); let totals = parser.previous_totals.expect("last totals"); assert_eq!(totals.input, 1250); assert_eq!(totals.cached, 260); @@ -777,9 +786,10 @@ mod tests { &range, ); - let day = parser.days.get("2026-05-31").expect("day usage"); - let usage = day.get("gpt-5").expect("model usage"); - assert_eq!(usage, &vec![20, 5, 3]); + assert_eq!(parser.records.len(), 1); + let record = &parser.records[0]; + assert_eq!(record.model, "gpt-5"); + assert_eq!((record.input, record.cached, record.output), (20, 5, 3)); } #[test] @@ -804,8 +814,10 @@ mod tests { JsonlScanner::parse_codex_file(file.path(), &range, 0, None, None).expect("parse"); assert_eq!(parsed.last_model.as_deref(), Some("gpt-5.5")); - let day = parsed.days.get("2026-05-31").expect("day usage"); - let usage = day.get("gpt-5.5").expect("model usage"); - assert_eq!(usage, &vec![45, 12, 8]); + assert_eq!(parsed.records.len(), 1); + let record = &parsed.records[0]; + assert_eq!(record.day_key, "2026-05-31"); + assert_eq!(record.model, "gpt-5.5"); + assert_eq!((record.input, record.cached, record.output), (45, 12, 8)); } } diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 0372027985..5e2d0b1217 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -13,7 +13,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(test)] use crate::codex_costs::scan_codex_file_cost; use crate::codex_costs::{ - add_codex_days_to_summary, codex_period_start, codex_scan_dates, scan_codex_file_cost_for_range, + add_codex_records_to_summary, codex_period_start, codex_scan_dates, + scan_codex_file_cost_for_range, }; use crate::codex_sessions::{codex_sessions_dir_candidates, default_wsl_roots}; use crate::core::{CostUsageDayRange, CostUsagePricing, JsonlScanner}; @@ -371,7 +372,7 @@ impl CostScanner { }; let (session_cost, has_tokens) = - add_codex_days_to_summary(summary, &parse_result.days, &range); + add_codex_records_to_summary(summary, &parse_result.records, &range); if has_tokens { summary.total_cost_usd += session_cost;