diff --git a/docs/CLI.md b/docs/CLI.md index a9c25bcdcc..1106e15a13 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -64,8 +64,17 @@ codexbar cost codexbar cost -p codex -f json --pretty codexbar cost -p codex --remote user@mac-host codexbar cost -p codex --format json --summary-only --provider-native-only --days 30 +codexbar cost --period month-to-date --json +codexbar cost --period all --json +codexbar cost --days 7 --json ``` +**Reporting period.** `--period month-to-date|all` selects the calendar month so far (midnight on the first of the month, in the machine's local zone, through today) or all available local history. `--days N` (1..=365) always selects a rolling window and wins over `--period`. With neither flag the saved `cost_reporting_period` in `settings.json` applies (`rolling:30`, `month-to-date` or `all`; 30 days when unset). Text headings show the period label (`Last 30 days`, `Month to date`, `All`, `Today`). JSON payloads add `reportingPeriod` (the raw value), `historyLabel`, and a `totals` object (`inputTokens`, `outputTokens`, `cachedTokens`, `reasoningTokens`, `totalTokens`, `totalCost`) for the selected window, and `days_scanned` is the resolved day count; the existing `cost`, `tokens`, and `sessions_count` fields keep their meaning. `totals` is `null` when a scan found nothing without establishing a known zero. + +`--remote` and `--summary-only` keep the 1..=365 day protocol: month to date is sent as its current day count, and All is rejected unless you pass an explicit `--days N`. `--group-by session` lists the most recent 365 days at most when All is selected. All reads every available local log, including logs older than a year; missing or deleted logs cannot be recovered and incomplete scans stay marked incomplete. + +`serve` `/cost` reads the saved period on every request, so `days_scanned`, `totals`, the scanner windows and the `daily` chart rows follow it (`daily` covers at most the latest 365 days for All). The dashboard cost line, the dashboard snapshot, and the Prometheus gauge `codexbar_cost_last_30_days_usd` keep their 30-day meaning. + Claude/Codex costs come from local session logs. Antigravity exposes local **token history only** through `cost`; dollar cost remains unknown rather than becoming a false `$0`. Other providers may differ; do not assume upstream Cursor dashboard cost behavior unless implemented in this tree. `--remote` adds one separate native Codex report fetched through non-interactive SSH; overlapping local and remote histories are never combined. `--summary-only` emits the versioned, path-free JSON contract used by the remote comparison and accepts only `--provider codex --format json`. Both modes reject session grouping and other provider selections. diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 8e9be16dc2..97f328f1c6 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -4,11 +4,15 @@ use clap::Args; +use super::cost_period::{ + cost_totals_json, resolve_period, rolling_window_days, stamp_period, window_days, +}; use super::usage::{OutputFormat, ProviderSelection}; use crate::codex_costs::{ CodexHostCostReport, CodexHostCostsArgs, CodexHostOutcome, run_codex_host_costs, }; use crate::core::{CostScanOptions, ProviderId}; +use crate::cost_reporting_period::CostReportingPeriod; use crate::cost_scanner::{CostScanner, CostSummary}; use crate::settings::Settings; use crate::spend_contract::build_local_spend_contract_from_summary; @@ -36,9 +40,14 @@ pub struct CostArgs { #[arg(long)] pub pretty: bool, - /// Number of days to scan (default: 30) - #[arg(short, long, default_value = "30")] - pub days: u32, + /// Cost history window in days (1..=365); always rolling and overrides --period + #[arg(short, long)] + pub days: Option, + + /// Cost period: month-to-date or all. Without --days or --period the + /// saved cost period applies (30 days by default) + #[arg(long)] + pub period: Option, /// A16 (upstream 0.48.0): exclude pi/OMP-compatible agent session mirrors, /// reporting only the provider-native local JSONL logs. When omitted @@ -91,9 +100,19 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { let group_by = CostGroupBy::from_arg(args.group_by.as_deref()); let use_color = !args.no_color && is_terminal(); - if args.remote.is_some() || args.summary_only { + let settings = Settings::load(); + let host_summary = args.remote.is_some() || args.summary_only; + let period = resolve_period( + args.days, + args.period.as_deref(), + settings.cost_reporting_period, + host_summary, + )?; + let days = window_days(period); + + if host_summary { return run_codex_host_costs(&CodexHostCostsArgs { - days: args.days, + days, remote: args.remote.clone(), summary_only: args.summary_only, pretty: args.pretty, @@ -115,13 +134,14 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { // owns its mirrored Codex/Claude events. A single native-provider request // keeps the historical inclusive behavior unless explicitly narrowed. scan_options.include_pi_sessions = !args.provider_native_only && !pi_selected; - let scanner = CostScanner::new(args.days).with_options(scan_options); + let scanner = CostScanner::for_period(period).with_options(scan_options); tracing::debug!( - "Running cost command: providers={:?}, format={:?}, days={}", + "Running cost command: providers={:?}, format={:?}, period={}, days={}", providers.as_list(), format, - args.days + period, + days ); // Collect cost data for requested providers @@ -170,12 +190,12 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { summary: CostSummary::default(), supported: true, token_history: Some(crate::providers::antigravity::local_sessions::summarize( - args.days, + days, )), }); } ProviderId::Muse => { - let report = crate::providers::muse::local_usage::scan(args.days, None); + let report = crate::providers::muse::local_usage::scan(days, None); results.push(CostResult { provider: provider.cli_name().to_string(), display_name: provider.display_name().to_string(), @@ -199,10 +219,10 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { match format { OutputFormat::Text => { - print_text_output(&results, use_color, args.days, group_by); + print_text_output(&results, use_color, period, group_by); } OutputFormat::Json => { - print_json_output(&results, args.pretty, args.days)?; + print_json_output(&results, args.pretty, period, days, &settings)?; } } @@ -273,12 +293,18 @@ struct CostResult { } /// Print text output -fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_by: CostGroupBy) { +fn print_text_output( + results: &[CostResult], + use_color: bool, + period: CostReportingPeriod, + group_by: CostGroupBy, +) { + let label = period.label(); for (i, result) in results.iter().enumerate() { let title = if result.token_history.is_some() { - format!("{} Token History (last {} days)", result.display_name, days) + format!("{} Token History ({label})", result.display_name) } else { - format!("{} Cost (last {} days)", result.display_name, days) + format!("{} Cost ({label})", result.display_name) }; if use_color { println!("\x1b[1m{title}\x1b[0m"); @@ -287,9 +313,9 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b } if let Some(history) = result.token_history { - print_local_token_history(history, days); + print_local_token_history(history); } else if group_by == CostGroupBy::Session && result.provider == "codex" { - print_codex_session_output(result, days); + print_codex_session_output(result, period); } else if group_by == CostGroupBy::Session { println!(" Session grouping is only available for Codex local conversations"); } else if !result.supported { @@ -297,7 +323,7 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b println!(" (Only Codex and Claude have local logs)"); } else if result.summary.sessions_count == 0 { if result.summary.known_zero { - println!(" No usage in the last {} days (scan complete)", days); + println!(" No usage in the selected period (scan complete)"); } else { println!(" No usage data found"); println!(" Check that you have used {} locally", result.display_name); @@ -372,11 +398,11 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b } } -fn print_local_token_history(history: crate::spend_contract::LocalTokenHistorySummary, days: u32) { +fn print_local_token_history(history: crate::spend_contract::LocalTokenHistorySummary) { use crate::spend_contract::LocalHistoryCoverage; match history.coverage { LocalHistoryCoverage::Complete if history.total_tokens == 0 => { - println!(" No token usage in the last {days} days (scan complete)"); + println!(" No token usage in the selected period (scan complete)"); } LocalHistoryCoverage::Complete => { println!(" Tokens: {} total", format_number(history.total_tokens)); @@ -389,8 +415,10 @@ fn print_local_token_history(history: crate::spend_contract::LocalTokenHistorySu println!(" Local token history; dollar costs unavailable"); } -fn print_codex_session_output(result: &CostResult, days: u32) { - let index = crate::codex_workspaces::CodexWorkspacesIndex::new(days); +fn print_codex_session_output(result: &CostResult, period: CostReportingPeriod) { + // The conversation index keeps rolling 1..=365 day windows, so All lists + // the most recent year; the heading below reports the window it used. + let index = crate::codex_workspaces::CodexWorkspacesIndex::new(rolling_window_days(period)); let snapshot = match index.load_snapshot(false, |_| {}) { Ok(snapshot) => snapshot, Err(err) => { @@ -454,13 +482,20 @@ fn short_session_id(value: &str) -> String { } /// Print JSON output -fn build_json_payloads(results: &[CostResult], days: u32) -> Vec { - let settings = Settings::load(); +fn build_json_payloads( + results: &[CostResult], + period: CostReportingPeriod, + days: u32, + settings: &Settings, +) -> Vec { results .iter() .map(|r| { if let Some(history) = r.token_history { - return crate::spend_contract::local_token_history_json(&r.provider, history, days); + let mut payload = + crate::spend_contract::local_token_history_json(&r.provider, history, days); + stamp_period(&mut payload, period); + return payload; } if !r.supported { serde_json::json!({ @@ -478,10 +513,11 @@ fn build_json_payloads(results: &[CostResult], days: u32) -> Vec Vec>(), "period": {"start": r.summary.period_start.map(|d| d.to_string()), "end": r.summary.period_end.map(|d| d.to_string())}, "spendContract": spend_contract - }) + }); + stamp_period(&mut payload, period); + payload } }) .collect() } -fn print_json_output(results: &[CostResult], pretty: bool, days: u32) -> anyhow::Result<()> { - let payloads = build_json_payloads(results, days); +fn print_json_output( + results: &[CostResult], + pretty: bool, + period: CostReportingPeriod, + days: u32, + settings: &Settings, +) -> anyhow::Result<()> { + let payloads = build_json_payloads(results, period, days, settings); let output = if pretty { serde_json::to_string_pretty(&payloads)? @@ -646,6 +690,90 @@ mod tests { assert!(partial["tokens"]["total"].is_null()); assert_eq!(partial["historyCoverage"], "partial"); } + #[derive(clap::Parser)] + struct Wrapper { + #[command(flatten)] + args: CostArgs, + } + + fn parse_args(argv: &[&str]) -> CostArgs { + use clap::Parser; + Wrapper::try_parse_from(std::iter::once("cost").chain(argv.iter().copied())) + .expect("cost args parse") + .args + } + + #[test] + fn days_and_period_are_detectable_when_absent() { + let args = parse_args(&[]); + assert_eq!(args.days, None); + assert_eq!(args.period, None); + let args = parse_args(&["--period", "month-to-date", "--days", "7"]); + assert_eq!(args.days, Some(7)); + assert_eq!(args.period.as_deref(), Some("month-to-date")); + } + + #[test] + fn json_payload_carries_period_identity_and_totals() { + let summary = CostSummary { + total_cost_usd: 2.5, + input_tokens: 10, + output_tokens: 5, + sessions_count: 1, + ..Default::default() + }; + let results = [CostResult { + provider: "claude".to_string(), + display_name: "Claude".to_string(), + summary, + supported: true, + token_history: None, + }]; + let settings = Settings::default(); + let payloads = + build_json_payloads(&results, CostReportingPeriod::MonthToDate, 12, &settings); + let payload = &payloads[0]; + assert_eq!(payload["reportingPeriod"], "month-to-date"); + assert_eq!(payload["historyLabel"], "Month to date"); + assert_eq!(payload["days_scanned"], 12); + assert_eq!(payload["totals"]["totalCost"], 2.5); + assert_eq!(payload["totals"]["totalTokens"], 15); + // Existing fields keep their meaning. + assert_eq!(payload["cost"]["total_usd"], 2.5); + assert_eq!(payload["tokens"]["input"], 10); + + let rolling = + build_json_payloads(&results, CostReportingPeriod::Rolling(30), 30, &settings); + assert_eq!(rolling[0]["reportingPeriod"], "rolling:30"); + assert_eq!(rolling[0]["historyLabel"], "Last 30 days"); + } + + #[test] + fn token_history_payload_is_stamped_with_the_period() { + use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; + let results = [CostResult { + provider: "antigravity".to_string(), + display_name: "Antigravity".to_string(), + summary: CostSummary::default(), + supported: true, + token_history: Some(LocalTokenHistorySummary { + total_tokens: 3, + session_count: 1, + coverage: LocalHistoryCoverage::Complete, + }), + }]; + let settings = Settings::default(); + let payloads = build_json_payloads( + &results, + CostReportingPeriod::AllAvailable, + 20_000, + &settings, + ); + assert_eq!(payloads[0]["reportingPeriod"], "all"); + assert_eq!(payloads[0]["historyLabel"], "All"); + assert_eq!(payloads[0]["days_scanned"], 20_000); + } + #[test] fn provider_native_only_flag_default_false() { // Default CostArgs has provider_native_only = false (backward compat). diff --git a/rust/src/cli/cost_period.rs b/rust/src/cli/cost_period.rs new file mode 100644 index 0000000000..dee4c1274a --- /dev/null +++ b/rust/src/cli/cost_period.rs @@ -0,0 +1,192 @@ +//! Reporting-period selection shared by `codexbar cost` and `serve` `/cost` +//! (upstream 0.67.0 `decodeCostReportingPeriod`, `costTotals`). + +use chrono::Utc; +use serde_json::{Value, json}; + +use crate::cost_reporting_period::{CostReportingPeriod, CostTimeZone, MAX_ROLLING_DAYS}; +use crate::cost_scanner::CostSummary; + +/// Pick the period for one `cost` invocation. +/// +/// `--days N` always selects a rolling window and wins over `--period`; +/// without either flag the saved selection applies. Host summaries +/// (`--remote`, `--summary-only`) speak a 1..=365 day protocol, so All needs an +/// explicit `--days`. +pub(super) fn resolve_period( + days: Option, + period: Option<&str>, + saved: CostReportingPeriod, + host_summary: bool, +) -> anyhow::Result { + if let Some(days) = days { + return Ok(CostReportingPeriod::rolling(days)); + } + let period = match period { + Some(raw) => match CostReportingPeriod::parse(raw) { + Some(CostReportingPeriod::MonthToDate) => CostReportingPeriod::MonthToDate, + Some(CostReportingPeriod::AllAvailable) => CostReportingPeriod::AllAvailable, + _ => anyhow::bail!("--period must be month-to-date or all"), + }, + None => saved, + }; + if host_summary && period == CostReportingPeriod::AllAvailable { + anyhow::bail!("host summaries support 1...365 days; override All with --days N"); + } + Ok(period) +} + +/// Days covered by `period` today, in the zone the local scanners bucket by. +pub(crate) fn window_days(period: CostReportingPeriod) -> u32 { + period.days(Utc::now(), CostTimeZone::Local, None) +} + +/// Days for consumers that only understand a rolling 1..=365 window (daily +/// chart rows, conversation grouping). Month to date fits; All is capped. +pub(crate) fn rolling_window_days(period: CostReportingPeriod) -> u32 { + window_days(period).min(MAX_ROLLING_DAYS) +} + +/// Add `reportingPeriod` (raw) and `historyLabel` to one provider payload. +pub(crate) fn stamp_period(payload: &mut Value, period: CostReportingPeriod) { + if let Some(object) = payload.as_object_mut() { + object.insert("reportingPeriod".to_string(), json!(period.raw())); + object.insert("historyLabel".to_string(), json!(period.label())); + } +} + +/// Totals for the selected window. `null` when the scan found nothing and did +/// not establish a known zero, so a missing scan is never shown as `$0`. +pub(crate) fn cost_totals_json(summary: &CostSummary) -> Value { + if summary.sessions_count == 0 && !summary.known_zero { + return Value::Null; + } + json!({ + "inputTokens": summary.input_tokens, + "outputTokens": summary.output_tokens, + "cachedTokens": summary.cached_tokens, + "reasoningTokens": summary.reasoning_tokens, + "totalTokens": summary.input_tokens.saturating_add(summary.output_tokens), + "totalCost": summary.total_cost_usd, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAVED_MTD: CostReportingPeriod = CostReportingPeriod::MonthToDate; + + #[test] + fn days_beats_period_and_clamps() { + let resolved = resolve_period(Some(7), Some("all"), SAVED_MTD, false).unwrap(); + assert_eq!(resolved, CostReportingPeriod::Rolling(7)); + let clamped = resolve_period(Some(900), None, SAVED_MTD, false).unwrap(); + assert_eq!(clamped, CostReportingPeriod::Rolling(365)); + let floor = resolve_period(Some(0), None, SAVED_MTD, false).unwrap(); + assert_eq!(floor, CostReportingPeriod::Rolling(1)); + } + + #[test] + fn days_wins_even_over_an_invalid_period() { + let resolved = resolve_period(Some(7), Some("nonsense"), SAVED_MTD, false).unwrap(); + assert_eq!(resolved, CostReportingPeriod::Rolling(7)); + } + + #[test] + fn absent_flags_use_the_saved_period() { + let default = CostReportingPeriod::default(); + assert_eq!( + resolve_period(None, None, default, false).unwrap(), + CostReportingPeriod::Rolling(30) + ); + assert_eq!( + resolve_period(None, None, SAVED_MTD, false).unwrap(), + SAVED_MTD + ); + } + + #[test] + fn explicit_period_overrides_saved() { + let saved = CostReportingPeriod::Rolling(7); + assert_eq!( + resolve_period(None, Some("all"), saved, false).unwrap(), + CostReportingPeriod::AllAvailable + ); + assert_eq!( + resolve_period(None, Some("month-to-date"), saved, false).unwrap(), + CostReportingPeriod::MonthToDate + ); + } + + #[test] + fn invalid_period_is_rejected() { + let error = resolve_period(None, Some("weekly"), SAVED_MTD, false).unwrap_err(); + assert_eq!(error.to_string(), "--period must be month-to-date or all"); + assert!(resolve_period(None, Some("rolling:0"), SAVED_MTD, false).is_err()); + } + + #[test] + fn host_summaries_reject_all_without_explicit_days() { + let error = resolve_period(None, Some("all"), SAVED_MTD, true).unwrap_err(); + assert!(error.to_string().contains("override All with --days N")); + let saved_all = resolve_period(None, None, CostReportingPeriod::AllAvailable, true); + assert!(saved_all.is_err()); + assert_eq!( + resolve_period(Some(14), Some("all"), SAVED_MTD, true).unwrap(), + CostReportingPeriod::Rolling(14) + ); + assert_eq!( + resolve_period(None, None, SAVED_MTD, true).unwrap(), + SAVED_MTD + ); + } + + #[test] + fn rolling_window_is_capped_at_a_year_for_all() { + assert_eq!(rolling_window_days(CostReportingPeriod::AllAvailable), 365); + assert_eq!(rolling_window_days(CostReportingPeriod::Rolling(30)), 30); + assert!(window_days(CostReportingPeriod::AllAvailable) > 365); + assert!((1..=31).contains(&window_days(CostReportingPeriod::MonthToDate))); + } + + #[test] + fn stamp_adds_raw_period_and_label() { + let mut payload = json!({"provider": "codex"}); + stamp_period(&mut payload, CostReportingPeriod::MonthToDate); + assert_eq!(payload["reportingPeriod"], "month-to-date"); + assert_eq!(payload["historyLabel"], "Month to date"); + assert_eq!(payload["provider"], "codex"); + + let mut rolling = json!({}); + stamp_period(&mut rolling, CostReportingPeriod::Rolling(30)); + assert_eq!(rolling["reportingPeriod"], "rolling:30"); + assert_eq!(rolling["historyLabel"], "Last 30 days"); + } + + #[test] + fn totals_sum_tokens_and_keep_unknown_distinct_from_zero() { + let summary = CostSummary { + total_cost_usd: 1.5, + input_tokens: 100, + output_tokens: 20, + cached_tokens: 7, + sessions_count: 2, + ..CostSummary::default() + }; + let totals = cost_totals_json(&summary); + assert_eq!(totals["inputTokens"], 100); + assert_eq!(totals["outputTokens"], 20); + assert_eq!(totals["cachedTokens"], 7); + assert_eq!(totals["totalTokens"], 120); + assert_eq!(totals["totalCost"], 1.5); + assert!(totals["reasoningTokens"].is_null()); + + assert!(cost_totals_json(&CostSummary::default()).is_null()); + let known_zero = CostSummary { + known_zero: true, + ..CostSummary::default() + }; + assert_eq!(cost_totals_json(&known_zero)["totalCost"], 0.0); + } +} diff --git a/rust/src/cli/mod.rs b/rust/src/cli/mod.rs index 497581222c..714073ed58 100755 --- a/rust/src/cli/mod.rs +++ b/rust/src/cli/mod.rs @@ -15,6 +15,7 @@ pub mod account; pub mod autostart; pub mod config; pub mod cost; +mod cost_period; pub mod dashboard; pub mod diagnose; pub mod guard; diff --git a/rust/src/cli/serve/data.rs b/rust/src/cli/serve/data.rs index d1841db6bc..5041815791 100644 --- a/rust/src/cli/serve/data.rs +++ b/rust/src/cli/serve/data.rs @@ -6,9 +6,11 @@ use serde_json::json; +use crate::cli::cost_period::{cost_totals_json, rolling_window_days, stamp_period, window_days}; use crate::cli::usage::ProviderSelection; use crate::core::{CostScanOptions, FetchContext, ProviderId, SourceMode, instantiate_provider}; use crate::cost_scanner::{self, CostScanner}; +use crate::settings::Settings; use super::json_response; @@ -64,45 +66,56 @@ pub async fn cost_response(provider: Option<&str>) -> String { return json_response(400, json!({ "error": error.to_string() })); } }; - let scanner = CostScanner::new(30).with_options(CostScanOptions::app_driven()); + // The saved selection is read per request, so a change in Settings (or a + // month rollover for month to date) applies without restarting `serve`. + // There is no `/cost` response cache; the scanners' own caches are keyed by + // the resolved day range, so one period never reuses another's entries. + let period = Settings::load().cost_reporting_period; + let days = window_days(period); + let scanner = CostScanner::for_period(period).with_options(CostScanOptions::app_driven()); let mut results = Vec::new(); for provider_id in selection.as_list() { if provider_id == ProviderId::Antigravity { - let history = crate::providers::antigravity::local_sessions::summarize(30); - results.push(crate::spend_contract::local_token_history_json( - "antigravity", - history, - 30, - )); + let history = crate::providers::antigravity::local_sessions::summarize(days); + let mut payload = + crate::spend_contract::local_token_history_json("antigravity", history, days); + stamp_period(&mut payload, period); + results.push(payload); continue; } if provider_id == ProviderId::Muse { - let report = crate::providers::muse::local_usage::scan(30, None); - results.push(crate::spend_contract::local_token_history_json( - "muse", - report.into(), - 30, - )); + let report = crate::providers::muse::local_usage::scan(days, None); + let mut payload = + crate::spend_contract::local_token_history_json("muse", report.into(), days); + stamp_period(&mut payload, period); + results.push(payload); continue; } - let (supported, summary) = match provider_id { - ProviderId::Codex => (true, scanner.scan_codex()), - ProviderId::Claude => (true, scanner.scan_claude()), - ProviderId::Pi => (true, scanner.scan_pi()), - _ => (false, Default::default()), + let (supported, summary, daily) = match provider_id { + ProviderId::Codex => (true, scanner.scan_codex(), None), + ProviderId::Claude => { + let snapshot = scanner.scan_claude_chart_snapshot_with_cancel(None); + (true, snapshot.summary, Some(snapshot.daily_cost)) + } + ProviderId::Pi => (true, scanner.scan_pi(), None), + _ => (false, Default::default(), None), }; if supported { - // Daily spend history for the dashboard bar charts. The debounced - // helper reuses the cache the summary scan just warmed, so no - // second disk walk happens per request. - let daily = daily_json(cost_scanner::get_daily_cost_history( - provider_id.cli_name(), - 30, - )); - results.push(json!({ + // Claude's snapshot derives the summary and chart rows in one + // transcript walk. Other providers use the shared daily-history + // path, capped at one year for All. + let daily = daily.unwrap_or_else(|| { + cost_scanner::get_daily_cost_history( + provider_id.cli_name(), + rolling_window_days(period), + ) + }); + let daily = daily_json(daily); + let mut payload = json!({ "provider": provider_id.cli_name(), "supported": true, - "days_scanned": 30, + "days_scanned": days, + "totals": cost_totals_json(&summary), "cost": { "total_usd": summary.total_cost_usd, "currency": "USD" @@ -115,7 +128,9 @@ pub async fn cost_response(provider: Option<&str>) -> String { }, "sessions_count": summary.sessions_count, "by_model": summary.by_model, - })); + }); + stamp_period(&mut payload, period); + results.push(payload); } else { results.push(json!({ "provider": provider_id.cli_name(), diff --git a/rust/src/cost_reporting_period.rs b/rust/src/cost_reporting_period.rs index 0cef28014a..7a62fc1df9 100644 --- a/rust/src/cost_reporting_period.rs +++ b/rust/src/cost_reporting_period.rs @@ -217,6 +217,17 @@ impl CostReportingPeriod { } } + /// Human label shown next to totals: "Today", "Last N days", "Month to + /// date" or "All". + pub fn label(&self) -> String { + match self { + Self::Rolling(1) => "Today".to_string(), + Self::Rolling(days) => format!("Last {days} days"), + Self::MonthToDate => "Month to date".to_string(), + Self::AllAvailable => "All".to_string(), + } + } + /// Resolve a saved selection, falling back to a legacy day count and then /// to the 30-day default. An unreadable saved value migrates like a /// missing one. @@ -356,6 +367,14 @@ mod tests { } } + #[test] + fn labels_match_upstream_wording() { + assert_eq!(CostReportingPeriod::Rolling(1).label(), "Today"); + assert_eq!(CostReportingPeriod::Rolling(30).label(), "Last 30 days"); + assert_eq!(CostReportingPeriod::MonthToDate.label(), "Month to date"); + assert_eq!(CostReportingPeriod::AllAvailable.label(), "All"); + } + #[test] fn identity_distinguishes_periods_rollover_and_zones() { let january = at("2026-02-01T07:59:59Z");