Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 102 additions & 3 deletions rust/src/cli/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[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
Expand All @@ -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;
Expand Down Expand Up @@ -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)?;
Expand All @@ -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!(
Expand All @@ -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 {
Expand Down Expand Up @@ -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::<Vec<_>>()
.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<serde_json::Value> = results
Expand Down Expand Up @@ -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");
}
}
20 changes: 20 additions & 0 deletions rust/src/codex_workspaces/indexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,22 @@ impl CodexWorkspacesIndex {
.collect();
daily.sort_by(|a, b| a.day.cmp(&b.day));

let mut sessions: Vec<SessionUsage> =
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,
scope_signature: scope.scope_signature().to_string(),
indexed_file_count: indexed,
skipped_file_count: skipped,
total,
sessions,
projects,
daily,
source_status,
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 7 additions & 0 deletions rust/src/codex_workspaces/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionUsage>,
pub projects: Vec<ProjectUsage>,
pub daily: Vec<DailyPoint>,
pub source_status: SourceStatus,
Expand All @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion rust/src/providers/opencodego/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
Loading