diff --git a/rust/crates/ccusage/build.rs b/rust/crates/ccusage/build.rs index 82936fdf1..0a7513217 100644 --- a/rust/crates/ccusage/build.rs +++ b/rust/crates/ccusage/build.rs @@ -21,10 +21,20 @@ fn main() { fetch_pricing_json().expect("fetch LiteLLM pricing for embed") }; let pricing_json = compact_pricing_json(&pricing_json).expect("compact LiteLLM pricing JSON"); + println!( + "cargo:rustc-env=CCUSAGE_EMBEDDED_PRICING_VERSION=fnv1a64:{:016x}", + fnv1a64(pricing_json.as_bytes()) + ); fs::write(out_path, pricing_json).expect("write build-time pricing snapshot"); } +fn fnv1a64(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + fn out_dir_path(file_name: &str) -> PathBuf { PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by cargo")).join(file_name) } diff --git a/rust/crates/ccusage/src/adapter/all/loader.rs b/rust/crates/ccusage/src/adapter/all/loader.rs index 6a65df325..9a94aef70 100644 --- a/rust/crates/ccusage/src/adapter/all/loader.rs +++ b/rust/crates/ccusage/src/adapter/all/loader.rs @@ -31,18 +31,26 @@ pub(crate) const BUILT_IN_AGENT_NAMES: &[&str] = &[ ]; pub(super) fn load_rows(kind: AgentReportKind, shared: &SharedArgs) -> Result { - load_rows_in(kind, shared, None, None) + load_rows_in(kind, shared, None, None, None) } pub(crate) fn load_rows_in( kind: AgentReportKind, shared: &SharedArgs, claude_dirs: Option<&[PathBuf]>, + codex_dirs: Option<&[PathBuf]>, providers: Option<&[String]>, ) -> Result { let pricing = load_pricing(shared); let load_kind = load_kind_for_report(kind); - let loaded = load_base_rows(load_kind, shared, &pricing, claude_dirs, providers)?; + let loaded = load_base_rows( + load_kind, + shared, + &pricing, + claude_dirs, + codex_dirs, + providers, + )?; Ok(AllLoadResult { rows: finish_rows(kind, loaded.rows, shared), detected_agents: loaded.detected_agents, @@ -55,10 +63,10 @@ pub(super) fn load_sections( ) -> Result { let pricing = load_pricing(shared); let daily_base = needs_daily_family(kinds) - .then(|| load_base_rows(AgentReportKind::Daily, shared, &pricing, None, None)) + .then(|| load_base_rows(AgentReportKind::Daily, shared, &pricing, None, None, None)) .transpose()?; let session_base = needs_session(kinds) - .then(|| load_base_rows(AgentReportKind::Session, shared, &pricing, None, None)) + .then(|| load_base_rows(AgentReportKind::Session, shared, &pricing, None, None, None)) .transpose()?; let daily_detected_agents = daily_base @@ -115,6 +123,7 @@ fn load_base_rows( shared: &SharedArgs, pricing: &PricingMap, claude_dirs: Option<&[PathBuf]>, + codex_dirs: Option<&[PathBuf]>, providers: Option<&[String]>, ) -> Result { let mut progress = crate::progress::UsageLoadProgress::new( @@ -139,7 +148,7 @@ fn load_base_rows( index: 1, agent: BUILT_IN_AGENT_NAMES[1], progress_agent: crate::progress::UsageLoadAgent::Codex, - load: Box::new(|| load_codex_rows(load_kind, &loader_shared, pricing)), + load: Box::new(|| load_codex_rows(load_kind, &loader_shared, pricing, codex_dirs)), }, AgentLoadSpec { index: 2, @@ -637,7 +646,20 @@ fn load_codex_rows( kind: AgentReportKind, shared: &SharedArgs, pricing: &PricingMap, + codex_dirs: Option<&[PathBuf]>, ) -> Result { + if let Some(codex_dirs) = codex_dirs { + let groups = codex::load_groups_with_additional_homes(codex_dirs, shared, kind)?; + let detected = !groups.is_empty(); + let speed = codex::resolve_codex_speed(CodexSpeed::Auto); + return Ok(AgentRows { + rows: groups + .iter() + .map(|(period, group)| codex_group_row(period, group, pricing, speed)) + .collect(), + detected, + }); + } if shared.since.is_none() && shared.until.is_none() { let groups = codex::load_groups(shared, kind)?; let detected = !groups.is_empty(); diff --git a/rust/crates/ccusage/src/adapter/codex/aggregate.rs b/rust/crates/ccusage/src/adapter/codex/aggregate.rs index 57722a62d..6de200b8a 100644 --- a/rust/crates/ccusage/src/adapter/codex/aggregate.rs +++ b/rust/crates/ccusage/src/adapter/codex/aggregate.rs @@ -48,6 +48,17 @@ pub(crate) fn load_groups( load_groups_from_sources(&sources, shared, kind) } +pub(crate) fn load_groups_with_additional_homes( + additional_homes: &[PathBuf], + shared: &SharedArgs, + kind: AgentReportKind, +) -> Result> { + let mut homes = paths::codex_home_paths()?; + homes.extend_from_slice(additional_homes); + let sources = paths::codex_usage_sources_from_homes(homes); + load_groups_from_sources(&sources, shared, kind) +} + fn load_groups_from_sources( sources: &[paths::CodexUsageSource], shared: &SharedArgs, diff --git a/rust/crates/ccusage/src/adapter/codex/mod.rs b/rust/crates/ccusage/src/adapter/codex/mod.rs index ce187402d..1054840cb 100644 --- a/rust/crates/ccusage/src/adapter/codex/mod.rs +++ b/rust/crates/ccusage/src/adapter/codex/mod.rs @@ -8,7 +8,9 @@ mod types; use crate::{PricingMap, Result, cli::AgentCommandArgs, log_level, print_json_or_jq, wants_json}; -pub(crate) use aggregate::{aggregate_events, filter_events_by_date, load_groups}; +pub(crate) use aggregate::{ + aggregate_events, filter_events_by_date, load_groups, load_groups_with_additional_homes, +}; pub(crate) use loader::load_codex_events; #[cfg(test)] pub(crate) use loader::load_codex_events_from_directory; diff --git a/rust/crates/ccusage/src/adapter/codex/paths.rs b/rust/crates/ccusage/src/adapter/codex/paths.rs index c188f481d..f694a42ac 100644 --- a/rust/crates/ccusage/src/adapter/codex/paths.rs +++ b/rust/crates/ccusage/src/adapter/codex/paths.rs @@ -17,7 +17,7 @@ fn codex_usage_paths_from_homes(homes: Vec) -> Vec { .collect() } -fn codex_usage_sources_from_homes(homes: Vec) -> Vec { +pub(super) fn codex_usage_sources_from_homes(homes: Vec) -> Vec { let mut paths = Vec::new(); let mut seen = FxHashSet::default(); for path in homes { diff --git a/rust/crates/ccusage/src/api.rs b/rust/crates/ccusage/src/api.rs index 21cd0c635..ffb5b0f01 100644 --- a/rust/crates/ccusage/src/api.rs +++ b/rust/crates/ccusage/src/api.rs @@ -16,15 +16,17 @@ use std::path::PathBuf; use crate::{ + BucketKind, DEFAULT_SESSION_DURATION_HOURS, ModelBreakdown, Result, SessionAccumulator, + SessionBlock, TokenUsageRaw, UsageSummary, adapter::{ all::{loader::load_rows_in, types::AllRow}, claude::{load_daily_summaries_in, load_entries_in}, }, - calculate_burn_rate, - cli::{normalize_date_bound, AgentReportKind, CostMode, SharedArgs, SortOrder, WeekDay}, - filter_and_sort_summaries, filter_blocks_by_date, identify_session_blocks, sort_blocks, - sort_summaries, summarize_by_key, summarize_summaries_by_bucket, BucketKind, ModelBreakdown, - Result, SessionAccumulator, SessionBlock, UsageSummary, DEFAULT_SESSION_DURATION_HOURS, + calculate_burn_rate, calculate_cost_for_usage, + cli::{AgentReportKind, CostMode, SharedArgs, SortOrder, WeekDay, normalize_date_bound}, + filter_and_sort_summaries, filter_blocks_by_date, identify_session_blocks, + pricing::PricingMap, + sort_blocks, sort_summaries, summarize_by_key, summarize_summaries_by_bucket, }; /// How costs are derived from usage entries. @@ -58,6 +60,12 @@ pub struct UsageOptions { /// entries without a `projects/` subdirectory are skipped and an /// override with no valid entries is an error. pub claude_dirs: Option>, + /// Additional Codex home directories (each normally containing + /// `sessions/` and optionally `archived_sessions/`). These are merged with + /// `CODEX_HOME` / default-home discovery, allowing embedders to include + /// managed provider-state homes without hiding ordinary local sessions or + /// mutating process-global environment variables. + pub codex_dirs: Option>, /// Optional all-provider adapter filter for embedders and tests. `None` /// means scan every supported adapter. pub providers: Option>, @@ -148,6 +156,9 @@ pub struct AgentSessionUsage { pub output_tokens: u64, pub cache_creation_tokens: u64, pub cache_read_tokens: u64, + /// Provider-supplied reasoning/output-thinking tokens when the adapter + /// exposes a distinct category. `None` means unavailable, not zero. + pub reasoning_tokens: Option, pub total_cost: f64, pub models: Vec, /// RFC3339 milliseconds when the adapter exposes it. @@ -155,6 +166,70 @@ pub struct AgentSessionUsage { pub last_activity: Option, } +/// Provider-neutral token buckets for one deterministic offline price quote. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TokenPriceRequest { + pub model: String, + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_input_tokens: u64, +} + +/// One price estimate from the build-pinned embedded catalog. +/// +/// Missing pricing is explicit and never represented as a zero-dollar quote. +#[derive(Debug, Clone, PartialEq)] +pub struct TokenPriceQuote { + pub requested_model: String, + pub resolved_model: String, + pub estimated_cost_usd: Option, + pub missing_pricing: bool, + pub currency: &'static str, + pub catalog: &'static str, + pub catalog_version: &'static str, + pub formula_version: &'static str, +} + +/// Price one canonical token vector from the immutable catalog embedded in +/// this ccusage build. This function performs no file scan or network access. +#[must_use] +pub fn quote_embedded_tokens(request: &TokenPriceRequest) -> TokenPriceQuote { + let pricing = PricingMap::load_embedded(); + let resolved_model = crate::model_aliases::resolve_model_name(&request.model).into_owned(); + let has_tokens = request.input_tokens > 0 + || request.cached_input_tokens > 0 + || request.output_tokens > 0 + || request.cache_creation_input_tokens > 0; + let missing_pricing = has_tokens && pricing.find(&request.model).is_none(); + let usage = TokenUsageRaw { + input_tokens: request.input_tokens, + output_tokens: request.output_tokens, + cache_creation_input_tokens: request.cache_creation_input_tokens, + cache_read_input_tokens: request.cached_input_tokens, + ..TokenUsageRaw::default() + }; + let estimated_cost_usd = (!missing_pricing).then(|| { + calculate_cost_for_usage( + Some(&request.model), + usage, + None, + CostMode::Calculate, + Some(&pricing), + ) + }); + TokenPriceQuote { + requested_model: request.model.clone(), + resolved_model, + estimated_cost_usd, + missing_pricing, + currency: "USD", + catalog: "ccusage-embedded", + catalog_version: env!("CCUSAGE_EMBEDDED_PRICING_VERSION"), + formula_version: "ccusage-token-pricing-v1", + } +} + /// Tokens-per-minute and cost-per-hour over a block's active span. #[derive(Debug, Clone, Copy, PartialEq)] pub struct BurnRateInfo { @@ -293,6 +368,7 @@ fn agent_session_usage(row: &AllRow) -> AgentSessionUsage { output_tokens: row.output_tokens, cache_creation_tokens: row.cache_creation_tokens, cache_read_tokens: row.cache_read_tokens, + reasoning_tokens: metadata_u64(row, "reasoningOutputTokens"), total_cost: row.total_cost, models: row .model_breakdowns @@ -308,6 +384,10 @@ fn metadata_string(row: &AllRow, key: &str) -> Option { row.metadata.as_ref()?.get(key)?.as_str().map(str::to_owned) } +fn metadata_u64(row: &AllRow, key: &str) -> Option { + row.metadata.as_ref()?.get(key)?.as_u64() +} + fn flatten_agent_rows(rows: Vec) -> Vec { rows.into_iter() .flat_map(|mut row| row.agent_breakdowns.take().unwrap_or_else(|| vec![row])) @@ -346,6 +426,7 @@ pub fn all_daily(opts: &UsageOptions) -> Result> { AgentReportKind::Daily, &shared, dirs.as_deref(), + opts.codex_dirs.as_deref(), opts.providers.as_deref(), )?; Ok(flatten_agent_rows(rows.rows) @@ -510,6 +591,7 @@ pub fn all_sessions(opts: &UsageOptions) -> Result> { AgentReportKind::Session, &shared, dirs.as_deref(), + opts.codex_dirs.as_deref(), opts.providers.as_deref(), )?; Ok(rows @@ -702,6 +784,97 @@ mod tests { ); } + #[test] + fn agent_session_usage_preserves_provider_reasoning_tokens() { + let row = AllRow { + period: "codex-session".to_string(), + agent: "codex", + models_used: vec!["gpt-5.4".to_string()], + input_tokens: 100, + output_tokens: 50, + cache_creation_tokens: 0, + cache_read_tokens: 10, + total_tokens: 167, + total_cost: 0.25, + metadata: Some(serde_json::json!({ + "lastActivity": "2026-01-10T10:00:00.000Z", + "reasoningOutputTokens": 7, + })), + metadata_agents: Some(vec!["codex"]), + agent_breakdowns: None, + model_breakdowns: Vec::new(), + }; + + let usage = agent_session_usage(&row); + + assert_eq!(usage.reasoning_tokens, Some(7)); + } + + #[test] + fn embedded_price_quote_returns_versioned_usd_estimate_for_known_model() { + let quote = quote_embedded_tokens(&TokenPriceRequest { + model: "gpt-5.4".to_owned(), + input_tokens: 1_000, + cached_input_tokens: 500, + output_tokens: 250, + cache_creation_input_tokens: 0, + }); + + assert_eq!(quote.requested_model, "gpt-5.4"); + assert!(!quote.resolved_model.is_empty()); + assert_eq!(quote.currency, "USD"); + assert_eq!(quote.catalog, "ccusage-embedded"); + assert!(quote.catalog_version.starts_with("fnv1a64:")); + assert_eq!(quote.formula_version, "ccusage-token-pricing-v1"); + assert!(!quote.missing_pricing); + assert!(quote.estimated_cost_usd.is_some_and(|cost| cost > 0.0)); + } + + #[test] + fn embedded_price_quote_marks_unknown_model_missing_instead_of_zero_cost() { + let quote = quote_embedded_tokens(&TokenPriceRequest { + model: "provider/definitely-unknown-model".to_owned(), + input_tokens: 1_000, + cached_input_tokens: 0, + output_tokens: 250, + cache_creation_input_tokens: 0, + }); + + assert!(quote.missing_pricing); + assert_eq!(quote.estimated_cost_usd, None); + } + + #[test] + fn all_sessions_scans_additional_codex_homes_without_mutating_environment() { + let session = "00000000-0000-4000-8000-000000000299"; + let fixture = fs_fixture!({ + "sessions/2026/07/20/rollout-2026-07-20T10-00-00-00000000-0000-4000-8000-000000000299.jsonl": + r#"{"timestamp":"2026-07-20T10:05:00.000Z","type":"event_msg","payload":{"type":"token_count","info":{"model":"gpt-5.4","last_token_usage":{"input_tokens":100,"cached_input_tokens":25,"output_tokens":10,"reasoning_output_tokens":7,"total_tokens":117}}}}"#, + }); + let opts = UsageOptions { + offline: true, + timezone: Some("UTC".into()), + providers: Some(vec!["codex".into()]), + codex_dirs: Some(vec![fixture.root().to_path_buf()]), + ..UsageOptions::default() + }; + + let rows = all_sessions(&opts).expect("explicit Codex scan"); + let row = rows + .iter() + .find(|row| row.provider == "codex" && row.session_id.ends_with(session)) + .expect("exact fixture session"); + + assert_eq!(row.input_tokens, 75); + assert_eq!(row.cache_read_tokens, 25); + assert_eq!(row.output_tokens, 10); + assert_eq!(row.reasoning_tokens, Some(7)); + assert_eq!( + row.last_activity.as_deref(), + Some("2026-07-20T10:05:00.000Z") + ); + } + #[test] fn blocks_split_on_gaps_and_mark_nothing_active_for_old_data() { let fixture = fs_fixture!({