Skip to content
Prev Previous commit
fix(zcode): saturate report token arithmetic
Apply saturating arithmetic to shared, focused, and unified report accumulation and totals so extreme counters cannot panic or wrap. Add daily and weekly report regressions and document the ZCode fixture surface and its dependencies.

Co-authored-by: axisrow <[email protected]>
  • Loading branch information
ryoppippi and axisrow committed Aug 31, 2026
commit 6a38480d7687638d6c53a3783a6c7d05a468945c
41 changes: 40 additions & 1 deletion rust/adapters/zcode/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ mod tests {
}

#[test]
fn snapshots_focused_zcode_json_reports_for_daily_monthly_and_session() {
fn snapshots_focused_zcode_json_reports_for_daily_monthly_weekly_and_session() {
let entries = snapshot_entries();
let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap();
let monthly = summarize_entries(&entries, AgentReportKind::Monthly).unwrap();
Expand All @@ -157,6 +157,45 @@ mod tests {
);
}

#[test]
fn saturates_focused_daily_and_weekly_reports_for_extreme_counters() {
let entries = [
extreme_entry("2099-01-02", 4_070_908_800_000, u64::MAX),
extreme_entry("2099-01-03", 4_071_081_600_000, 1),
];
let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap();
let daily_report = report_from_rows(&daily, AgentReportKind::Daily);
let weekly = summarize_entries(&entries, AgentReportKind::Weekly).unwrap();
let weekly_report = report_from_rows(&weekly, AgentReportKind::Weekly);

for report in [daily_report, weekly_report] {
for key in [
"inputTokens",
"outputTokens",
"cacheCreationTokens",
"cacheReadTokens",
"totalTokens",
] {
assert_eq!(report["totals"][key], u64::MAX, "{key}");
}
}
assert_eq!(weekly[0].model_breakdowns[0].input_tokens, u64::MAX);
assert_eq!(weekly[0].model_breakdowns[0].output_tokens, u64::MAX);
}

fn extreme_entry(date: &str, millis: i64, tokens: u64) -> LoadedEntry {
let mut entry = entry("session-extreme", date, millis);
entry.data.message.usage = TokenUsageRaw {
input_tokens: tokens,
output_tokens: tokens,
cache_creation_input_tokens: tokens,
cache_read_input_tokens: tokens,
..TokenUsageRaw::default()
};
entry.extra_total_tokens = tokens;
entry
}

fn snapshot_entries() -> Vec<LoadedEntry> {
[
(
Expand Down
37 changes: 29 additions & 8 deletions rust/crates/ccusage-adapter-all/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,26 @@ fn agent_json(row: &AllRow) -> Value {

fn totals_json(rows: &[AllRow], include_sources: bool) -> Value {
let mut totals = json!({
"inputTokens": rows.iter().map(|row| row.input_tokens).sum::<u64>(),
"outputTokens": rows.iter().map(|row| row.output_tokens).sum::<u64>(),
"cacheCreationTokens": rows.iter().map(|row| row.cache_creation_tokens).sum::<u64>(),
"cacheReadTokens": rows.iter().map(|row| row.cache_read_tokens).sum::<u64>(),
"totalTokens": rows.iter().map(|row| row.total_tokens).sum::<u64>(),
"inputTokens": rows
.iter()
.map(|row| row.input_tokens)
.fold(0, u64::saturating_add),
"outputTokens": rows
.iter()
.map(|row| row.output_tokens)
.fold(0, u64::saturating_add),
"cacheCreationTokens": rows
.iter()
.map(|row| row.cache_creation_tokens)
.fold(0, u64::saturating_add),
"cacheReadTokens": rows
.iter()
.map(|row| row.cache_read_tokens)
.fold(0, u64::saturating_add),
"totalTokens": rows
.iter()
.map(|row| row.total_tokens)
.fold(0, u64::saturating_add),
"totalCost": json_float(rows.iter().map(|row| row.total_cost).sum::<f64>()),
});
if include_sources {
Expand Down Expand Up @@ -356,7 +371,10 @@ pub(super) fn print_table_with_options(
}
table.separator();
let totals = totals_json(rows, include_sources);
let table_total_tokens = rows.iter().map(table_total_tokens).sum::<u64>();
let table_total_tokens = rows
.iter()
.map(table_total_tokens)
.fold(0, u64::saturating_add);
if compact {
let mut total_row = vec![
color(shared, "Total", Color::Yellow),
Expand Down Expand Up @@ -721,8 +739,11 @@ fn push_model_breakdown_rows(
shared: &SharedArgs,
) {
for b in breakdowns {
let total =
b.input_tokens + b.output_tokens + b.cache_creation_tokens + b.cache_read_tokens;
let total = b
.input_tokens
.saturating_add(b.output_tokens)
.saturating_add(b.cache_creation_tokens)
.saturating_add(b.cache_read_tokens);
let model = color(
shared,
format!("- {}", short_model_name(&b.model_name)),
Expand Down
81 changes: 81 additions & 0 deletions rust/crates/ccusage-adapter-all/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,87 @@ fn aggregates_daily_agent_rows_by_period() {
assert_eq!(breakdowns[1].agent, "codex");
}

#[test]
fn saturates_unified_daily_and_weekly_reports_for_extreme_counters() {
let rows = vec![
extreme_all_row("2099-01-02", u64::MAX),
extreme_all_row("2099-01-03", 1),
];
let daily_rows = aggregate_rows(rows.clone(), AgentReportKind::Daily);
let daily_report = report_json_with_options(&daily_rows, AgentReportKind::Daily, false, true);
let weekly_rows = aggregate_rows(rows, AgentReportKind::Weekly);
let weekly_report =
report_json_with_options(&weekly_rows, AgentReportKind::Weekly, false, true);

for report in [daily_report, weekly_report] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test does not verify that weekly aggregation produced one row, so a broken weekly bucket can still pass because report totals saturate across separate rows and the assertions inspect only row 0. Assert weekly_rows.len() == 1 before checking the weekly result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At rust/crates/ccusage-adapter-all/src/tests.rs, line 152:

<comment>This test does not verify that weekly aggregation produced one row, so a broken weekly bucket can still pass because report totals saturate across separate rows and the assertions inspect only row 0. Assert `weekly_rows.len() == 1` before checking the weekly result.</comment>

<file context>
@@ -137,6 +137,87 @@ fn aggregates_daily_agent_rows_by_period() {
+    let weekly_report =
+        report_json_with_options(&weekly_rows, AgentReportKind::Weekly, false, true);
+
+    for report in [daily_report, weekly_report] {
+        for key in [
+            "inputTokens",
</file context>
Suggested change
for report in [daily_report, weekly_report] {
assert_eq!(weekly_rows.len(), 1);
for report in [daily_report, weekly_report] {

for key in [
"inputTokens",
"outputTokens",
"cacheCreationTokens",
"cacheReadTokens",
"totalTokens",
] {
assert_eq!(report["totals"][key], u64::MAX, "{key}");
}
assert_eq!(
report["totals"]["sourceBreakdowns"][0]["inputTokens"],
u64::MAX
);
assert_eq!(
report["totals"]["sourceBreakdowns"][0]["modelBreakdowns"][0]["inputTokens"],
u64::MAX
);
}
assert_eq!(weekly_rows[0].model_breakdowns[0].input_tokens, u64::MAX);
assert_eq!(weekly_rows[0].model_breakdowns[0].output_tokens, u64::MAX);
}

fn extreme_all_row(period: &str, tokens: u64) -> AllRow {
AllRow {
period: period.to_string(),
agent: "codex",
models_used: vec!["gpt-5".to_string()],
input_tokens: tokens,
output_tokens: tokens,
cache_creation_tokens: tokens,
cache_read_tokens: tokens,
total_tokens: tokens,
total_cost: 0.0,
metadata: Some(json!({
"sourceBreakdowns": [{
"source": "Responses API",
"modelsUsed": ["gpt-5"],
"inputTokens": tokens,
"outputTokens": tokens,
"cacheCreationTokens": tokens,
"cacheReadTokens": tokens,
"reasoningOutputTokens": tokens,
"totalTokens": tokens,
"totalCost": 0.0,
"modelBreakdowns": [{
"modelName": "gpt-5",
"inputTokens": tokens,
"outputTokens": tokens,
"cacheCreationTokens": tokens,
"cacheReadTokens": tokens,
"cost": 0.0
}]
}]
})),
metadata_agents: Some(vec!["codex"]),
agent_breakdowns: None,
model_breakdowns: vec![ModelBreakdown {
model_name: "gpt-5".to_string(),
input_tokens: tokens,
output_tokens: tokens,
cache_creation_tokens: tokens,
cache_read_tokens: tokens,
extra_total_tokens: tokens,
..ModelBreakdown::default()
}],
}
}

#[test]
fn merges_same_agent_daily_rows_into_one_monthly_breakdown() {
let rows = aggregate_rows(
Expand Down
52 changes: 31 additions & 21 deletions rust/crates/ccusage-adapter-all/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,13 @@ pub(super) struct AllAccumulator {

impl AllAccumulator {
pub(super) fn add(&mut self, row: AllRow) {
self.input_tokens += row.input_tokens;
self.output_tokens += row.output_tokens;
self.cache_creation_tokens += row.cache_creation_tokens;
self.cache_read_tokens += row.cache_read_tokens;
self.total_tokens += row.total_tokens;
self.input_tokens = self.input_tokens.saturating_add(row.input_tokens);
self.output_tokens = self.output_tokens.saturating_add(row.output_tokens);
self.cache_creation_tokens = self
.cache_creation_tokens
.saturating_add(row.cache_creation_tokens);
self.cache_read_tokens = self.cache_read_tokens.saturating_add(row.cache_read_tokens);
self.total_tokens = self.total_tokens.saturating_add(row.total_tokens);
self.total_cost += row.total_cost;
self.models.extend(row.models_used.iter().cloned());
if let Some(agents) = row.metadata_agents.as_ref() {
Expand Down Expand Up @@ -132,11 +134,15 @@ impl AllAccumulator {
}

fn merge_agent_breakdown(target: &mut AllRow, source: AllRow) {
target.input_tokens += source.input_tokens;
target.output_tokens += source.output_tokens;
target.cache_creation_tokens += source.cache_creation_tokens;
target.cache_read_tokens += source.cache_read_tokens;
target.total_tokens += source.total_tokens;
target.input_tokens = target.input_tokens.saturating_add(source.input_tokens);
target.output_tokens = target.output_tokens.saturating_add(source.output_tokens);
target.cache_creation_tokens = target
.cache_creation_tokens
.saturating_add(source.cache_creation_tokens);
target.cache_read_tokens = target
.cache_read_tokens
.saturating_add(source.cache_read_tokens);
target.total_tokens = target.total_tokens.saturating_add(source.total_tokens);
target.total_cost += source.total_cost;
let mut models: BTreeSet<String> = target.models_used.drain(..).collect();
models.extend(source.models_used);
Expand Down Expand Up @@ -209,7 +215,7 @@ fn add_json_number(target: &mut Map<String, Value>, source: &Map<String, Value>,
return;
};
let value = match (target_value.as_u64(), source_value.as_u64()) {
(Some(target), Some(source)) => json!(target + source),
(Some(target), Some(source)) => json!(target.saturating_add(source)),
_ => {
json_float(target_value.as_f64().unwrap_or(0.0) + source_value.as_f64().unwrap_or(0.0))
}
Expand Down Expand Up @@ -301,11 +307,13 @@ fn merge_model_breakdowns(
i
});
let b = &mut breakdowns[index];
b.input_tokens += item.input_tokens;
b.output_tokens += item.output_tokens;
b.cache_creation_tokens += item.cache_creation_tokens;
b.cache_read_tokens += item.cache_read_tokens;
b.extra_total_tokens += item.extra_total_tokens;
b.input_tokens = b.input_tokens.saturating_add(item.input_tokens);
b.output_tokens = b.output_tokens.saturating_add(item.output_tokens);
b.cache_creation_tokens = b
.cache_creation_tokens
.saturating_add(item.cache_creation_tokens);
b.cache_read_tokens = b.cache_read_tokens.saturating_add(item.cache_read_tokens);
b.extra_total_tokens = b.extra_total_tokens.saturating_add(item.extra_total_tokens);
b.cost += item.cost;
b.missing_pricing |= item.missing_pricing;
}
Expand All @@ -327,11 +335,13 @@ fn aggregate_model_breakdowns(rows: &[AllRow]) -> Vec<ModelBreakdown> {
i
});
let b = &mut breakdowns[index];
b.input_tokens += item.input_tokens;
b.output_tokens += item.output_tokens;
b.cache_creation_tokens += item.cache_creation_tokens;
b.cache_read_tokens += item.cache_read_tokens;
b.extra_total_tokens += item.extra_total_tokens;
b.input_tokens = b.input_tokens.saturating_add(item.input_tokens);
b.output_tokens = b.output_tokens.saturating_add(item.output_tokens);
b.cache_creation_tokens = b
.cache_creation_tokens
.saturating_add(item.cache_creation_tokens);
b.cache_read_tokens = b.cache_read_tokens.saturating_add(item.cache_read_tokens);
b.extra_total_tokens = b.extra_total_tokens.saturating_add(item.extra_total_tokens);
b.cost += item.cost;
b.missing_pricing |= item.missing_pricing;
}
Expand Down
47 changes: 33 additions & 14 deletions rust/crates/ccusage-core/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,36 @@ pub fn session_summary_json(row: &UsageSummary) -> Value {
}

pub fn totals_json(rows: &[UsageSummary]) -> Value {
let input = rows.iter().map(|row| row.input_tokens).sum::<u64>();
let output = rows.iter().map(|row| row.output_tokens).sum::<u64>();
let input = rows
.iter()
.map(|row| row.input_tokens)
.fold(0, u64::saturating_add);
let output = rows
.iter()
.map(|row| row.output_tokens)
.fold(0, u64::saturating_add);
let cache_create = rows
.iter()
.map(|row| row.cache_creation_tokens)
.sum::<u64>();
let cache_read = rows.iter().map(|row| row.cache_read_tokens).sum::<u64>();
let extra = rows.iter().map(|row| row.extra_total_tokens).sum::<u64>();
.fold(0, u64::saturating_add);
let cache_read = rows
.iter()
.map(|row| row.cache_read_tokens)
.fold(0, u64::saturating_add);
let extra = rows
.iter()
.map(|row| row.extra_total_tokens)
.fold(0, u64::saturating_add);
let mut value = json!({
"inputTokens": input,
"outputTokens": output,
"cacheCreationTokens": cache_create,
"cacheReadTokens": cache_read,
"totalTokens": input + output + cache_create + cache_read + extra,
"totalTokens": input
.saturating_add(output)
.saturating_add(cache_create)
.saturating_add(cache_read)
.saturating_add(extra),
"totalCost": rows.iter().map(|row| row.total_cost).sum::<f64>(),
});
let credits = rows.iter().filter_map(|row| row.credits).sum::<f64>();
Expand Down Expand Up @@ -299,10 +315,12 @@ pub fn print_usage_table_with_options(
.get("totalCost")
.and_then(Value::as_f64)
.unwrap_or_default();
let total_tokens = totals
.get("totalTokens")
.and_then(Value::as_u64)
.unwrap_or(input + output + cache_create + cache_read);
let total_tokens = totals.get("totalTokens").and_then(Value::as_u64).unwrap_or(
input
.saturating_add(output)
.saturating_add(cache_create)
.saturating_add(cache_read),
);
table.separator();
let mut total_row = vec![
color(shared, "Total", Color::Yellow),
Expand Down Expand Up @@ -437,10 +455,11 @@ fn push_breakdown_rows(
shared: &SharedArgs,
) {
for breakdown in &row.model_breakdowns {
let total = breakdown.input_tokens
+ breakdown.output_tokens
+ breakdown.cache_creation_tokens
+ breakdown.cache_read_tokens;
let total = breakdown
.input_tokens
.saturating_add(breakdown.output_tokens)
.saturating_add(breakdown.cache_creation_tokens)
.saturating_add(breakdown.cache_read_tokens);
let mut values = vec![
color(
shared,
Expand Down
Loading
Loading