mirror of
https://github.com/openai/codex.git
synced 2026-09-17 12:23:33 +00:00
Add token history and credit formatting helpers for analytics (#45741)
## What changed - Add daily text token history grouped by model or token type, with date-range and model filtering, freshness metadata, and validation of token counts. Support both grouped and per-model report data, including reported model totals when components are unavailable. - Add credit formatting helpers that preserve tiny signed adjustments and format integer millionths without floating-point rounding, plus a short date formatter. ## Testing Add unit tests for token groupings, model filtering, freshness, missing components, explicit zero totals, and credit formatting across tiny refunds and integer limits. GitOrigin-RevId: 706dcae7995e46707c2f1df9cdf3e06966459dd6
This commit is contained in:
@@ -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;
|
||||
|
||||
79
codex-rs/tui/src/analytics/data.rs
Normal file
79
codex-rs/tui/src/analytics/data.rs
Normal file
@@ -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())
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub(crate) enum AccountAnalyticsUnit {
|
||||
RelativeUsage,
|
||||
Credits,
|
||||
Count,
|
||||
Tokens,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
||||
135
codex-rs/tui/src/analytics/tokens.rs
Normal file
135
codex-rs/tui/src/analytics/tokens.rs
Normal file
@@ -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<AccountAnalyticsHistory, String> {
|
||||
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<AccountAnalyticsHistory, String> {
|
||||
let mut days: BTreeMap<NaiveDate, BTreeMap<String, f64>> = BTreeMap::new();
|
||||
for record in response.data {
|
||||
let date = record
|
||||
.date
|
||||
.parse::<NaiveDate>()
|
||||
.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::<Vec<_>>()
|
||||
} 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;
|
||||
131
codex-rs/tui/src/analytics/tokens_tests.rs
Normal file
131
codex-rs/tui/src/analytics/tokens_tests.rs
Normal file
@@ -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<_>>(),
|
||||
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,
|
||||
},
|
||||
],
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
55
codex-rs/tui/src/analytics_tests.rs
Normal file
55
codex-rs/tui/src/analytics_tests.rs
Normal file
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user