From c40b9452dd0a29038dde6729c5fdc7947ba9ccbe Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 15:24:17 +0700 Subject: [PATCH 1/2] Add golden tests for provider-gated cost CLI output --- rust/src/cli/cost.rs | 344 ++++++++++------ rust/src/cli/cost_golden_tests.rs | 655 ++++++++++++++++++++++++++++++ 2 files changed, 878 insertions(+), 121 deletions(-) create mode 100644 rust/src/cli/cost_golden_tests.rs diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 47cd64a7e6..a9506191eb 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -112,6 +112,15 @@ const PARTIAL_HISTORY_NOTICE: &str = " Coverage: partial (history catch-up in p /// Run the cost command pub async fn run(args: CostArgs) -> anyhow::Result<()> { + run_with_settings(args, Settings::load).await +} + +/// `load_settings` runs only once a mode needs saved settings; tests inject +/// defaults so they never read the user's real settings file. +async fn run_with_settings( + args: CostArgs, + load_settings: impl FnOnce() -> Settings, +) -> anyhow::Result<()> { let format = if args.json { OutputFormat::Json } else { @@ -143,7 +152,7 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { .await; } - let settings = Settings::load(); + let settings = load_settings(); let host_summary = args.remote.is_some() || args.summary_only; let period = resolve_period( args.days, @@ -352,6 +361,19 @@ struct CostResult { token_history: Option, } +/// Appends one formatted line to a text report buffer. +macro_rules! outln { + ($out:expr) => { + $out.push('\n') + }; + ($out:expr, $($arg:tt)*) => {{ + $out.push_str(&format!($($arg)*)); + $out.push('\n'); + }}; +} + +type WorkspacesIndexFor<'a> = &'a dyn Fn(u32) -> crate::codex_workspaces::CodexWorkspacesIndex; + /// Print text output fn print_text_output( results: &[CostResult], @@ -360,114 +382,149 @@ fn print_text_output( group_by: CostGroupBy, hide_personal_info: bool, ) { - let label = period.label(); for (i, result) in results.iter().enumerate() { - let title = if result.token_history.is_some() { - format!("{} Token History ({label})", result.display_name) + let mut out = String::new(); + render_result_text( + &mut out, + result, + use_color, + period, + group_by, + hide_personal_info, + &crate::codex_workspaces::CodexWorkspacesIndex::new, + ); + if i < results.len() - 1 { + outln!(out); + } + print!("{out}"); + } +} + +fn render_result_text( + out: &mut String, + result: &CostResult, + use_color: bool, + period: CostReportingPeriod, + group_by: CostGroupBy, + hide_personal_info: bool, + workspaces: WorkspacesIndexFor<'_>, +) { + let label = period.label(); + let title = if result.token_history.is_some() { + format!("{} Token History ({label})", result.display_name) + } else { + format!("{} Cost ({label})", result.display_name) + }; + if use_color { + outln!(out, "\x1b[1m{title}\x1b[0m"); + } else { + outln!(out, "{title}"); + } + + if group_by == CostGroupBy::Project && result.provider == "codex" { + render_codex_project_output(out, result, period, hide_personal_info, workspaces); + } else if group_by == CostGroupBy::Project { + outln!( + out, + " Project grouping is only available for Codex local projects" + ); + } else if let Some(history) = result.token_history.as_ref() { + render_local_token_history(out, history); + } else if group_by == CostGroupBy::Session && result.provider == "codex" { + render_codex_session_output(out, result, period, workspaces); + } else if group_by == CostGroupBy::Session { + outln!( + out, + " Session grouping is only available for Codex local conversations" + ); + } else if !result.supported { + outln!(out, " Local cost scanning not available for this provider"); + outln!(out, " (Only Codex and Claude have local logs)"); + } else if result.summary.sessions_count == 0 { + if result.summary.incomplete_request_count > 0 { + // Only preliminary proxy rows exist: usage is unavailable, not $0. + outln!(out, " No completed usage data found"); + render_incomplete_note(out, result.summary.incomplete_request_count); + } else if result.summary.known_zero { + outln!(out, " No usage in the selected period (scan complete)"); } else { - format!("{} Cost ({label})", result.display_name) - }; + outln!(out, " No usage data found"); + outln!( + out, + " Check that you have used {} locally", + result.display_name + ); + } + } else { + // Total cost + let incomplete_suffix = incomplete_suffix(result.summary.incomplete_request_count); if use_color { - println!("\x1b[1m{title}\x1b[0m"); + outln!( + out, + " Total: \x1b[32m{}\x1b[0m{incomplete_suffix}", + result.summary.format_total() + ); } else { - println!("{title}"); + outln!( + out, + " Total: {}{incomplete_suffix}", + result.summary.format_total() + ); } - if group_by == CostGroupBy::Project && result.provider == "codex" { - print_codex_project_output(result, period, hide_personal_info); - } else if group_by == CostGroupBy::Project { - println!(" Project grouping is only available for Codex local projects"); - } else if let Some(history) = result.token_history.as_ref() { - print_local_token_history(history); - } else if group_by == CostGroupBy::Session && result.provider == "codex" { - 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 { - println!(" Local cost scanning not available for this provider"); - println!(" (Only Codex and Claude have local logs)"); - } else if result.summary.sessions_count == 0 { - if result.summary.incomplete_request_count > 0 { - // Only preliminary proxy rows exist: usage is unavailable, not $0. - println!(" No completed usage data found"); - print_incomplete_note(result.summary.incomplete_request_count); - } else if result.summary.known_zero { - 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); - } - } else { - // Total cost - let incomplete_suffix = incomplete_suffix(result.summary.incomplete_request_count); - if use_color { - println!( - " Total: \x1b[32m{}\x1b[0m{incomplete_suffix}", - result.summary.format_total() - ); - } else { - println!( - " Total: {}{incomplete_suffix}", - result.summary.format_total() - ); - } - - // Token breakdown - println!( - " Tokens: {} input, {} output, {} cached", - format_number(result.summary.input_tokens), - format_number(result.summary.output_tokens), - format_number(result.summary.cached_tokens) - ); + // Token breakdown + outln!( + out, + " Tokens: {} input, {} output, {} cached", + format_number(result.summary.input_tokens), + format_number(result.summary.output_tokens), + format_number(result.summary.cached_tokens) + ); - // Sessions - println!(" Sessions: {}", result.summary.sessions_count); - print_incomplete_note(result.summary.incomplete_request_count); - - // Cost by model - if !result.summary.by_model.is_empty() { - println!(" By model:"); - let mut models: Vec<_> = result.summary.by_model.iter().collect(); - models.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal)); - for (model, cost) in models { - println!(" {}: ${:.2}", model, cost); - } + // Sessions + outln!(out, " Sessions: {}", result.summary.sessions_count); + render_incomplete_note(out, result.summary.incomplete_request_count); + + // Cost by model + if !result.summary.by_model.is_empty() { + outln!(out, " By model:"); + let mut models: Vec<_> = result.summary.by_model.iter().collect(); + models.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal)); + for (model, cost) in models { + outln!(out, " {}: ${:.2}", model, cost); } + } - if !result.summary.by_speed.is_empty() { - println!(" Codex speed:"); - for bucket in ["standard", "fast"] { - if let Some(cost) = result.summary.by_speed.get(bucket) { - let tokens = result - .summary - .by_speed_tokens - .get(bucket) - .map(|counts| format_number(counts.total())) - .unwrap_or_else(|| "0".to_string()); - println!(" {}: ${:.2} ({} tokens)", bucket, cost, tokens); - } + if !result.summary.by_speed.is_empty() { + outln!(out, " Codex speed:"); + for bucket in ["standard", "fast"] { + if let Some(cost) = result.summary.by_speed.get(bucket) { + let tokens = result + .summary + .by_speed_tokens + .get(bucket) + .map(|counts| format_number(counts.total())) + .unwrap_or_else(|| "0".to_string()); + outln!(out, " {}: ${:.2} ({} tokens)", bucket, cost, tokens); } } + } - // F18 (upstream 0.48.0): label partial pricing completeness. - if let crate::cost_scanner::ModelPricingCompleteness::Partial { unpriced_models } = - &result.summary.model_pricing_completeness - && !unpriced_models.is_empty() - { - println!( - " Pricing: partial (unpriced: {})", - unpriced_models.join(", ") - ); - } - - // A16 (upstream 0.48.0): coverage status for Codex. - if result.provider == "codex" && !result.summary.history_coverage_established { - println!("{PARTIAL_HISTORY_NOTICE}"); - } + // F18 (upstream 0.48.0): label partial pricing completeness. + if let crate::cost_scanner::ModelPricingCompleteness::Partial { unpriced_models } = + &result.summary.model_pricing_completeness + && !unpriced_models.is_empty() + { + outln!( + out, + " Pricing: partial (unpriced: {})", + unpriced_models.join(", ") + ); } - if i < results.len() - 1 { - println!(); + // A16 (upstream 0.48.0): coverage status for Codex. + if result.provider == "codex" && !result.summary.history_coverage_established { + outln!(out, "{PARTIAL_HISTORY_NOTICE}"); } } } @@ -478,70 +535,101 @@ fn incomplete_suffix(count: u32) -> &'static str { if count > 0 { " · Incomplete" } else { "" } } -fn print_incomplete_note(count: u32) { +fn render_incomplete_note(out: &mut String, count: u32) { if count > 0 { - println!( + outln!( + out, " Incomplete: {count} requests lacked final usage and were excluded from tokens and cost." ); } } -fn print_local_token_history(history: &crate::spend_contract::LocalTokenHistorySummary) { +fn render_local_token_history( + out: &mut String, + history: &crate::spend_contract::LocalTokenHistorySummary, +) { use crate::spend_contract::LocalHistoryCoverage; match history.coverage { LocalHistoryCoverage::Partial if history.lower_bound => { - println!( + outln!( + out, " Tokens: at least {} (lower bound; scan stopped short)", format_number(history.total_tokens) ); - println!(" Sessions: at least {}", history.session_count); + outln!(out, " Sessions: at least {}", history.session_count); } LocalHistoryCoverage::Complete if history.total_tokens == 0 => { - println!(" No token usage in the selected period (scan complete)"); + outln!( + out, + " No token usage in the selected period (scan complete)" + ); } LocalHistoryCoverage::Complete => { - println!(" Tokens: {} total", format_number(history.total_tokens)); - println!(" Sessions: {}", history.session_count); + outln!( + out, + " Tokens: {} total", + format_number(history.total_tokens) + ); + outln!(out, " Sessions: {}", history.session_count); } LocalHistoryCoverage::Partial | LocalHistoryCoverage::Unavailable => { - println!(" Local token history is unavailable or incomplete"); + outln!(out, " Local token history is unavailable or incomplete"); } } if let Some(cost) = history.total_usd() { - println!(" API list-price estimate: ${cost:.2} (not billed spend)"); + outln!( + out, + " API list-price estimate: ${cost:.2} (not billed spend)" + ); } else if let Some(cost) = history.cost_estimate.known_subtotal_usd { if history.coverage == LocalHistoryCoverage::Complete { - println!( + outln!( + out, " Known API list-price subtotal: ${cost:.2} ({} unpriced requests)", history.cost_estimate.coverage.unpriced ); } else { - println!(" Known API list-price subtotal: at least ${cost:.2} (history incomplete)"); + outln!( + out, + " Known API list-price subtotal: at least ${cost:.2} (history incomplete)" + ); } } else { - println!(" Local token history; dollar costs unavailable"); + outln!(out, " Local token history; dollar costs unavailable"); } } -fn print_codex_session_output(result: &CostResult, period: CostReportingPeriod) { +fn render_codex_session_output( + out: &mut String, + result: &CostResult, + period: CostReportingPeriod, + workspaces: WorkspacesIndexFor<'_>, +) { // 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 index = workspaces(rolling_window_days(period)); let snapshot = match index.load_snapshot(false, |_| {}) { Ok(snapshot) => snapshot, Err(err) => { - println!(" Conversation history unavailable: {err}"); + outln!(out, " Conversation history unavailable: {err}"); return; } }; - println!(" Conversations (last {} days):", snapshot.history_days); + outln!( + out, + " Conversations (last {} days):", + snapshot.history_days + ); if snapshot.source_status.is_partial() { - println!(" Conversation history is incomplete while local indexing catches up."); + outln!( + out, + " Conversation history is incomplete while local indexing catches up." + ); } if snapshot.sessions.is_empty() { - println!(" —"); + outln!(out, " —"); } else { for session in &snapshot.sessions { let id = short_session_id(&session.id); @@ -551,12 +639,14 @@ fn print_codex_session_output(result: &CostResult, period: CostReportingPeriod) format!("${:.2}", session.cost_estimate.known_usd) }; let model = session.top_model.as_deref().unwrap_or("unknown model"); - println!( + outln!( + out, " Session {id}: {cost} · {} tokens · {model}", format_number(session.totals.total_tokens) ); if let Some(activity) = session.latest_activity { - println!( + outln!( + out, " {}", activity .with_timezone(&chrono::Local) @@ -567,17 +657,25 @@ fn print_codex_session_output(result: &CostResult, period: CostReportingPeriod) } if !result.summary.history_coverage_established { - println!(" Coverage: partial (cost history catch-up in progress)"); + outln!( + out, + " Coverage: partial (cost history catch-up in progress)" + ); } - println!(" Not a subscription bill or plan value · local usage × public API prices"); + outln!( + out, + " Not a subscription bill or plan value · local usage × public API prices" + ); } -fn print_codex_project_output( +fn render_codex_project_output( + out: &mut String, result: &CostResult, period: CostReportingPeriod, hide_personal_info: bool, + workspaces: WorkspacesIndexFor<'_>, ) { - let index = crate::codex_workspaces::CodexWorkspacesIndex::new(rolling_window_days(period)); + let index = workspaces(rolling_window_days(period)); let snapshot = index.load_snapshot(false, |_| {}).map(|mut snapshot| { if hide_personal_info { snapshot.redact_for_privacy(); @@ -585,7 +683,7 @@ fn print_codex_project_output( snapshot }); for line in render_codex_project_lines(result.summary.history_coverage_established, &snapshot) { - println!("{line}"); + outln!(out, "{line}"); } } @@ -1249,3 +1347,7 @@ mod tests { assert!(text.contains("Day boundaries: UTC")); } } + +#[cfg(test)] +#[path = "cost_golden_tests.rs"] +mod golden_tests; diff --git a/rust/src/cli/cost_golden_tests.rs b/rust/src/cli/cost_golden_tests.rs new file mode 100644 index 0000000000..88cde8aea3 --- /dev/null +++ b/rust/src/cli/cost_golden_tests.rs @@ -0,0 +1,655 @@ +//! Golden outputs for the `cost` command's provider-gated modes: project and +//! session grouping, the partial-history notice, identified-request subtotals +//! and host summaries. Every case uses temporary fixture homes and default +//! settings, so nothing reads the user's real Codex history or settings. + +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use chrono::Local; +use tempfile::TempDir; + +use super::*; +use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; + +const PERIOD: CostReportingPeriod = CostReportingPeriod::Rolling(30); + +fn result(provider: ProviderId, summary: CostSummary, supported: bool) -> CostResult { + CostResult { + provider: provider.cli_name().to_string(), + display_name: provider.display_name().to_string(), + summary, + supported, + token_history: None, + } +} + +fn codex_summary(history_coverage_established: bool) -> CostSummary { + let mut summary = CostSummary { + total_cost_usd: 1.25, + input_tokens: 12_000, + output_tokens: 3_400, + cached_tokens: 500, + sessions_count: 2, + history_coverage_established, + ..CostSummary::default() + }; + summary.by_model.insert("gpt-5".to_string(), 1.25); + summary +} + +fn claude_summary() -> CostSummary { + let mut summary = CostSummary { + total_cost_usd: 0.5, + input_tokens: 1_000, + output_tokens: 200, + sessions_count: 1, + history_coverage_established: false, + ..CostSummary::default() + }; + summary.by_model.insert("claude-sonnet-4".to_string(), 0.5); + summary +} + +fn antigravity_result() -> CostResult { + CostResult { + token_history: Some(LocalTokenHistorySummary { + total_tokens: 4_321, + session_count: 3, + coverage: LocalHistoryCoverage::Complete, + ..Default::default() + }), + ..result(ProviderId::Antigravity, CostSummary::default(), true) + } +} + +/// Fixture Codex home with one project holding two sessions. +struct CodexFixture { + tmp: TempDir, +} + +impl CodexFixture { + fn empty() -> Self { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("codex")).unwrap(); + Self { tmp } + } + + fn with_project() -> Self { + let fixture = Self::empty(); + let day = Local::now().date_naive().format("%Y-%m-%d").to_string(); + let home = fixture.home(); + let project = fixture.root().join("project"); + fs::create_dir_all(&project).unwrap(); + // Pin the SQLite home so a developer's CODEX_SQLITE_HOME cannot interfere. + fs::write( + home.join("config.toml"), + format!( + "sqlite_home = '{}'\n", + home.canonicalize().unwrap().display() + ), + ) + .unwrap(); + rusqlite::Connection::open(home.join("state_5.sqlite")) + .unwrap() + .execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT);") + .unwrap(); + write_session(&home, &day, "alpha-session-0001", &project, 20_000, 1_000); + write_session(&home, &day, "beta", &project, 1_000, 100); + fixture + } + + fn root(&self) -> &Path { + self.tmp.path() + } + + fn home(&self) -> PathBuf { + self.root().join("codex") + } + + /// Replaces the temporary root and the machine-local activity times. + fn redact(&self, text: String) -> String { + let canonical = self.root().canonicalize().unwrap(); + let mut text = text; + for root in [canonical.clone(), self.root().to_path_buf()] { + let root = root.to_string_lossy().into_owned(); + text = text.replace(&root.replace('\\', "/"), ""); + text = text.replace(&root, ""); + } + let mut lines: Vec = Vec::new(); + for line in text.split('\n') { + let after_session = lines + .last() + .is_some_and(|prev| prev.starts_with(" Session ")); + if after_session && line.starts_with(" ") { + lines.push(" ".to_string()); + } else { + lines.push(line.to_string()); + } + } + lines.join("\n") + } +} + +fn write_session(home: &Path, day: &str, id: &str, cwd: &Path, input: i32, output: i32) { + let parts: Vec<_> = day.split('-').collect(); + let folder = home + .join("sessions") + .join(parts[0]) + .join(parts[1]) + .join(parts[2]); + fs::create_dir_all(&folder).unwrap(); + let mut file = File::create(folder.join(format!("{id}.jsonl"))).unwrap(); + writeln!( + file, + r#"{{"timestamp":"{day}T10:00:00.000Z","type":"session_meta","payload":{{"session_id":"{id}","cwd":"{cwd}","originator":"codex_exec","source":"cli"}}}}"#, + cwd = cwd.to_string_lossy().replace('\\', "\\\\"), + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"{day}T10:00:01.000Z","type":"turn_context","payload":{{"model":"gpt-5"}}}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"{day}T10:00:02.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":{input},"cached_input_tokens":0,"output_tokens":{output}}}}}}}}}"# + ) + .unwrap(); +} + +/// Text report exactly as `print_text_output` would write it, with Codex +/// local history read from `fixture` instead of the real home. +fn render( + results: &[CostResult], + use_color: bool, + group_by: CostGroupBy, + hide_personal_info: bool, + fixture: &CodexFixture, +) -> String { + let home = fixture.home(); + let sidecar = fixture.root().join("sidecar.sqlite"); + let workspaces = move |days: u32| { + crate::codex_workspaces::CodexWorkspacesIndex::new(days) + .with_codex_home(&home) + .with_sidecar_path(&sidecar) + }; + let mut text = String::new(); + for (i, result) in results.iter().enumerate() { + let mut out = String::new(); + render_result_text( + &mut out, + result, + use_color, + PERIOD, + group_by, + hide_personal_info, + &workspaces, + ); + if i < results.len() - 1 { + out.push('\n'); + } + text.push_str(&out); + } + fixture.redact(text) +} + +fn plain(results: &[CostResult], group_by: CostGroupBy) -> String { + render(results, false, group_by, false, &CodexFixture::empty()) +} + +const CODEX_DEFAULT: &str = "\ +Codex Cost (Last 30 days) + Total: $1.25 + Tokens: 12,000 input, 3,400 output, 500 cached + Sessions: 2 + By model: + gpt-5: $1.25 +"; + +#[test] +fn golden_codex_default_report() { + let codex = [result(ProviderId::Codex, codex_summary(true), true)]; + assert_eq!(plain(&codex, CostGroupBy::None), CODEX_DEFAULT); +} + +#[test] +fn golden_codex_default_report_marks_partial_history() { + let codex = [result(ProviderId::Codex, codex_summary(false), true)]; + assert_eq!( + plain(&codex, CostGroupBy::None), + format!("{CODEX_DEFAULT} Coverage: partial (history catch-up in progress)\n") + ); +} + +#[test] +fn golden_codex_colored_report_marks_partial_history() { + let codex = [result(ProviderId::Codex, codex_summary(false), true)]; + assert_eq!( + render( + &codex, + true, + CostGroupBy::None, + false, + &CodexFixture::empty() + ), + "\x1b[1mCodex Cost (Last 30 days)\x1b[0m + Total: \x1b[32m$1.25\x1b[0m + Tokens: 12,000 input, 3,400 output, 500 cached + Sessions: 2 + By model: + gpt-5: $1.25 + Coverage: partial (history catch-up in progress) +" + ); +} + +#[test] +fn golden_codex_empty_history_has_no_coverage_notice() { + let summary = CostSummary { + known_zero: true, + ..CostSummary::default() + }; + let codex = [result(ProviderId::Codex, summary, true)]; + assert_eq!( + plain(&codex, CostGroupBy::None), + "Codex Cost (Last 30 days)\n No usage in the selected period (scan complete)\n" + ); +} + +#[test] +fn golden_claude_default_report_has_no_coverage_notice() { + let claude = [result(ProviderId::Claude, claude_summary(), true)]; + assert_eq!( + plain(&claude, CostGroupBy::None), + "\ +Claude Cost (Last 30 days) + Total: $0.50 + Tokens: 1,000 input, 200 output, 0 cached + Sessions: 1 + By model: + claude-sonnet-4: $0.50 +" + ); +} + +#[test] +fn golden_codex_and_claude_reports_are_separated_by_a_blank_line() { + let both = [ + result(ProviderId::Codex, codex_summary(false), true), + result(ProviderId::Claude, claude_summary(), true), + ]; + let text = plain(&both, CostGroupBy::None); + assert!(text.starts_with(&format!( + "{CODEX_DEFAULT} Coverage: partial (history catch-up in progress)\n\nClaude Cost" + ))); + assert_eq!(text.matches("Coverage: partial").count(), 1); +} + +const PROJECT_UNSUPPORTED: &str = " Project grouping is only available for Codex local projects\n"; +const SESSION_UNSUPPORTED: &str = + " Session grouping is only available for Codex local conversations\n"; + +#[test] +fn golden_project_grouping_is_unavailable_for_other_providers() { + let claude = [result(ProviderId::Claude, claude_summary(), true)]; + assert_eq!( + plain(&claude, CostGroupBy::Project), + format!("Claude Cost (Last 30 days)\n{PROJECT_UNSUPPORTED}") + ); + let cursor = [result(ProviderId::Cursor, CostSummary::default(), false)]; + assert_eq!( + plain(&cursor, CostGroupBy::Project), + format!("Cursor Cost (Last 30 days)\n{PROJECT_UNSUPPORTED}") + ); + // Token-history providers get the unavailable line, not their history. + assert_eq!( + plain(&[antigravity_result()], CostGroupBy::Project), + format!("Antigravity Token History (Last 30 days)\n{PROJECT_UNSUPPORTED}") + ); +} + +#[test] +fn golden_session_grouping_is_unavailable_for_other_providers() { + let claude = [result(ProviderId::Claude, claude_summary(), true)]; + assert_eq!( + plain(&claude, CostGroupBy::Session), + format!("Claude Cost (Last 30 days)\n{SESSION_UNSUPPORTED}") + ); + let cursor = [result(ProviderId::Cursor, CostSummary::default(), false)]; + assert_eq!( + plain(&cursor, CostGroupBy::Session), + format!("Cursor Cost (Last 30 days)\n{SESSION_UNSUPPORTED}") + ); + // Token-history providers keep printing their history for session grouping. + assert_eq!( + plain(&[antigravity_result()], CostGroupBy::Session), + "\ +Antigravity Token History (Last 30 days) + Tokens: 4,321 total + Sessions: 3 + Local token history; dollar costs unavailable +" + ); +} + +#[test] +fn golden_unsupported_provider_default_report() { + let cursor = [result(ProviderId::Cursor, CostSummary::default(), false)]; + assert_eq!( + plain(&cursor, CostGroupBy::None), + "\ +Cursor Cost (Last 30 days) + Local cost scanning not available for this provider + (Only Codex and Claude have local logs) +" + ); +} + +#[test] +fn golden_codex_project_report() { + let fixture = CodexFixture::with_project(); + let codex = [result(ProviderId::Codex, codex_summary(true), true)]; + assert_eq!( + render(&codex, false, CostGroupBy::Project, false, &fixture), + "\ +Codex Cost (Last 30 days) + Projects (last 30 days): + project: $0.04 · 22,100 tokens + /project + Not a subscription bill or plan value · local usage × public API prices +" + ); +} + +#[test] +fn golden_codex_project_report_marks_partial_history_and_hides_paths() { + let fixture = CodexFixture::with_project(); + let codex = [result(ProviderId::Codex, codex_summary(false), true)]; + assert_eq!( + render(&codex, false, CostGroupBy::Project, true, &fixture), + "\ +Codex Cost (Last 30 days) + Coverage: partial (history catch-up in progress) + Projects (last 30 days): + Workspace #1: $0.04 · 22,100 tokens + Not a subscription bill or plan value · local usage × public API prices +" + ); +} + +#[test] +fn golden_codex_project_report_for_empty_history() { + let codex = [result(ProviderId::Codex, codex_summary(false), true)]; + assert_eq!( + plain(&codex, CostGroupBy::Project), + "\ +Codex Cost (Last 30 days) + Coverage: partial (history catch-up in progress) + Projects (last 30 days): + — + Not a subscription bill or plan value · local usage × public API prices +" + ); +} + +#[test] +fn golden_codex_session_report() { + let fixture = CodexFixture::with_project(); + let codex = [result(ProviderId::Codex, codex_summary(false), true)]; + assert_eq!( + render(&codex, false, CostGroupBy::Session, false, &fixture), + "\ +Codex Cost (Last 30 days) + Conversations (last 30 days): + Session alph...ion-0001: $0.04 · 21,000 tokens · gpt-5 + + Session beta: $0.00 · 1,100 tokens · gpt-5 + + Coverage: partial (cost history catch-up in progress) + Not a subscription bill or plan value · local usage × public API prices +" + ); +} + +#[test] +fn golden_codex_session_report_for_empty_history() { + let codex = [result(ProviderId::Codex, codex_summary(true), true)]; + assert_eq!( + plain(&codex, CostGroupBy::Session), + "\ +Codex Cost (Last 30 days) + Conversations (last 30 days): + Conversation history is incomplete while local indexing catches up. + — + Not a subscription bill or plan value · local usage × public API prices +" + ); +} + +#[test] +fn golden_mixed_providers_under_project_grouping() { + let fixture = CodexFixture::with_project(); + let results = [ + result(ProviderId::Codex, codex_summary(true), true), + result(ProviderId::Claude, claude_summary(), true), + ]; + let text = render(&results, false, CostGroupBy::Project, false, &fixture); + assert!(text.contains(" project: $0.04 · 22,100 tokens\n")); + assert!(text.ends_with(&format!( + "public API prices\n\nClaude Cost (Last 30 days)\n{PROJECT_UNSUPPORTED}" + ))); +} + +/// Runs `codexbar cost ` with default settings and returns the error +/// text. Every case fails validation before any history or network read. +async fn run_error(argv: &[&str]) -> String { + let args = parse_args(argv); + match run_with_settings(args, Settings::default).await { + Ok(()) => panic!("expected `cost {}` to fail", argv.join(" ")), + Err(err) => format!("{err:#}"), + } +} + +fn parse_args(argv: &[&str]) -> CostArgs { + #[derive(clap::Parser)] + struct Wrapper { + #[command(flatten)] + args: CostArgs, + } + use clap::Parser; + Wrapper::try_parse_from(std::iter::once("cost").chain(argv.iter().copied())) + .expect("cost args parse") + .args +} + +const IDENTIFIED_ERROR: &str = "Use --identified-only --provider codex with JSON, --days 1...365 and one optional --remote host. --identified-end must be Unix milliseconds; period, grouping, breakdown and separate host summaries are unsupported."; + +#[tokio::test] +async fn golden_identified_usage_rejects_other_providers_and_modes() { + for argv in [ + &["--provider", "claude", "--identified-only", "--json"][..], + &["--provider", "all", "--identified-only", "--json"], + &["--identified-only", "--json"], + // Aliases resolve to Codex elsewhere, but the identified mode + // requires the literal provider name. + &["--provider", "openai", "--identified-only", "--json"], + &["--provider", "codex", "--identified-only"], + &[ + "--provider", + "codex", + "--json", + "--identified-end", + "1700000000000", + ], + &[ + "--provider", + "claude", + "--json", + "--identified-end", + "1700000000000", + ], + &[ + "--provider", + "codex", + "--identified-only", + "--json", + "--group-by", + "project", + ], + &[ + "--provider", + "codex", + "--identified-only", + "--json", + "--period", + "all", + ], + &[ + "--provider", + "codex", + "--identified-only", + "--json", + "--summary-only", + ], + &[ + "--provider", + "codex", + "--identified-only", + "--json", + "--days", + "0", + ], + &[ + "--provider", + "codex", + "--identified-only", + "--json", + "--days", + "366", + ], + ] { + assert_eq!(run_error(argv).await, IDENTIFIED_ERROR, "{argv:?}"); + } +} + +#[tokio::test] +async fn golden_identified_usage_validates_the_remote_host() { + let error = run_error(&[ + "--provider", + "codex", + "--identified-only", + "--json", + "--remote=-oProxyCommand=x", + ]) + .await; + assert_eq!(error, GOLDEN_INVALID_REMOTE); +} + +#[tokio::test] +async fn golden_identified_usage_checks_the_provider_name_first() { + assert_eq!( + run_error(&["--provider", "nope", "--identified-only", "--json"]).await, + "Unknown provider: 'nope'. Use --help to see available providers." + ); +} + +const HOST_PROVIDER_ERROR: &str = + "--remote and --summary-only require exactly --provider codex; they do not support all or both"; + +#[tokio::test] +async fn golden_host_summaries_reject_other_providers() { + for argv in [ + &["--provider", "claude", "--remote", "build-host"][..], + &["--provider", "all", "--summary-only", "--json"], + &["--provider", "both", "--remote", "build-host"], + &["--remote", "build-host"], + &[ + "--provider", + "claude", + "--remote", + "build-host", + "--group-by", + "project", + ], + ] { + assert_eq!(run_error(argv).await, HOST_PROVIDER_ERROR, "{argv:?}"); + } +} + +#[tokio::test] +async fn golden_host_summaries_validate_codex_arguments() { + let group_by = "--remote and --summary-only cannot be combined with --group-by"; + let cases: [(&[&str], &str); 7] = [ + ( + &[ + "--provider", + "codex", + "--remote", + "build-host", + "--group-by", + "project", + ], + group_by, + ), + // Host summaries resolve the provider id, so the alias is accepted. + ( + &[ + "--provider", + "openai", + "--remote", + "build-host", + "--group-by", + "session", + ], + group_by, + ), + ( + &[ + "--provider", + "codex", + "--remote", + "build-host", + "--summary-only", + "--json", + ], + "--remote and --summary-only cannot be combined", + ), + ( + &["--provider", "codex", "--summary-only"], + "--summary-only requires --format json or --json", + ), + ( + &[ + "--provider", + "codex", + "--summary-only", + "--json", + "--period", + "bogus", + ], + "--period must be month-to-date or all", + ), + ( + &[ + "--provider", + "codex", + "--remote", + "build-host", + "--period", + "all", + ], + "host summaries support 1...365 days; override All with --days N", + ), + ( + &["--provider", "codex", "--remote=-oProxyCommand=x"], + GOLDEN_INVALID_REMOTE, + ), + ]; + for (argv, expected) in cases { + assert_eq!(run_error(argv).await, expected, "{argv:?}"); + } +} + +const GOLDEN_INVALID_REMOTE: &str = "host must not start with '-'"; From d2a7629f5a656735de171fb3d9fa19c03d1f2a4d Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 15:54:51 +0700 Subject: [PATCH 2/2] Move Codex cost CLI modes behind a provider extension --- rust/src/cli/cost.rs | 362 ++++++-------------------- rust/src/cli/cost_golden_tests.rs | 18 +- rust/src/codex_costs.rs | 4 + rust/src/codex_costs/cost_cli.rs | 388 ++++++++++++++++++++++++++++ rust/src/core/cost_cli_extension.rs | 105 ++++++++ rust/src/core/mod.rs | 6 + rust/src/core/provider_factory.rs | 140 +++++++++- 7 files changed, 727 insertions(+), 296 deletions(-) create mode 100644 rust/src/codex_costs/cost_cli.rs create mode 100644 rust/src/core/cost_cli_extension.rs diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index a9506191eb..16f928bce8 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -8,12 +8,12 @@ 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, IdentifiedUsageArgs, - run_codex_host_costs, run_codex_identified_usage, +use crate::codex_costs::{CodexHostCostReport, CodexHostOutcome}; +use crate::core::format_count as format_number; +use crate::core::{ + CostCliExtension, CostCliInvocation, CostCliMode, CostGroupedReportContext, CostGrouping, + CostScanOptions, ProviderId, cost_cli_extension_for, cost_cli_extensions, }; -use crate::codex_workspaces::short_session_id; -use crate::core::{CostScanOptions, ProviderId}; use crate::cost_reporting_period::CostReportingPeriod; use crate::cost_scanner::{CostScanner, CostSummary}; use crate::settings::Settings; @@ -106,10 +106,6 @@ impl CostGroupBy { } } -/// Shown by the default and project Codex reports when the local cost scan -/// has not fully covered the window. -const PARTIAL_HISTORY_NOTICE: &str = " Coverage: partial (history catch-up in progress)"; - /// Run the cost command pub async fn run(args: CostArgs) -> anyhow::Result<()> { run_with_settings(args, Settings::load).await @@ -131,52 +127,41 @@ async fn run_with_settings( let group_by = CostGroupBy::from_arg(args.group_by.as_deref()); let use_color = !args.no_color && is_terminal(); - // The identified window is explicit UTC milliseconds; saved periods and - // time zones never apply, so dispatch before resolving the period. - if args.identified_only || args.identified_end.is_some() { - return run_codex_identified_usage(&IdentifiedUsageArgs { - identified_only: args.identified_only, - provider_is_codex_only: args - .provider - .as_deref() - .is_some_and(|provider| provider.trim().eq_ignore_ascii_case("codex")), - json: format == OutputFormat::Json, - days: args.days, - end_unix_ms: args.identified_end, - remote: args.remote.clone(), - period_set: args.period.is_some(), - group_by_set: args.group_by.is_some(), - summary_only: args.summary_only, - pretty: args.pretty, - }) - .await; + let selected_providers = providers.as_list(); + let invocation = CostCliInvocation { + provider_arg: args.provider.as_deref(), + providers: &selected_providers, + json: format == OutputFormat::Json, + pretty: args.pretty, + days: args.days, + period_set: args.period.is_some(), + group_by_set: args.group_by.is_some(), + remote: args.remote.as_deref(), + summary_only: args.summary_only, + identified_only: args.identified_only, + identified_end: args.identified_end, + }; + // An exclusive mode replaces the report and validates the provider + // selection itself, so it is claimed by its flags alone. + let exclusive = cost_cli_extensions() + .find_map(|extension| Some((extension, extension.exclusive_mode(&invocation)?))); + if let Some((extension, mode @ CostCliMode::BeforePeriod)) = exclusive { + return extension.run_exclusive_mode(mode, &invocation, None).await; } let settings = load_settings(); - 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, + exclusive.is_some(), )?; let days = window_days(period); - if host_summary { - return run_codex_host_costs(&CodexHostCostsArgs { - days, - remote: args.remote.clone(), - summary_only: args.summary_only, - pretty: args.pretty, - format: if format == OutputFormat::Json { - crate::codex_costs::HostOutputFormat::Json - } else { - crate::codex_costs::HostOutputFormat::Text - }, - provider_is_codex_only: providers.as_list() == vec![ProviderId::Codex], - group_by_rejected: args.group_by.is_some(), - }) - .await; + if let Some((extension, mode)) = exclusive { + return extension + .run_exclusive_mode(mode, &invocation, Some(days)) + .await; } let mut scan_options = CostScanOptions::app_driven(); @@ -372,7 +357,12 @@ macro_rules! outln { }}; } -type WorkspacesIndexFor<'a> = &'a dyn Fn(u32) -> crate::codex_workspaces::CodexWorkspacesIndex; +/// Resolves the cost extension for a result's provider CLI name. +type ExtensionFor<'a> = &'a dyn Fn(&str) -> Option<&'a dyn CostCliExtension>; + +fn registered_extension(provider: &str) -> Option<&'static dyn CostCliExtension> { + ProviderId::from_cli_name(provider).and_then(cost_cli_extension_for) +} /// Print text output fn print_text_output( @@ -391,7 +381,7 @@ fn print_text_output( period, group_by, hide_personal_info, - &crate::codex_workspaces::CodexWorkspacesIndex::new, + ®istered_extension, ); if i < results.len() - 1 { outln!(out); @@ -407,8 +397,19 @@ fn render_result_text( period: CostReportingPeriod, group_by: CostGroupBy, hide_personal_info: bool, - workspaces: WorkspacesIndexFor<'_>, + extension_for: ExtensionFor<'_>, ) { + let extension = extension_for(&result.provider); + let grouped_report = |grouping| { + extension?.grouped_report( + grouping, + &CostGroupedReportContext { + summary: &result.summary, + history_days: rolling_window_days(period), + hide_personal_info, + }, + ) + }; let label = period.label(); let title = if result.token_history.is_some() { format!("{} Token History ({label})", result.display_name) @@ -421,22 +422,26 @@ fn render_result_text( outln!(out, "{title}"); } - if group_by == CostGroupBy::Project && result.provider == "codex" { - render_codex_project_output(out, result, period, hide_personal_info, workspaces); - } else if group_by == CostGroupBy::Project { - outln!( - out, - " Project grouping is only available for Codex local projects" - ); + // Project grouping is checked before token history so token-history + // providers report it unavailable; session grouping is checked after. + if group_by == CostGroupBy::Project { + match grouped_report(CostGrouping::Project) { + Some(lines) => render_lines(out, &lines), + None => outln!( + out, + " Project grouping is only available for Codex local projects" + ), + } } else if let Some(history) = result.token_history.as_ref() { render_local_token_history(out, history); - } else if group_by == CostGroupBy::Session && result.provider == "codex" { - render_codex_session_output(out, result, period, workspaces); } else if group_by == CostGroupBy::Session { - outln!( - out, - " Session grouping is only available for Codex local conversations" - ); + match grouped_report(CostGrouping::Session) { + Some(lines) => render_lines(out, &lines), + None => outln!( + out, + " Session grouping is only available for Codex local conversations" + ), + } } else if !result.supported { outln!(out, " Local cost scanning not available for this provider"); outln!(out, " (Only Codex and Claude have local logs)"); @@ -522,13 +527,18 @@ fn render_result_text( ); } - // A16 (upstream 0.48.0): coverage status for Codex. - if result.provider == "codex" && !result.summary.history_coverage_established { - outln!(out, "{PARTIAL_HISTORY_NOTICE}"); + if let Some(notice) = extension.and_then(|ext| ext.coverage_notice(&result.summary)) { + outln!(out, "{notice}"); } } } +fn render_lines(out: &mut String, lines: &[String]) { + for line in lines { + outln!(out, "{line}"); + } +} + /// " · Incomplete" marker for totals that exclude preliminary Claude proxy /// rows (upstream 0.60.5 #3688). fn incomplete_suffix(count: u32) -> &'static str { @@ -599,139 +609,6 @@ fn render_local_token_history( } } -fn render_codex_session_output( - out: &mut String, - result: &CostResult, - period: CostReportingPeriod, - workspaces: WorkspacesIndexFor<'_>, -) { - // 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 = workspaces(rolling_window_days(period)); - let snapshot = match index.load_snapshot(false, |_| {}) { - Ok(snapshot) => snapshot, - Err(err) => { - outln!(out, " Conversation history unavailable: {err}"); - return; - } - }; - - outln!( - out, - " Conversations (last {} days):", - snapshot.history_days - ); - if snapshot.source_status.is_partial() { - outln!( - out, - " Conversation history is incomplete while local indexing catches up." - ); - } - - if snapshot.sessions.is_empty() { - outln!(out, " —"); - } else { - for session in &snapshot.sessions { - let id = short_session_id(&session.id); - let cost = if session.cost_estimate.unknown_tokens > 0 { - format!("~${:.2} partial", session.cost_estimate.known_usd) - } else { - format!("${:.2}", session.cost_estimate.known_usd) - }; - let model = session.top_model.as_deref().unwrap_or("unknown model"); - outln!( - out, - " Session {id}: {cost} · {} tokens · {model}", - format_number(session.totals.total_tokens) - ); - if let Some(activity) = session.latest_activity { - outln!( - out, - " {}", - activity - .with_timezone(&chrono::Local) - .format("%b %d, %H:%M") - ); - } - } - } - - if !result.summary.history_coverage_established { - outln!( - out, - " Coverage: partial (cost history catch-up in progress)" - ); - } - outln!( - out, - " Not a subscription bill or plan value · local usage × public API prices" - ); -} - -fn render_codex_project_output( - out: &mut String, - result: &CostResult, - period: CostReportingPeriod, - hide_personal_info: bool, - workspaces: WorkspacesIndexFor<'_>, -) { - let index = workspaces(rolling_window_days(period)); - let snapshot = index.load_snapshot(false, |_| {}).map(|mut snapshot| { - if hide_personal_info { - snapshot.redact_for_privacy(); - } - snapshot - }); - for line in render_codex_project_lines(result.summary.history_coverage_established, &snapshot) { - outln!(out, "{line}"); - } -} - -/// Project report lines (upstream #4472): incomplete history carries the same -/// partial-history notice as the default report, even with no project rows. -fn render_codex_project_lines( - cost_history_established: bool, - snapshot: &Result, -) -> Vec { - let mut lines = Vec::new(); - let catalog_partial = snapshot - .as_ref() - .is_ok_and(|snapshot| snapshot.source_status.is_partial()); - if !cost_history_established || catalog_partial { - lines.push(PARTIAL_HISTORY_NOTICE.to_string()); - } - let snapshot = match snapshot { - Ok(snapshot) => snapshot, - Err(err) => { - lines.push(format!(" Project history unavailable: {err}")); - return lines; - } - }; - lines.push(format!(" Projects (last {} days):", snapshot.history_days)); - if snapshot.projects.is_empty() { - lines.push(" —".to_string()); - } - for project in &snapshot.projects { - let cost = if project.cost_estimate.unknown_tokens > 0 { - format!("~${:.2} partial", project.cost_estimate.known_usd) - } else { - format!("${:.2}", project.cost_estimate.known_usd) - }; - lines.push(format!( - " {}: {cost} · {} tokens", - project.display_name, - format_number(project.totals.total_tokens) - )); - if let Some(path) = project.path.as_deref() { - lines.push(format!(" {path}")); - } - } - lines.push( - " Not a subscription bill or plan value · local usage × public API prices".to_string(), - ); - lines -} - /// Print JSON output fn build_json_payloads( results: &[CostResult], @@ -821,20 +698,6 @@ fn print_json_output( Ok(()) } -/// Format a number with commas -fn format_number(n: u64) -> String { - let s = n.to_string(); - let mut result = String::new(); - let chars: Vec = s.chars().collect(); - for (i, c) in chars.iter().enumerate() { - if i > 0 && (chars.len() - i).is_multiple_of(3) { - result.push(','); - } - result.push(*c); - } - result -} - /// Check if stdout is a terminal fn is_terminal() -> bool { use std::io::IsTerminal; @@ -1219,83 +1082,6 @@ mod tests { assert!(!parse_args(&[]).identified_only); } - fn project_snapshot( - projects: Vec, - ) -> crate::codex_workspaces::CodexLocalProjectUsageSnapshot { - crate::codex_workspaces::CodexLocalProjectUsageSnapshot { - updated_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(), - history_days: 30, - scope_signature: String::new(), - indexed_file_count: 0, - skipped_file_count: 0, - total: Default::default(), - sessions: Vec::new(), - projects, - daily: Vec::new(), - source_status: crate::codex_workspaces::SourceStatus::Complete, - } - } - - #[test] - fn project_report_marks_partial_history_even_when_empty() { - let empty: Result<_, String> = Ok(project_snapshot(Vec::new())); - let partial = render_codex_project_lines(false, &empty); - assert_eq!(partial[0], PARTIAL_HISTORY_NOTICE); - assert!(partial.contains(&" Projects (last 30 days):".to_string())); - assert!(partial.contains(&" —".to_string())); - - let complete = render_codex_project_lines(true, &empty); - assert!(!complete.iter().any(|line| line == PARTIAL_HISTORY_NOTICE)); - assert!(complete.contains(&" —".to_string())); - - let unavailable: Result = - Err("locked".to_string()); - let lines = render_codex_project_lines(false, &unavailable); - assert_eq!(lines[0], PARTIAL_HISTORY_NOTICE); - assert_eq!(lines[1], " Project history unavailable: locked"); - - let mut locked_catalog = project_snapshot(Vec::new()); - locked_catalog.source_status = crate::codex_workspaces::SourceStatus::CatalogLocked; - let lines = render_codex_project_lines(true, &Ok::<_, String>(locked_catalog)); - assert_eq!(lines[0], PARTIAL_HISTORY_NOTICE); - assert_eq!( - lines - .iter() - .filter(|line| *line == PARTIAL_HISTORY_NOTICE) - .count(), - 1 - ); - } - - #[test] - fn project_report_lists_projects_with_partial_costs() { - use crate::codex_workspaces::{CostEstimate, ProjectUsage, UsageTotals}; - let project = |name: &str, path: Option<&str>, unknown_tokens: u64| ProjectUsage { - id: name.to_string(), - display_name: name.to_string(), - path: path.map(str::to_string), - totals: UsageTotals::from_parts(1_000, 0, 234), - cost_estimate: CostEstimate { - known_usd: 1.5, - unknown_tokens, - }, - session_count: 1, - latest_activity: None, - top_model: None, - top_sessions: Vec::new(), - is_projectless: false, - }; - let snapshot: Result<_, String> = Ok(project_snapshot(vec![ - project("alpha", Some("C:\\src\\alpha"), 0), - project("beta", None, 10), - ])); - let lines = render_codex_project_lines(true, &snapshot); - assert!(lines.contains(&" alpha: $1.50 · 1,234 tokens".to_string())); - assert!(lines.contains(&" C:\\src\\alpha".to_string())); - assert!(lines.contains(&" beta: ~$1.50 partial · 1,234 tokens".to_string())); - assert!(!lines.iter().any(|line| line == " —")); - } - #[test] fn remote_failure_retains_local_report() { let local_summary = CodexCostSummary::from_summaries_at( diff --git a/rust/src/cli/cost_golden_tests.rs b/rust/src/cli/cost_golden_tests.rs index 88cde8aea3..bc17187294 100644 --- a/rust/src/cli/cost_golden_tests.rs +++ b/rust/src/cli/cost_golden_tests.rs @@ -168,12 +168,16 @@ fn render( hide_personal_info: bool, fixture: &CodexFixture, ) -> String { - let home = fixture.home(); - let sidecar = fixture.root().join("sidecar.sqlite"); - let workspaces = move |days: u32| { - crate::codex_workspaces::CodexWorkspacesIndex::new(days) - .with_codex_home(&home) - .with_sidecar_path(&sidecar) + let codex = crate::codex_costs::CodexCostCli::with_paths( + fixture.home(), + fixture.root().join("sidecar.sqlite"), + ); + let extension_for = |provider: &str| -> Option<&dyn CostCliExtension> { + if provider == "codex" { + Some(&codex) + } else { + registered_extension(provider) + } }; let mut text = String::new(); for (i, result) in results.iter().enumerate() { @@ -185,7 +189,7 @@ fn render( PERIOD, group_by, hide_personal_info, - &workspaces, + &extension_for, ); if i < results.len() - 1 { out.push('\n'); diff --git a/rust/src/codex_costs.rs b/rust/src/codex_costs.rs index 544db175c6..3b2eb8e195 100644 --- a/rust/src/codex_costs.rs +++ b/rust/src/codex_costs.rs @@ -3,11 +3,15 @@ //! The SSH wire contract lives in [`summary_contract`]; this module owns the //! local-scan aggregation and pricing logic. +mod cost_cli; mod host_costs; pub(crate) mod identified_requests; mod quota_windows; mod summary_contract; +pub(crate) use cost_cli::CODEX_COST_CLI; +#[cfg(test)] +pub(crate) use cost_cli::CodexCostCli; pub(crate) use host_costs::{CodexHostCostsArgs, HostOutputFormat, run_codex_host_costs}; pub(crate) use identified_requests::{ IdentifiedUsageArgs, MAX_IDENTIFIED_OUTPUT_BYTES, run_codex_identified_usage, diff --git a/rust/src/codex_costs/cost_cli.rs b/rust/src/codex_costs/cost_cli.rs new file mode 100644 index 0000000000..ceee712c06 --- /dev/null +++ b/rust/src/codex_costs/cost_cli.rs @@ -0,0 +1,388 @@ +//! Codex's `codexbar cost` extension: project and conversation reports, +//! the partial-history notice, identified-request subtotals and host +//! summaries. + +use std::path::PathBuf; + +use futures::future::LocalBoxFuture; + +use crate::codex_costs::{ + CodexHostCostsArgs, HostOutputFormat, IdentifiedUsageArgs, run_codex_host_costs, + run_codex_identified_usage, +}; +use crate::codex_workspaces::{ + CodexLocalProjectUsageSnapshot, CodexWorkspacesIndex, short_session_id, +}; +use crate::core::{ + CostCliExtension, CostCliInvocation, CostCliMode, CostGroupedReportContext, CostGrouping, + ProviderId, format_count, +}; +use crate::cost_scanner::CostSummary; + +/// Shown by the default and project reports when the local cost scan has not +/// fully covered the window. +pub(crate) const PARTIAL_HISTORY_NOTICE: &str = + " Coverage: partial (history catch-up in progress)"; + +pub(crate) static CODEX_COST_CLI: CodexCostCli = CodexCostCli { + codex_home: None, + sidecar_path: None, +}; + +/// `None` paths use the user's Codex home and workspace sidecar. +pub(crate) struct CodexCostCli { + codex_home: Option, + sidecar_path: Option, +} + +impl CodexCostCli { + #[cfg(test)] + pub(crate) fn with_paths(codex_home: PathBuf, sidecar_path: PathBuf) -> Self { + Self { + codex_home: Some(codex_home), + sidecar_path: Some(sidecar_path), + } + } + + fn workspaces_index(&self, history_days: u32) -> CodexWorkspacesIndex { + let mut index = CodexWorkspacesIndex::new(history_days); + if let Some(home) = &self.codex_home { + index = index.with_codex_home(home); + } + if let Some(path) = &self.sidecar_path { + index = index.with_sidecar_path(path); + } + index + } +} + +impl CostCliExtension for CodexCostCli { + fn exclusive_mode(&self, invocation: &CostCliInvocation<'_>) -> Option { + // The identified window is explicit UTC milliseconds; saved periods + // and time zones never apply. + if invocation.identified_only || invocation.identified_end.is_some() { + Some(CostCliMode::BeforePeriod) + } else if invocation.remote.is_some() || invocation.summary_only { + Some(CostCliMode::BoundedWindow) + } else { + None + } + } + + fn run_exclusive_mode<'a>( + &'a self, + mode: CostCliMode, + invocation: &'a CostCliInvocation<'a>, + window_days: Option, + ) -> LocalBoxFuture<'a, anyhow::Result<()>> { + Box::pin(async move { + match (mode, window_days) { + (CostCliMode::BeforePeriod, _) => { + run_codex_identified_usage(&identified_usage_args(invocation)).await + } + (CostCliMode::BoundedWindow, Some(days)) => { + run_codex_host_costs(&host_costs_args(invocation, days)).await + } + (CostCliMode::BoundedWindow, None) => { + anyhow::bail!("host cost reports need a resolved day window") + } + } + }) + } + + fn grouped_report( + &self, + grouping: CostGrouping, + context: &CostGroupedReportContext<'_>, + ) -> Option> { + let index = self.workspaces_index(context.history_days); + let lines = match grouping { + CostGrouping::Project => { + let snapshot = index.load_snapshot(false, |_| {}).map(|mut snapshot| { + if context.hide_personal_info { + snapshot.redact_for_privacy(); + } + snapshot + }); + render_project_lines(context.summary.history_coverage_established, &snapshot) + } + CostGrouping::Session => render_session_lines(context.summary, &index), + }; + Some(lines) + } + + fn coverage_notice(&self, summary: &CostSummary) -> Option<&'static str> { + // A16 (upstream 0.48.0): coverage status for Codex. + (!summary.history_coverage_established).then_some(PARTIAL_HISTORY_NOTICE) + } +} + +fn identified_usage_args(invocation: &CostCliInvocation<'_>) -> IdentifiedUsageArgs { + IdentifiedUsageArgs { + identified_only: invocation.identified_only, + // The literal name, not an alias such as `openai`. + provider_is_codex_only: invocation + .provider_arg + .is_some_and(|provider| provider.trim().eq_ignore_ascii_case("codex")), + json: invocation.json, + days: invocation.days, + end_unix_ms: invocation.identified_end, + remote: invocation.remote.map(str::to_string), + period_set: invocation.period_set, + group_by_set: invocation.group_by_set, + summary_only: invocation.summary_only, + pretty: invocation.pretty, + } +} + +fn host_costs_args(invocation: &CostCliInvocation<'_>, days: u32) -> CodexHostCostsArgs { + CodexHostCostsArgs { + days, + remote: invocation.remote.map(str::to_string), + summary_only: invocation.summary_only, + pretty: invocation.pretty, + format: if invocation.json { + HostOutputFormat::Json + } else { + HostOutputFormat::Text + }, + provider_is_codex_only: invocation.providers == [ProviderId::Codex], + group_by_rejected: invocation.group_by_set, + } +} + +fn render_session_lines(summary: &CostSummary, index: &CodexWorkspacesIndex) -> Vec { + let mut lines = Vec::new(); + let snapshot = match index.load_snapshot(false, |_| {}) { + Ok(snapshot) => snapshot, + Err(err) => { + lines.push(format!(" Conversation history unavailable: {err}")); + return lines; + } + }; + + // The conversation index keeps rolling 1..=365 day windows, so All lists + // the most recent year; the heading reports the window it used. + lines.push(format!( + " Conversations (last {} days):", + snapshot.history_days + )); + if snapshot.source_status.is_partial() { + lines.push( + " Conversation history is incomplete while local indexing catches up.".to_string(), + ); + } + + if snapshot.sessions.is_empty() { + lines.push(" —".to_string()); + } else { + for session in &snapshot.sessions { + let id = short_session_id(&session.id); + let cost = if session.cost_estimate.unknown_tokens > 0 { + format!("~${:.2} partial", session.cost_estimate.known_usd) + } else { + format!("${:.2}", session.cost_estimate.known_usd) + }; + let model = session.top_model.as_deref().unwrap_or("unknown model"); + lines.push(format!( + " Session {id}: {cost} · {} tokens · {model}", + format_count(session.totals.total_tokens) + )); + if let Some(activity) = session.latest_activity { + lines.push(format!( + " {}", + activity + .with_timezone(&chrono::Local) + .format("%b %d, %H:%M") + )); + } + } + } + + if !summary.history_coverage_established { + lines.push(" Coverage: partial (cost history catch-up in progress)".to_string()); + } + lines.push( + " Not a subscription bill or plan value · local usage × public API prices".to_string(), + ); + lines +} + +/// Project report lines (upstream #4472): incomplete history carries the same +/// partial-history notice as the default report, even with no project rows. +fn render_project_lines( + cost_history_established: bool, + snapshot: &Result, +) -> Vec { + let mut lines = Vec::new(); + let catalog_partial = snapshot + .as_ref() + .is_ok_and(|snapshot| snapshot.source_status.is_partial()); + if !cost_history_established || catalog_partial { + lines.push(PARTIAL_HISTORY_NOTICE.to_string()); + } + let snapshot = match snapshot { + Ok(snapshot) => snapshot, + Err(err) => { + lines.push(format!(" Project history unavailable: {err}")); + return lines; + } + }; + lines.push(format!(" Projects (last {} days):", snapshot.history_days)); + if snapshot.projects.is_empty() { + lines.push(" —".to_string()); + } + for project in &snapshot.projects { + let cost = if project.cost_estimate.unknown_tokens > 0 { + format!("~${:.2} partial", project.cost_estimate.known_usd) + } else { + format!("${:.2}", project.cost_estimate.known_usd) + }; + lines.push(format!( + " {}: {cost} · {} tokens", + project.display_name, + format_count(project.totals.total_tokens) + )); + if let Some(path) = project.path.as_deref() { + lines.push(format!(" {path}")); + } + } + lines.push( + " Not a subscription bill or plan value · local usage × public API prices".to_string(), + ); + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + fn project_snapshot( + projects: Vec, + ) -> CodexLocalProjectUsageSnapshot { + CodexLocalProjectUsageSnapshot { + updated_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + history_days: 30, + scope_signature: String::new(), + indexed_file_count: 0, + skipped_file_count: 0, + total: Default::default(), + sessions: Vec::new(), + projects, + daily: Vec::new(), + source_status: crate::codex_workspaces::SourceStatus::Complete, + } + } + + #[test] + fn project_report_marks_partial_history_even_when_empty() { + let empty: Result<_, String> = Ok(project_snapshot(Vec::new())); + let partial = render_project_lines(false, &empty); + assert_eq!(partial[0], PARTIAL_HISTORY_NOTICE); + assert!(partial.contains(&" Projects (last 30 days):".to_string())); + assert!(partial.contains(&" —".to_string())); + + let complete = render_project_lines(true, &empty); + assert!(!complete.iter().any(|line| line == PARTIAL_HISTORY_NOTICE)); + assert!(complete.contains(&" —".to_string())); + + let unavailable: Result = Err("locked".to_string()); + let lines = render_project_lines(false, &unavailable); + assert_eq!(lines[0], PARTIAL_HISTORY_NOTICE); + assert_eq!(lines[1], " Project history unavailable: locked"); + + let mut locked_catalog = project_snapshot(Vec::new()); + locked_catalog.source_status = crate::codex_workspaces::SourceStatus::CatalogLocked; + let lines = render_project_lines(true, &Ok::<_, String>(locked_catalog)); + assert_eq!(lines[0], PARTIAL_HISTORY_NOTICE); + assert_eq!( + lines + .iter() + .filter(|line| *line == PARTIAL_HISTORY_NOTICE) + .count(), + 1 + ); + } + + #[test] + fn project_report_lists_projects_with_partial_costs() { + use crate::codex_workspaces::{CostEstimate, ProjectUsage, UsageTotals}; + let project = |name: &str, path: Option<&str>, unknown_tokens: u64| ProjectUsage { + id: name.to_string(), + display_name: name.to_string(), + path: path.map(str::to_string), + totals: UsageTotals::from_parts(1_000, 0, 234), + cost_estimate: CostEstimate { + known_usd: 1.5, + unknown_tokens, + }, + session_count: 1, + latest_activity: None, + top_model: None, + top_sessions: Vec::new(), + is_projectless: false, + }; + let snapshot: Result<_, String> = Ok(project_snapshot(vec![ + project("alpha", Some("C:\\src\\alpha"), 0), + project("beta", None, 10), + ])); + let lines = render_project_lines(true, &snapshot); + assert!(lines.contains(&" alpha: $1.50 · 1,234 tokens".to_string())); + assert!(lines.contains(&" C:\\src\\alpha".to_string())); + assert!(lines.contains(&" beta: ~$1.50 partial · 1,234 tokens".to_string())); + assert!(!lines.iter().any(|line| line == " —")); + } + + fn invocation<'a>(providers: &'a [ProviderId]) -> CostCliInvocation<'a> { + CostCliInvocation { + provider_arg: Some("codex"), + providers, + json: true, + pretty: false, + days: None, + period_set: false, + group_by_set: false, + remote: None, + summary_only: false, + identified_only: false, + identified_end: None, + } + } + + #[test] + fn exclusive_modes_follow_the_mode_flags() { + let codex = [ProviderId::Codex]; + let plain = invocation(&codex); + assert_eq!(CODEX_COST_CLI.exclusive_mode(&plain), None); + let identified = CostCliInvocation { + identified_end: Some(1), + remote: Some("build-host"), + ..invocation(&codex) + }; + assert_eq!( + CODEX_COST_CLI.exclusive_mode(&identified), + Some(CostCliMode::BeforePeriod) + ); + let summary = CostCliInvocation { + summary_only: true, + ..invocation(&codex) + }; + assert_eq!( + CODEX_COST_CLI.exclusive_mode(&summary), + Some(CostCliMode::BoundedWindow) + ); + } + + #[test] + fn coverage_notice_marks_only_uncovered_history() { + let covered = CostSummary { + history_coverage_established: true, + ..CostSummary::default() + }; + assert_eq!(CODEX_COST_CLI.coverage_notice(&covered), None); + assert_eq!( + CODEX_COST_CLI.coverage_notice(&CostSummary::default()), + Some(PARTIAL_HISTORY_NOTICE) + ); + } +} diff --git a/rust/src/core/cost_cli_extension.rs b/rust/src/core/cost_cli_extension.rs new file mode 100644 index 0000000000..24e52c423d --- /dev/null +++ b/rust/src/core/cost_cli_extension.rs @@ -0,0 +1,105 @@ +//! Provider-owned extensions to the `codexbar cost` command. +//! +//! The shared command renders the common per-provider report. A provider that +//! offers more (grouped reports, a coverage notice, or an exclusive mode that +//! replaces the report) implements [`CostCliExtension`] in its own module and +//! registers it in [`super::cost_cli_extension_for`]. Providers without an +//! extension get the shared "not available" output. + +use futures::future::LocalBoxFuture; + +use super::ProviderId; +use crate::cost_scanner::CostSummary; + +/// A grouped text report requested with `--group-by`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CostGrouping { + Project, + Session, +} + +/// When an exclusive mode runs relative to reporting-period resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CostCliMode { + /// Runs before the reporting period is resolved; saved settings and the + /// saved cost period never apply. + BeforePeriod, + /// Runs on the resolved period's day window. All needs an explicit + /// `--days`, because the window must be 1...365 days. + BoundedWindow, +} + +/// Provider-neutral view of the `cost` arguments an exclusive mode inspects. +pub(crate) struct CostCliInvocation<'a> { + /// The raw `--provider` value. + pub(crate) provider_arg: Option<&'a str>, + /// The resolved provider selection. + pub(crate) providers: &'a [ProviderId], + pub(crate) json: bool, + pub(crate) pretty: bool, + pub(crate) days: Option, + pub(crate) period_set: bool, + pub(crate) group_by_set: bool, + pub(crate) remote: Option<&'a str>, + pub(crate) summary_only: bool, + pub(crate) identified_only: bool, + pub(crate) identified_end: Option, +} + +/// Inputs for one provider's grouped text report. +pub(crate) struct CostGroupedReportContext<'a> { + pub(crate) summary: &'a CostSummary, + /// Rolling history window in days (1...365). + pub(crate) history_days: u32, + pub(crate) hide_personal_info: bool, +} + +pub(crate) trait CostCliExtension: Sync { + /// The exclusive mode these arguments select, if this extension owns one. + /// An exclusive mode replaces the whole report and validates the provider + /// selection itself, so its errors stay owned by the provider. + fn exclusive_mode(&self, _invocation: &CostCliInvocation<'_>) -> Option { + None + } + + /// Runs a mode returned by [`Self::exclusive_mode`]. `window_days` is the + /// resolved day window for [`CostCliMode::BoundedWindow`], `None` otherwise. + fn run_exclusive_mode<'a>( + &'a self, + _mode: CostCliMode, + _invocation: &'a CostCliInvocation<'a>, + _window_days: Option, + ) -> LocalBoxFuture<'a, anyhow::Result<()>> { + Box::pin(async { anyhow::bail!("this cost mode is not supported") }) + } + + /// Text lines for a grouped report, or `None` when this provider does not + /// support the grouping. + fn grouped_report( + &self, + _grouping: CostGrouping, + _context: &CostGroupedReportContext<'_>, + ) -> Option> { + None + } + + /// A line appended to the default text report, such as a notice that the + /// local history scan has not yet covered the window. + fn coverage_notice(&self, _summary: &CostSummary) -> Option<&'static str> { + None + } +} + +/// Formats a count with comma thousands separators. +pub(crate) fn format_count(n: u64) -> String { + let s = n.to_string(); + let mut result = String::new(); + let chars: Vec = s.chars().collect(); + for (i, c) in chars.iter().enumerate() { + if i > 0 && (chars.len() - i).is_multiple_of(3) { + result.push(','); + } + result.push(*c); + } + result +} diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 1c10ad2a6c..cff208b00e 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -7,6 +7,7 @@ mod claude_routed_pricing; pub(crate) mod codex_included_pricing; mod codex_routed_pricing; mod cost_cache_budget; +mod cost_cli_extension; mod cost_pricing; pub mod curl_capture; mod display_detail; @@ -42,6 +43,10 @@ pub use adaptive_refresh::*; pub use aws_signing::*; pub use blocking_quota::*; pub use cost_cache_budget::*; +pub(crate) use cost_cli_extension::{ + CostCliExtension, CostCliInvocation, CostCliMode, CostGroupedReportContext, CostGrouping, + format_count, +}; pub use cost_pricing::*; pub use curl_capture::*; pub use display_detail::*; @@ -59,6 +64,7 @@ pub use openai_dashboard::*; pub use pace_capability::*; pub use provider::*; pub use provider_factory::instantiate as instantiate_provider; +pub(crate) use provider_factory::{cost_cli_extension_for, cost_cli_extensions}; pub use provider_state::*; pub use quota_burndown::{ MAX_SERIES_SAMPLES, PersistedPlanEntry, PersistedPlanSeries, QuotaBurndownModel, diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 29a3538aed..ff2f077a0e 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -5,7 +5,9 @@ //! only requires editing `ProviderId`, the matching `providers/` module, and //! this one match arm. -use super::{Provider, ProviderId, ProviderLimitResetPolicy, ProviderPaceCapability}; +use super::{ + CostCliExtension, Provider, ProviderId, ProviderLimitResetPolicy, ProviderPaceCapability, +}; use crate::providers::AerostackProvider; use crate::providers::AtlasCloudProvider; use crate::providers::ClawRouterProvider; @@ -395,6 +397,130 @@ pub fn limit_reset_policy(id: ProviderId) -> ProviderLimitResetPolicy { | ProviderId::Flora => ProviderLimitResetPolicy::DEFAULT, } } + +/// `codexbar cost` extension declared by a provider's module. Exhaustive over +/// [`ProviderId`]: a new variant is a compile error until it is listed here, +/// and providers without extra cost modes map to `None`. +pub(crate) fn cost_cli_extension_for(id: ProviderId) -> Option<&'static dyn CostCliExtension> { + match id { + ProviderId::Codex => Some(&crate::codex_costs::CODEX_COST_CLI), + ProviderId::Claude + | ProviderId::TinyApi + | ProviderId::Xapi + | ProviderId::Langdock + | ProviderId::Exa + | ProviderId::Copilot + | ProviderId::Cursor + | ProviderId::Grok + | ProviderId::Kimi + | ProviderId::Zai + | ProviderId::Amp + | ProviderId::Alibaba + | ProviderId::AlibabaTokenPlan + | ProviderId::CommandCode + | ProviderId::Doubao + | ProviderId::MiMo + | ProviderId::StepFun + | ProviderId::Notion + | ProviderId::Ollama + | ProviderId::OpenCodeGo + | ProviderId::Pi + | ProviderId::Gemini + | ProviderId::Antigravity + | ProviderId::AtlasCloud + | ProviderId::Synthetic + | ProviderId::ClawRouter + | ProviderId::IBMBob + | ProviderId::Linkup + | ProviderId::LithosAI + | ProviderId::MuseAI + | ProviderId::WorkBuddy + | ProviderId::Tavily + | ProviderId::Factory + | ProviderId::Kiro + | ProviderId::VertexAI + | ProviderId::Augment + | ProviderId::MiniMax + | ProviderId::OpenCode + | ProviderId::KimiK2 + | ProviderId::Warp + | ProviderId::AzureOpenAI + | ProviderId::T3Chat + | ProviderId::OpenRouter + | ProviderId::JetBrains + | ProviderId::NanoGPT + | ProviderId::Infini + | ProviderId::Perplexity + | ProviderId::Abacus + | ProviderId::Mistral + | ProviderId::Kilo + | ProviderId::Bedrock + | ProviderId::Codebuff + | ProviderId::CodeRabbit + | ProviderId::DeepSeek + | ProviderId::DeepInfra + | ProviderId::AiAnd + | ProviderId::Windsurf + | ProviderId::Manus + | ProviderId::Venice + | ProviderId::OpenAIApi + | ProviderId::ElevenLabs + | ProviderId::Deepgram + | ProviderId::Groq + | ProviderId::HuggingFace + | ProviderId::Helmcode + | ProviderId::V0 + | ProviderId::TypeSafe + | ProviderId::LLMProxy + | ProviderId::Chutes + | ProviderId::LiteLLM + | ProviderId::Poe + | ProviderId::Devin + | ProviderId::Zed + | ProviderId::CrossModel + | ProviderId::Qoder + | ProviderId::CodeBuddy + | ProviderId::Sakana + | ProviderId::Sofya + | ProviderId::Sub2Api + | ProviderId::Wayfinder + | ProviderId::ZenMux + | ProviderId::ClinePass + | ProviderId::LongCat + | ProviderId::Neuralwatt + | ProviderId::ZoomMate + | ProviderId::QwenCloud + | ProviderId::Replicate + | ProviderId::SailResearch + | ProviderId::Xai + | ProviderId::Fireworks + | ProviderId::Meta + | ProviderId::Muse + | ProviderId::Nous + | ProviderId::Hyper + | ProviderId::GitKraken + | ProviderId::Bifrost + | ProviderId::Aixy + | ProviderId::LLMMan + | ProviderId::DevPass + | ProviderId::XKiro + | ProviderId::Raycast + | ProviderId::Vercel + | ProviderId::Cosmic + | ProviderId::Aerostack + | ProviderId::Parallel + | ProviderId::Fal + | ProviderId::Flora => None, + } +} + +/// Every registered `codexbar cost` extension, in [`ProviderId::all`] order. +pub(crate) fn cost_cli_extensions() -> impl Iterator { + ProviderId::all() + .iter() + .filter_map(|&id| cost_cli_extension_for(id)) +} + #[cfg(test)] mod tests { use super::*; @@ -466,4 +592,16 @@ mod tests { } ); } + + #[test] + fn only_codex_registers_a_cost_cli_extension() { + for &id in ProviderId::all() { + assert_eq!( + cost_cli_extension_for(id).is_some(), + id == ProviderId::Codex, + "{id}" + ); + } + assert_eq!(cost_cli_extensions().count(), 1); + } }