Skip to content
Open
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
10 changes: 10 additions & 0 deletions rust/crates/ccusage/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
32 changes: 27 additions & 5 deletions rust/crates/ccusage/src/adapter/all/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,26 @@ pub(crate) const BUILT_IN_AGENT_NAMES: &[&str] = &[
];

pub(super) fn load_rows(kind: AgentReportKind, shared: &SharedArgs) -> Result<AllLoadResult> {
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<AllLoadResult> {
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,
Expand All @@ -55,10 +63,10 @@ pub(super) fn load_sections(
) -> Result<AllSectionsLoadResult> {
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
Expand Down Expand Up @@ -115,6 +123,7 @@ fn load_base_rows(
shared: &SharedArgs,
pricing: &PricingMap,
claude_dirs: Option<&[PathBuf]>,
codex_dirs: Option<&[PathBuf]>,
providers: Option<&[String]>,
) -> Result<AllLoadResult> {
let mut progress = crate::progress::UsageLoadProgress::new(
Expand All @@ -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,
Expand Down Expand Up @@ -637,7 +646,20 @@ fn load_codex_rows(
kind: AgentReportKind,
shared: &SharedArgs,
pricing: &PricingMap,
codex_dirs: Option<&[PathBuf]>,
) -> Result<AgentRows> {
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();
Expand Down
11 changes: 11 additions & 0 deletions rust/crates/ccusage/src/adapter/codex/aggregate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BTreeMap<String, CodexGroup>> {
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,
Expand Down
4 changes: 3 additions & 1 deletion rust/crates/ccusage/src/adapter/codex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/ccusage/src/adapter/codex/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ fn codex_usage_paths_from_homes(homes: Vec<PathBuf>) -> Vec<PathBuf> {
.collect()
}

fn codex_usage_sources_from_homes(homes: Vec<PathBuf>) -> Vec<CodexUsageSource> {
pub(super) fn codex_usage_sources_from_homes(homes: Vec<PathBuf>) -> Vec<CodexUsageSource> {
let mut paths = Vec::new();
let mut seen = FxHashSet::default();
for path in homes {
Expand Down
183 changes: 178 additions & 5 deletions rust/crates/ccusage/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Vec<PathBuf>>,
/// 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<Vec<PathBuf>>,
/// Optional all-provider adapter filter for embedders and tests. `None`
/// means scan every supported adapter.
pub providers: Option<Vec<String>>,
Expand Down Expand Up @@ -148,13 +156,80 @@ 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<u64>,
pub total_cost: f64,
pub models: Vec<AgentModelUsage>,
/// RFC3339 milliseconds when the adapter exposes it.
pub first_activity: Option<String>,
pub last_activity: Option<String>,
}

/// 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<f64>,
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 {
Expand Down Expand Up @@ -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
Expand All @@ -308,6 +384,10 @@ fn metadata_string(row: &AllRow, key: &str) -> Option<String> {
row.metadata.as_ref()?.get(key)?.as_str().map(str::to_owned)
}

fn metadata_u64(row: &AllRow, key: &str) -> Option<u64> {
row.metadata.as_ref()?.get(key)?.as_u64()
}

fn flatten_agent_rows(rows: Vec<AllRow>) -> Vec<AllRow> {
rows.into_iter()
.flat_map(|mut row| row.agent_breakdowns.take().unwrap_or_else(|| vec![row]))
Expand Down Expand Up @@ -346,6 +426,7 @@ pub fn all_daily(opts: &UsageOptions) -> Result<Vec<AgentPeriodUsage>> {
AgentReportKind::Daily,
&shared,
dirs.as_deref(),
opts.codex_dirs.as_deref(),
opts.providers.as_deref(),
)?;
Ok(flatten_agent_rows(rows.rows)
Expand Down Expand Up @@ -510,6 +591,7 @@ pub fn all_sessions(opts: &UsageOptions) -> Result<Vec<AgentSessionUsage>> {
AgentReportKind::Session,
&shared,
dirs.as_deref(),
opts.codex_dirs.as_deref(),
opts.providers.as_deref(),
)?;
Ok(rows
Expand Down Expand Up @@ -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!({
Expand Down
Loading