Something went wrong. Try again.
npx ccusage
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268use std::{collections::HashSet, sync::Arc};
use jiff::tz::TimeZone as JiffTimeZone;
use super::{ parser::{DroidEntry, calculate_droid_cost, load_settings_file, missing_droid_pricing}, paths::discover_settings_files,};use crate::{ LoadedEntry, PricingMap, Result, UsageEntry, UsageMessage, cli::SharedArgs, debug_log, format_date_tz, parse_tz, read_files_parallel,};
pub fn load_entries(shared: &SharedArgs, pricing: &PricingMap) -> Result<Vec<LoadedEntry>> { crate::progress::track_usage_load( crate::progress::UsageLoadAgent("Droid"), shared.json, || load_entries_inner(shared, pricing), )}
fn load_entries_inner(shared: &SharedArgs, pricing: &PricingMap) -> Result<Vec<LoadedEntry>> { let tz = parse_tz(shared.timezone.as_deref()); let mut files = discover_settings_files()?; files.sort(); // Read files in parallel, reassembled in the original (sorted) file order so // the subsequent stable sort and reverse latest-wins dedup pick the same // snapshot per session as the single-threaded read. let loaded = read_files_parallel(&files, shared.single_thread, |file| { load_settings_file(file).unwrap_or_else(|error| { debug_log( shared, format!( "Failed to read Droid settings file {}: {error}", file.display() ), ); None }) }); let mut parsed: Vec<DroidEntry> = loaded.into_iter().flatten().collect(); parsed.sort_by_key(|entry| entry.timestamp); let mut seen_sessions = HashSet::new(); let mut entries = Vec::new(); for entry in parsed.into_iter().rev() { if !seen_sessions.insert(entry.session_id.clone()) { continue; } entries.push(to_loaded_entry(entry, tz.as_ref(), pricing)); } Ok(entries)}
fn to_loaded_entry( entry: DroidEntry, tz: Option<&JiffTimeZone>, pricing: &PricingMap,) -> LoadedEntry { let cost = calculate_droid_cost(&entry, pricing); let missing_pricing_model = missing_droid_pricing(&entry, pricing); let data = UsageEntry { session_id: Some(entry.session_id.clone()), timestamp: entry.timestamp_text.clone(), version: None, message: UsageMessage { usage: entry.usage, model: Some(entry.model.clone()), id: Some(format!("droid:{}", entry.session_id)), }, cost_usd: None, request_id: None, is_api_error_message: None, is_sidechain: None, }; LoadedEntry { date: format_date_tz(entry.timestamp, tz), timestamp: entry.timestamp, project: Arc::from("droid"), session_id: Arc::from(entry.session_id.as_str()), project_path: Arc::from("Droid"), cost, credits: None, extra_total_tokens: entry.reasoning_tokens, model: Some(entry.model), usage_limit_reset_time: None, missing_pricing_model, message_count: None, data, }}
#[cfg(test)]use super::report::{report_from_rows, summarize_entries};
#[cfg(test)]mod tests { use ccusage_test_support::{EnvVarGuard, fs_fixture}; use serde_json::json;
use super::super::{ parser::{normalize_droid_model_name, parse_token_usage}, paths::DROID_SESSIONS_DIR_ENV, }; use super::*; use crate::{ TokenUsageRaw, UsageEntry, UsageMessage, cli::AgentReportKind, parse_ts_timestamp, }; #[test] fn normalizes_droid_model_names() { assert_eq!( normalize_droid_model_name("custom:Claude-Opus-4.5-Thinking-[Anthropic]-0"), "claude-opus-4-5-thinking-0" ); assert_eq!( normalize_droid_model_name("Claude-Sonnet-4-[Anthropic]"), "claude-sonnet-4" ); assert_eq!( normalize_droid_model_name("gemini-2.5-pro"), "gemini-2-5-pro" ); }
#[test] fn falls_back_to_total_tokens_when_droid_parts_are_missing() { let usage = parse_token_usage(Some(&serde_json::json!({ "totalTokens": 456 }))) .unwrap();
assert_eq!(usage.output_tokens, 456); assert_eq!(usage.thinking_tokens, 0); }
#[test] fn loads_usage_from_droid_settings_files() { let fixture = fs_fixture!({ "session-a.settings.json": r#"{ "model": "Claude-Sonnet-4-[Anthropic]", "providerLock": "anthropic", "providerLockTimestamp": "2026-05-01T01:02:03.000Z", "tokenUsage": { "inputTokens": 100, "outputTokens": 50, "cacheCreationTokens": 20, "cacheReadTokens": 10, "thinkingTokens": 5 } }"#, "zero.settings.json": r#"{"model":"gpt-5","tokenUsage":{"inputTokens":0}}"#, }); let _cleanup = EnvVarGuard::set(DROID_SESSIONS_DIR_ENV, fixture.root());
let pricing = PricingMap::load_embedded(); let shared = SharedArgs { timezone: Some("UTC".to_string()), ..SharedArgs::default() }; let entries = load_entries(&shared, &pricing).unwrap();
assert_eq!(entries.len(), 1); assert_eq!(entries[0].date, "2026-05-01"); assert_eq!(entries[0].session_id.as_ref(), "session-a"); assert_eq!(entries[0].model.as_deref(), Some("claude-sonnet-4")); assert_eq!(entries[0].data.message.usage.input_tokens, 100); assert_eq!(entries[0].data.message.usage.output_tokens, 50); assert_eq!( entries[0].data.message.usage.cache_creation_input_tokens, 20 ); assert_eq!(entries[0].data.message.usage.cache_read_input_tokens, 10); assert_eq!(entries[0].extra_total_tokens, 5); }
#[test] fn falls_back_to_sidecar_jsonl_model() { let fixture = fs_fixture!({ "session-b.settings.json": r#"{ "providerLock": "anthropic", "providerLockTimestamp": "2026-05-02T01:02:03.000Z", "tokenUsage": {"inputTokens": 10, "outputTokens": 20} }"#, "session-b.jsonl": r#"{"content":"Model: Claude Opus 4.5 Thinking [Anthropic]"}"#, }); let _cleanup = EnvVarGuard::set(DROID_SESSIONS_DIR_ENV, fixture.root());
let pricing = PricingMap::load_embedded(); let entries = load_entries(&SharedArgs::default(), &pricing).unwrap();
assert_eq!(entries.len(), 1); assert_eq!( entries[0].data.message.model.as_deref(), Some("claude-opus-4-5-thinking") ); }
#[test] fn keeps_latest_snapshot_for_duplicate_session_ids() { let fixture = fs_fixture!({ "archive/session-c.settings.json": r#"{ "model": "gpt-5", "providerLock": "openai", "providerLockTimestamp": "2026-05-01T01:02:03.000Z", "tokenUsage": {"inputTokens": 10, "outputTokens": 20} }"#, "session-c.settings.json": r#"{ "model": "gpt-5", "providerLock": "openai", "providerLockTimestamp": "2026-05-02T01:02:03.000Z", "tokenUsage": {"inputTokens": 100, "outputTokens": 200} }"#, }); let _cleanup = EnvVarGuard::set(DROID_SESSIONS_DIR_ENV, fixture.root());
let pricing = PricingMap::load_embedded(); let entries = load_entries(&SharedArgs::default(), &pricing).unwrap();
assert_eq!(entries.len(), 1); assert_eq!(entries[0].session_id.as_ref(), "session-c"); assert_eq!(entries[0].data.message.usage.input_tokens, 100); assert_eq!(entries[0].data.message.usage.output_tokens, 200); }
#[test] fn report_total_includes_thinking_tokens() { let entry = LoadedEntry { data: UsageEntry { session_id: Some("session-a".to_string()), timestamp: "2026-05-01T01:02:03.000Z".to_string(), version: None, message: UsageMessage { usage: TokenUsageRaw { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 20, cache_read_input_tokens: 10, speed: None, cache_creation: None, }, model: Some("claude-sonnet-4".to_string()), id: Some("droid:session-a".to_string()), }, cost_usd: None, request_id: None, is_api_error_message: None, is_sidechain: None, }, timestamp: parse_ts_timestamp("2026-05-01T01:02:03.000Z").unwrap(), date: "2026-05-01".to_string(), project: Arc::from("droid"), session_id: Arc::from("session-a"), project_path: Arc::from("Droid"), cost: 0.0, credits: None, extra_total_tokens: 5, model: Some("claude-sonnet-4".to_string()), usage_limit_reset_time: None, missing_pricing_model: None, message_count: None, }; let rows = summarize_entries(&[entry], AgentReportKind::Daily).unwrap(); let report = report_from_rows(&rows, AgentReportKind::Daily);
assert_eq!(report["daily"][0]["totalTokens"], json!(185)); assert_eq!(report["totals"]["totalTokens"], json!(185)); }}