From 7ea814f0ae50facba6cd0a61d06270d46f3d8133 Mon Sep 17 00:00:00 2001 From: vlad-thenvoi Date: Mon, 20 Jul 2026 19:25:19 +0300 Subject: [PATCH 1/3] feat(api): expose session reasoning tokens Preserve provider-supplied reasoning/output-thinking totals on the all-provider session facade as an optional field. Missing metadata remains unknown rather than being coerced to zero, allowing Jam's canonical reconciliation importer to retain Codex token categories without parsing provider files itself. --- rust/crates/ccusage/src/api.rs | 41 +++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/rust/crates/ccusage/src/api.rs b/rust/crates/ccusage/src/api.rs index 21cd0c635..bc16f5799 100644 --- a/rust/crates/ccusage/src/api.rs +++ b/rust/crates/ccusage/src/api.rs @@ -16,15 +16,16 @@ use std::path::PathBuf; use crate::{ + BucketKind, DEFAULT_SESSION_DURATION_HOURS, ModelBreakdown, Result, SessionAccumulator, + SessionBlock, 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}, + cli::{AgentReportKind, CostMode, SharedArgs, SortOrder, WeekDay, normalize_date_bound}, 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, + sort_summaries, summarize_by_key, summarize_summaries_by_bucket, }; /// How costs are derived from usage entries. @@ -148,6 +149,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. @@ -293,6 +297,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 +313,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])) @@ -702,6 +711,32 @@ 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 blocks_split_on_gaps_and_mark_nothing_active_for_old_data() { let fixture = fs_fixture!({ From 5a82e81a1eb8b71bb3d9d507825e9f41957e5d1c Mon Sep 17 00:00:00 2001 From: vlad-thenvoi Date: Mon, 20 Jul 2026 19:46:10 +0300 Subject: [PATCH 2/3] feat(api): scan additional Codex homes Allow embedders to add managed Codex provider-state homes to the ordinary CODEX_HOME/default discovery set without mutating global environment variables. The all-provider pipeline reuses the existing Codex parser, archived-session dedupe, date filtering, pricing, and reasoning metadata. --- rust/crates/ccusage/src/adapter/all/loader.rs | 32 ++++++++++++--- .../ccusage/src/adapter/codex/aggregate.rs | 11 ++++++ rust/crates/ccusage/src/adapter/codex/mod.rs | 4 +- .../crates/ccusage/src/adapter/codex/paths.rs | 2 +- rust/crates/ccusage/src/api.rs | 39 +++++++++++++++++++ 5 files changed, 81 insertions(+), 7 deletions(-) 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 bc16f5799..96b452651 100644 --- a/rust/crates/ccusage/src/api.rs +++ b/rust/crates/ccusage/src/api.rs @@ -59,6 +59,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>, @@ -355,6 +361,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) @@ -519,6 +526,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 @@ -737,6 +745,37 @@ mod tests { assert_eq!(usage.reasoning_tokens, Some(7)); } + #[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!({ From 6c0f06509f1039744228c34195224199da05ac90 Mon Sep 17 00:00:00 2001 From: vlad-thenvoi Date: Mon, 20 Jul 2026 22:45:19 +0300 Subject: [PATCH 3/3] feat(api): quote embedded token pricing Expose a scan-free, network-free pricing quote for one canonical token vector. Persistable quote metadata includes requested/resolved model, USD currency, a content-derived embedded catalog version, and formula version. Unknown model pricing is explicit and returns no estimated cost, preventing downstream consumers from treating missing pricing as zero. Tests cover known and unknown model behavior; the full 364-test library suite and warnings-denied Clippy pass. --- rust/crates/ccusage/build.rs | 10 +++ rust/crates/ccusage/src/api.rs | 107 +++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 4 deletions(-) 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/api.rs b/rust/crates/ccusage/src/api.rs index 96b452651..ffb5b0f01 100644 --- a/rust/crates/ccusage/src/api.rs +++ b/rust/crates/ccusage/src/api.rs @@ -17,15 +17,16 @@ use std::path::PathBuf; use crate::{ BucketKind, DEFAULT_SESSION_DURATION_HOURS, ModelBreakdown, Result, SessionAccumulator, - SessionBlock, UsageSummary, + SessionBlock, TokenUsageRaw, UsageSummary, adapter::{ all::{loader::load_rows_in, types::AllRow}, claude::{load_daily_summaries_in, load_entries_in}, }, - calculate_burn_rate, + 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, sort_blocks, - sort_summaries, summarize_by_key, summarize_summaries_by_bucket, + 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. @@ -165,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 { @@ -745,6 +810,40 @@ mod tests { 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";