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:
Felipe Coury
2026-09-15 17:21:21 +00:00
committed by copyberry
parent 1fd5399004
commit 7224096b85
6 changed files with 407 additions and 0 deletions

View File

@@ -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;

View 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())
}

View File

@@ -25,6 +25,7 @@ pub(crate) enum AccountAnalyticsUnit {
RelativeUsage,
Credits,
Count,
Tokens,
}
#[derive(Debug, Clone, PartialEq)]

View 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;

View 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,
},
],
}],
}
);
}

View 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)
);
}