From 70ff7f928a8cd39f3d06e1b2706ee97e8b05efa2 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:36:56 +0700 Subject: [PATCH] feat: port upstream 0.51 cost contracts --- rust/src/cli/cost.rs | 105 ++++++++++++++++++++++++- rust/src/codex_workspaces/indexer.rs | 20 +++++ rust/src/codex_workspaces/types.rs | 7 ++ rust/src/providers/opencodego/local.rs | 5 +- 4 files changed, 133 insertions(+), 4 deletions(-) diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 706cc5ab32..2c42ed1b7a 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -45,6 +45,25 @@ pub struct CostArgs { /// observable effect in the current environment. #[arg(long = "provider-native-only")] pub provider_native_only: bool, + + /// Group text output by Codex local conversation/session. + #[arg(long = "group-by", value_parser = ["session"])] + pub group_by: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CostGroupBy { + None, + Session, +} + +impl CostGroupBy { + fn from_arg(raw: Option<&str>) -> Self { + match raw { + Some("session") => Self::Session, + _ => Self::None, + } + } } /// Run the cost command @@ -56,6 +75,7 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { }; let providers = ProviderSelection::from_arg(args.provider.as_deref())?; + let group_by = CostGroupBy::from_arg(args.group_by.as_deref()); let use_color = !args.no_color && is_terminal(); let mut scan_options = CostScanOptions::app_driven(); scan_options.include_pi_sessions = !args.provider_native_only; @@ -105,7 +125,7 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { match format { OutputFormat::Text => { - print_text_output(&results, use_color, args.days); + print_text_output(&results, use_color, args.days, group_by); } OutputFormat::Json => { print_json_output(&results, args.pretty, args.days)?; @@ -124,7 +144,7 @@ struct CostResult { } /// Print text output -fn print_text_output(results: &[CostResult], use_color: bool, days: u32) { +fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_by: CostGroupBy) { for (i, result) in results.iter().enumerate() { if use_color { println!( @@ -135,7 +155,11 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32) { println!("{} Cost (last {} days)", result.display_name, days); } - if !result.supported { + if group_by == CostGroupBy::Session && result.provider == "codex" { + print_codex_session_output(result, days); + } 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 { @@ -215,6 +239,69 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32) { } } + +fn print_codex_session_output(result: &CostResult, days: u32) { + let index = crate::codex_workspaces::CodexWorkspacesIndex::new(days); + let snapshot = match index.load_snapshot(false, |_| {}) { + Ok(snapshot) => snapshot, + Err(err) => { + println!(" Conversation history unavailable: {err}"); + return; + } + }; + + println!(" Conversations (last {} days):", snapshot.history_days); + if snapshot.source_status.is_partial() { + println!(" Conversation history is incomplete while local indexing catches up."); + } + + if snapshot.sessions.is_empty() { + println!(" —"); + } 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"); + println!( + " Session {id}: {cost} · {} tokens · {model}", + format_number(session.totals.total_tokens) + ); + if let Some(activity) = session.latest_activity { + println!( + " {}", + activity.with_timezone(&chrono::Local).format("%b %d, %H:%M") + ); + } + } + } + + if !result.summary.history_coverage_established { + println!(" Coverage: partial (cost history catch-up in progress)"); + } + println!(" Not a subscription bill or plan value · local usage × public API prices"); +} + +fn short_session_id(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.chars().count() <= 12 { + return trimmed.to_string(); + } + let prefix: String = trimmed.chars().take(4).collect(); + let suffix: String = trimmed + .chars() + .rev() + .take(8) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{prefix}...{suffix}") +} + /// Print JSON output fn print_json_output(results: &[CostResult], pretty: bool, days: u32) -> anyhow::Result<()> { let payloads: Vec = results @@ -400,4 +487,16 @@ mod tests { let args = CostArgs::default(); assert!(!args.provider_native_only); } + + #[test] + fn group_by_defaults_none_and_accepts_session() { + assert_eq!(CostGroupBy::from_arg(None), CostGroupBy::None); + assert_eq!(CostGroupBy::from_arg(Some("session")), CostGroupBy::Session); + } + + #[test] + fn short_session_id_is_privacy_conscious() { + assert_eq!(short_session_id("abc"), "abc"); + assert_eq!(short_session_id("1234567890abcdef"), "1234...90abcdef"); + } } diff --git a/rust/src/codex_workspaces/indexer.rs b/rust/src/codex_workspaces/indexer.rs index fe2d1baea4..ebbbb74688 100644 --- a/rust/src/codex_workspaces/indexer.rs +++ b/rust/src/codex_workspaces/indexer.rs @@ -230,6 +230,14 @@ impl CodexWorkspacesIndex { .collect(); daily.sort_by(|a, b| a.day.cmp(&b.day)); + let mut sessions: Vec = + session_buckets.values().map(SessionBucket::to_session_usage).collect(); + sessions.sort_by(|a, b| { + b.latest_activity + .cmp(&a.latest_activity) + .then_with(|| a.id.cmp(&b.id)) + }); + let snapshot = CodexLocalProjectUsageSnapshot { updated_at: Utc::now(), history_days: self.history_days, @@ -237,6 +245,7 @@ impl CodexWorkspacesIndex { indexed_file_count: indexed, skipped_file_count: skipped, total, + sessions, projects, daily, source_status, @@ -851,6 +860,17 @@ mod tests { indexed_file_count: 1, skipped_file_count: 0, total: UsageTotals::from_parts(10, 0, 5), + sessions: vec![SessionUsage { + id: "s1".into(), + project_id: "project-abc".into(), + display_title: "do not leak".into(), + cwd: Some("/Users/me/secret-repo".into()), + started_at: None, + latest_activity: None, + totals: UsageTotals::from_parts(10, 0, 5), + cost_estimate: CostEstimate::default(), + top_model: Some("gpt-5".into()), + }], projects: vec![ProjectUsage { id: "project-abc".into(), display_name: "secret-repo".into(), diff --git a/rust/src/codex_workspaces/types.rs b/rust/src/codex_workspaces/types.rs index f2944d7133..e96af15da3 100644 --- a/rust/src/codex_workspaces/types.rs +++ b/rust/src/codex_workspaces/types.rs @@ -157,6 +157,9 @@ pub struct CodexLocalProjectUsageSnapshot { pub indexed_file_count: u32, pub skipped_file_count: u32, pub total: UsageTotals, + /// All indexed conversations in the selected history window. + #[serde(default)] + pub sessions: Vec, pub projects: Vec, pub daily: Vec, pub source_status: SourceStatus, @@ -166,6 +169,10 @@ impl CodexLocalProjectUsageSnapshot { /// Strip paths/titles for presentation when hide-personal-info is on. /// Does not rewrite the sidecar. pub fn redact_for_privacy(&mut self) { + for session in &mut self.sessions { + session.display_title = "Local Codex chat".to_string(); + session.cwd = None; + } for project in &mut self.projects { if project.id == crate::codex_workspaces::CHATS_PROJECT_ID { project.display_name = crate::codex_workspaces::CHATS_DISPLAY_NAME.to_string(); diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index 68bc3f5b69..294bbf1d3b 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -111,7 +111,10 @@ impl LocalUsageSnapshot { Some(monthly_reset), None, )); - ProviderFetchResult::new(snap, "local") + // Upstream 0.51 (#2982): local SQLite quota reconstruction is useful + // but it is not server-confirmed authority. Keep that distinction in + // the data contract so CLI/React can present it without guessing. + ProviderFetchResult::new(snap, "local estimate") } }