diff --git a/codex-rs/tui/src/analytics.rs b/codex-rs/tui/src/analytics.rs index 2304f6649f..1ebb5bbc35 100644 --- a/codex-rs/tui/src/analytics.rs +++ b/codex-rs/tui/src/analytics.rs @@ -1,5 +1,11 @@ //! Account analytics data preparation, staged for the dashboard integration. +mod data; mod models; mod normalize; mod report_data; +mod tokens; + +#[cfg(test)] +#[path = "analytics_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/data.rs b/codex-rs/tui/src/analytics/data.rs new file mode 100644 index 0000000000..9eca2ac263 --- /dev/null +++ b/codex-rs/tui/src/analytics/data.rs @@ -0,0 +1,79 @@ +//! Precise signed credit amounts and dates for analytics presentation. + +/// Keep tiny refunds visible while avoiding noise on ordinary credit amounts. +pub(super) fn amount(value: f64) -> String { + if value == 0.0 { + return "0".into(); + } + if value.abs() < 0.000001 { + return format!("{value:.2e}"); + } + let rounded = if value.abs() < 0.01 { + format!("{value:.6}") + } else { + format!("{value:.2}") + }; + let (whole, fraction) = rounded.split_once('.').unwrap_or((&rounded, "")); + let mut grouped = String::new(); + for (index, ch) in whole.chars().enumerate() { + if index > 0 + && ch.is_ascii_digit() + && (whole.len() - index).is_multiple_of(/*rhs*/ 3) + && !grouped.ends_with('-') + { + grouped.push(','); + } + grouped.push(ch); + } + let fraction = fraction.trim_end_matches('0'); + if !fraction.is_empty() { + grouped.push('.'); + grouped.push_str(fraction); + } + grouped +} + +/// Align ordinary credit amounts to two decimals without hiding tiny adjustments. +pub(super) fn credit_amount(value: f64) -> String { + let formatted = amount(value); + if value != 0.0 && value.abs() < 0.01 { + return formatted; + } + match formatted.split_once('.') { + None => format!("{formatted}.00"), + Some((_, fraction)) if fraction.len() == 1 => format!("{formatted}0"), + Some(_) => formatted, + } +} + +/// Format integer millionths without floating-point rounding or hiding tiny adjustments. +pub(super) fn credits(micros: i64) -> String { + let magnitude = micros.unsigned_abs(); + if magnitude > 0 && magnitude < 10_000 { + let fractional = format!("{magnitude:06}"); + return format!( + "{}0.{}", + if micros < 0 { "-" } else { "" }, + fractional.trim_end_matches('0') + ); + } + let cents = (magnitude + 5_000) / 10_000; + let mut whole = (cents / 100).to_string(); + let digits = whole.len(); + for index in (1..digits).rev() { + if (digits - index).is_multiple_of(/*rhs*/ 3) { + whole.insert(index, ','); + } + } + format!( + "{}{whole}.{:02}", + if micros < 0 { "-" } else { "" }, + cents % 100 + ) +} + +pub(super) fn date(date: &str) -> String { + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") + .map(|date| date.format("%b %-d").to_string()) + .unwrap_or_else(|_| date.to_string()) +} diff --git a/codex-rs/tui/src/analytics/models.rs b/codex-rs/tui/src/analytics/models.rs index cd417b3ee5..9523963ab5 100644 --- a/codex-rs/tui/src/analytics/models.rs +++ b/codex-rs/tui/src/analytics/models.rs @@ -25,6 +25,7 @@ pub(crate) enum AccountAnalyticsUnit { RelativeUsage, Credits, Count, + Tokens, } #[derive(Debug, Clone, PartialEq)] diff --git a/codex-rs/tui/src/analytics/tokens.rs b/codex-rs/tui/src/analytics/tokens.rs new file mode 100644 index 0000000000..7e8e1cae14 --- /dev/null +++ b/codex-rs/tui/src/analytics/tokens.rs @@ -0,0 +1,135 @@ +//! Enterprise text token history, independent of credit and allowance accounting. + +use super::models::*; +use super::report_data::AnalyticsData; +use chrono::NaiveDate; +use std::collections::BTreeMap; + +pub(super) fn history( + response: AnalyticsData, + grouping: AccountAnalyticsGrouping, + start: NaiveDate, + end: NaiveDate, +) -> Result { + filtered_history(response, grouping, start, end, /*model_filter*/ None) +} + +pub(super) fn filtered_history( + response: AnalyticsData, + grouping: AccountAnalyticsGrouping, + start: NaiveDate, + end: NaiveDate, + model_filter: Option<&str>, +) -> Result { + let mut days: BTreeMap> = BTreeMap::new(); + for record in response.data { + let date = record + .date + .parse::() + .map_err(|_| "Invalid token report date.")?; + if date < start || date > end { + continue; + } + let groups = if let Some(groups) = record.groups { + groups + .into_iter() + .map(|group| { + ( + if group.is_other { + "Other".into() + } else { + group + .dimensions + .get("model") + .cloned() + .unwrap_or_else(|| "Unknown".into()) + }, + [ + group.uncached_text_input_tokens, + group.cached_text_input_tokens, + group.text_output_tokens, + ], + None, + ) + }) + .collect::>() + } else if let Some(models) = record.models { + models + .into_iter() + .map(|model| { + ( + model.model, + [ + model.uncached_text_input_tokens, + model.cached_text_input_tokens, + model.text_output_tokens, + ], + model.text_total_tokens, + ) + }) + .collect() + } else { + return Err("Token breakdown is not available in this report.".into()); + }; + let values = days.entry(date).or_default(); + for (model, counts, total) in groups { + if model_filter.is_some_and(|selected| selected != model) { + continue; + } + let missing_components = counts.iter().all(Option::is_none); + if grouping == AccountAnalyticsGrouping::Model + && let Some(total) = total.filter(|total| *total != 0.0 || missing_components) + { + if !total.is_finite() || total < 0.0 || total.fract() != 0.0 { + return Err("Invalid token count.".into()); + } + *values.entry(model).or_default() += total; + continue; + } + if missing_components { + return Err("Token counts were not reported.".into()); + } + for (label, count) in ["Uncached input", "Cached input", "Output"] + .into_iter() + .zip(counts) + { + let count = count.unwrap_or(/*default*/ 0.0); + if !count.is_finite() || count < 0.0 || count.fract() != 0.0 { + return Err("Invalid token count.".into()); + } + let key = if grouping == AccountAnalyticsGrouping::Model { + &model + } else { + label + }; + *values.entry(key.to_string()).or_default() += count; + } + } + } + Ok(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Tokens, + updated_at: response + .data_freshness_ts + .and_then(|time| chrono::DateTime::parse_from_rfc3339(&time).ok()) + .map(|time| time.timestamp()), + data: days + .into_iter() + .map(|(date, values)| AccountAnalyticsDay { + date, + total: values.values().sum(), + values: values + .into_iter() + .map(|(key, value)| AccountAnalyticsValue { + label: key.clone(), + key, + value, + }) + .collect(), + }) + .collect(), + }) +} + +#[cfg(test)] +#[path = "tokens_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/tokens_tests.rs b/codex-rs/tui/src/analytics/tokens_tests.rs new file mode 100644 index 0000000000..3501317b19 --- /dev/null +++ b/codex-rs/tui/src/analytics/tokens_tests.rs @@ -0,0 +1,131 @@ +//! Enterprise token normalization across supported groupings. + +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn tokens_use_text_components_and_keep_daily_totals_across_groupings() { + let response: AnalyticsData = serde_json::from_value(json!({"units":"credits", "data":[{ + "date":"2026-09-01", "groups":[{"dimensions":{"model":"gpt-5"}, "uncached_text_input_tokens":10, "cached_text_input_tokens":80, "text_output_tokens":10}] + }]})).unwrap(); + let date = "2026-09-01".parse().unwrap(); + let by_type = history( + response.clone(), + AccountAnalyticsGrouping::TokenType, + date, + date, + ) + .unwrap(); + let by_model = history(response, AccountAnalyticsGrouping::Model, date, date).unwrap(); + assert_eq!( + (by_type.unit, by_type.data[0].total, by_model.data[0].total), + (AccountAnalyticsUnit::Tokens, 100.0, 100.0) + ); + assert_eq!( + by_type.data[0] + .values + .iter() + .map(|v| (v.label.as_str(), v.value)) + .collect::>(), + vec![ + ("Cached input", 80.0), + ("Output", 10.0), + ("Uncached input", 10.0) + ] + ); +} + +#[test] +fn token_model_filter_retains_components_and_freshness() { + let date = "2026-09-01".parse().unwrap(); + let response: AnalyticsData = serde_json::from_value(json!({"data_freshness_ts":"2026-09-02T00:00:00Z", "data":[{ + "date":"2026-09-01", "groups":[ + {"dimensions":{"model":"alpha"}, "uncached_text_input_tokens":10,"cached_text_input_tokens":20,"text_output_tokens":30}, + {"dimensions":{"model":"beta"}, "uncached_text_input_tokens":100,"cached_text_input_tokens":200,"text_output_tokens":300} + ], "models":[{"model":"ignored", "text_total_tokens":1000}] + }]})).unwrap(); + let selected = filtered_history( + response.clone(), + AccountAnalyticsGrouping::TokenType, + date, + date, + Some("alpha"), + ) + .unwrap(); + let expected = history(serde_json::from_value(json!({"data_freshness_ts":"2026-09-02T00:00:00Z", "data":[{ + "date":"2026-09-01", "models":[{"model":"alpha", "uncached_text_input_tokens":10,"cached_text_input_tokens":20,"text_output_tokens":30}] + }]})).unwrap(), AccountAnalyticsGrouping::TokenType, date, date).unwrap(); + assert_eq!(selected, expected); + assert_eq!( + history(response, AccountAnalyticsGrouping::TokenType, date, date) + .unwrap() + .data[0] + .total, + 660.0 + ); +} + +#[test] +fn model_totals_use_reported_total_and_missing_components_remain_unavailable() { + let date = "2026-09-01".parse().unwrap(); + let response: AnalyticsData = serde_json::from_value(json!({"data":[{"date":"2026-09-01", "models":[{"model":"alpha", "text_total_tokens":123}]}]})).unwrap(); + assert_eq!( + history( + response.clone(), + AccountAnalyticsGrouping::Model, + date, + date + ) + .unwrap() + .data, + vec![AccountAnalyticsDay { + date: "2026-09-01".parse().unwrap(), + total: 123.0, + values: vec![AccountAnalyticsValue { + key: "alpha".into(), + label: "alpha".into(), + value: 123.0 + }] + }] + ); + assert_eq!( + history(response, AccountAnalyticsGrouping::TokenType, date, date).unwrap_err(), + "Token counts were not reported." + ); +} + +#[test] +fn explicit_zero_model_total_is_reported_without_components() { + let date = "2026-09-01".parse().unwrap(); + let response: AnalyticsData = serde_json::from_value(json!({ + "data": [{"date": "2026-09-01", "models": [ + {"model": "idle", "text_total_tokens": 0}, + {"model": "active", "text_total_tokens": 0, "text_output_tokens": 5} + ]}] + })) + .unwrap(); + assert_eq!( + history(response, AccountAnalyticsGrouping::Model, date, date).unwrap(), + AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Tokens, + updated_at: None, + data: vec![AccountAnalyticsDay { + date: "2026-09-01".parse().unwrap(), + total: 5.0, + values: vec![ + AccountAnalyticsValue { + key: "active".into(), + label: "active".into(), + value: 5.0, + }, + AccountAnalyticsValue { + key: "idle".into(), + label: "idle".into(), + value: 0.0, + }, + ], + }], + } + ); +} diff --git a/codex-rs/tui/src/analytics_tests.rs b/codex-rs/tui/src/analytics_tests.rs new file mode 100644 index 0000000000..f1e5b3a6f5 --- /dev/null +++ b/codex-rs/tui/src/analytics_tests.rs @@ -0,0 +1,55 @@ +//! Credit formatting preserves tiny adjustments and integer precision. + +use super::data; +use pretty_assertions::assert_eq; + +#[test] +fn analytics_credit_display_retains_integer_precision() { + assert_eq!( + [753.71, 411.12, 47.4, 0.0, 1_212.224, -0.004, -0.000004].map(data::credit_amount), + [ + "753.71", + "411.12", + "47.40", + "0.00", + "1,212.22", + "-0.004", + "-0.000004" + ] + .map(str::to_string) + ); + assert_eq!( + [0, -4, 999_999_999, 19_350_041_555, i64::MIN, i64::MAX].map(data::credits), + [ + "0.00", + "-0.000004", + "1,000.00", + "19,350.04", + "-9,223,372,036,854.78", + "9,223,372,036,854.78" + ] + .map(str::to_string) + ); + assert_eq!( + [ + 12_746.441206, + -1_000.125, + 999.999, + 0.0, + -0.000004, + 0.009, + 7.5 + ] + .map(data::amount), + [ + "12,746.44", + "-1,000.12", + "1,000", + "0", + "-0.000004", + "0.009", + "7.5" + ] + .map(str::to_string) + ); +}