diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea625cb..555d1b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `burn summary` marks unpriced model rows and labels totals as priced-only; JSON adds `unpricedTurns` and `unpricedModels` so unknown-model spend is never mistaken for free usage. + ## [4.0.0] - 2026-06-23 - **BREAKING (`relayburn-sdk`):** the published Rust SDK no longer re-exports its low-level `analyze`-layer internals (detector/aggregator functions and helper types such as `PricingTable`, `CompareTable`, `CompareCell`) — these were never the intended embedding surface. Embed through the verb layer instead: `LedgerHandle` methods / `summary_report` / `hotspots` / `compare`. CLI, MCP, and `@relayburn/sdk` behavior is unchanged. diff --git a/crates/relayburn-cli/src/commands/summary/human.rs b/crates/relayburn-cli/src/commands/summary/human.rs index 35b13299..a2e519d4 100644 --- a/crates/relayburn-cli/src/commands/summary/human.rs +++ b/crates/relayburn-cli/src/commands/summary/human.rs @@ -55,6 +55,33 @@ pub(super) fn coverage_cell(value: u64, c: &relayburn_sdk::FieldCoverage) -> Str format_uint(value) } +/// Render a grouped cost without making an unpriced model look free. Provider +/// and tag groups can mix priced and unpriced turns, so their priced subtotal +/// remains numeric and the summary-level unpriced line carries that caveat. +pub(super) fn grouped_cost_cell( + group_by: SummaryGroupBy, + unpriced_models: &[String], + label: &str, + cost: f64, +) -> String { + if group_by == SummaryGroupBy::Model + && unpriced_models.iter().any(|model| model.as_str() == label) + { + "unpriced".to_string() + } else { + format_usd(cost) + } +} + +pub(super) fn unpriced_turns_line(unpriced_turns: u64, unpriced_models: &[String]) -> String { + format!( + "{} {} unpriced: {} (total excludes their cost)", + format_uint(unpriced_turns), + if unpriced_turns == 1 { "turn" } else { "turns" }, + unpriced_models.join(", "), + ) +} + pub(super) fn emit_grouped( globals: &GlobalArgs, report: &SummaryGroupedReport, @@ -530,15 +557,31 @@ pub(super) fn emit_human( r.usage.cache_create_5m + r.usage.cache_create_1h, &r.coverage.cache_create, ), - format_usd(r.cost.total), + grouped_cost_cell( + report.group_by, + &report.unpriced_models, + &r.label, + r.cost.total, + ), ]); } lines.push(render_table(&rendered)); lines.push(String::new()); lines.push(format!( - "total cost: {}", + "{}: {}", + if report.unpriced_turns > 0 { + "total priced cost" + } else { + "total cost" + }, format_usd(report.total_cost.total) )); + if report.unpriced_turns > 0 { + lines.push(unpriced_turns_line( + report.unpriced_turns, + &report.unpriced_models, + )); + } lines.push(format!( " input {} / output {} / reasoning {} / cacheRead {} / cacheCreate {}", format_usd(report.total_cost.input), @@ -590,7 +633,7 @@ pub(super) fn emit_human( if report.unpriced_turns > 0 { let models = report.unpriced_models.join(", "); eprintln!( - "warning: {} turn(s) had no pricing for model(s): {} — their cost is reported as $0.", + "warning: {} turn(s) had no pricing for model(s): {}.", report.unpriced_turns, models, ); eprintln!( diff --git a/crates/relayburn-cli/src/commands/summary/json.rs b/crates/relayburn-cli/src/commands/summary/json.rs index 6e448833..a0e49540 100644 --- a/crates/relayburn-cli/src/commands/summary/json.rs +++ b/crates/relayburn-cli/src/commands/summary/json.rs @@ -101,6 +101,8 @@ pub(super) fn grouped_json_value( "totalCost".into(), cost_breakdown_to_json(&report.total_cost), ); + payload.insert("unpricedTurns".into(), json!(report.unpriced_turns)); + payload.insert("unpricedModels".into(), json!(&report.unpriced_models)); payload.insert(key.into(), Value::Array(group_rows)); payload.insert( "fidelity".into(), diff --git a/crates/relayburn-cli/src/commands/summary/mod.rs b/crates/relayburn-cli/src/commands/summary/mod.rs index 28c5babf..b22c3a80 100644 --- a/crates/relayburn-cli/src/commands/summary/mod.rs +++ b/crates/relayburn-cli/src/commands/summary/mod.rs @@ -458,7 +458,7 @@ mod tests { } #[test] - fn grouped_json_includes_quality_when_report_has_it() { + fn grouped_json_includes_quality_and_unpriced_usage() { let report = SummaryGroupedReport { group_by: SummaryGroupBy::Model, tag_key: None, @@ -480,13 +480,41 @@ mod tests { stop_reasons: relayburn_sdk::StopReasonCounts::default(), subagents: SubagentCounts::default(), quality: Some(QualityResult::default()), - unpriced_turns: 0, - unpriced_models: Vec::new(), + unpriced_turns: 2, + unpriced_models: vec!["made-up-model-xyz".into()], }; let value = grouped_json_value(&report, &relayburn_sdk::IngestReport::empty()); assert_eq!(value["quality"], json!({"outcomes": [], "oneShot": []})); + assert_eq!(value["unpricedTurns"], 2); + assert_eq!(value["unpricedModels"], json!(["made-up-model-xyz"])); + } + + #[test] + fn grouped_cost_cell_marks_unpriced_model() { + let unpriced_models = vec!["made-up-model-xyz".to_string()]; + assert_eq!( + grouped_cost_cell( + SummaryGroupBy::Model, + &unpriced_models, + "made-up-model-xyz", + 0.0, + ), + "unpriced" + ); + assert_eq!( + grouped_cost_cell(SummaryGroupBy::Model, &[], "free-model", 0.0), + "$0.00" + ); + assert_eq!( + grouped_cost_cell(SummaryGroupBy::Provider, &unpriced_models, "openai", 1.25,), + "$1.25" + ); + assert_eq!( + unpriced_turns_line(2, &unpriced_models), + "2 turns unpriced: made-up-model-xyz (total excludes their cost)" + ); } #[test]