diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 4920a52c8..0827498b7 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -1078,54 +1078,14 @@ fn emit_cursor_setup_warnings(warnings: &[String]) { } } -fn warp_setup_warnings_for_report( - home_dir: &Option, - clients: &Option>, -) -> Vec { +fn warp_setup_warnings_for_report(clients: &Option>) -> Vec { if !client_filter_explicitly_requests_warp(clients) { return Vec::new(); } - let (home_path, home_override) = match home_dir { - Some(home) => (PathBuf::from(home), true), - None => match dirs::home_dir() { - Some(home) => (home, false), - None => { - return vec![ - "Warp usage requires Tokscale's Warp aggregate cache, but the home directory could not be resolved. Tokscale does not parse local Warp transcripts.".to_string(), - ]; - } - }, - }; - let has_cache = if home_override { - warp::has_usage_cache_in_home(&home_path) - } else { - warp::load_usage_cache().is_some() - }; - if has_cache { - return Vec::new(); - } - - let cache_glob = if home_override { - home_path - .join(".config/tokscale/warp-cache/usage*.json") - .to_string_lossy() - .to_string() - } else { - "~/.config/tokscale/warp-cache/usage*.json".to_string() - }; - let action = if home_override { - "run `tokscale warp sync` for the default profile or populate that cache before running a report with --home" - } else if warp::has_credentials() { - "run `tokscale warp sync`" - } else { - "run `tokscale warp login` and `tokscale warp sync`" - }; - - vec![format!( - "Warp usage requires Tokscale's aggregate API cache at `{}`; {}. Tokscale does not parse local Warp/Oz session transcripts and does not infer tokens from request counts.", - cache_glob, action - )] + vec![ + "Warp aggregate request/spend data is not included in local reports because it has no token buckets. Tokscale does not parse local Warp/Oz session transcripts; add Warp again only when a token-level source is available.".to_string(), + ] } fn setup_warnings_for_report( @@ -1133,7 +1093,7 @@ fn setup_warnings_for_report( clients: &Option>, ) -> Vec { let mut warnings = cursor_setup_warnings_for_report(home_dir, clients); - warnings.extend(warp_setup_warnings_for_report(home_dir, clients)); + warnings.extend(warp_setup_warnings_for_report(clients)); warnings } @@ -4450,158 +4410,6 @@ fn cap_graph_result_to_utc_today( true } -/// A client row dropped from a submission because it carried cost without any -/// token attribution. See [`exclude_tokenless_cost_contributions`]. -#[derive(Debug, Clone, PartialEq)] -struct ExcludedTokenlessRow { - date: String, - client: String, - model_id: String, - provider_id: String, - cost: f64, -} - -fn client_token_total(tokens: &tokscale_core::TokenBreakdown) -> i64 { - tokens.input + tokens.output + tokens.cache_read + tokens.cache_write + tokens.reasoning -} - -/// Cursor's pre-2025-05 exports include `premium-tool-call` rows billed per -/// tool invocation with no token attribution. The server grandfathers these -/// (cost > 0, tokens = 0) rather than rejecting them, so the client must not -/// drop them either — otherwise that legitimate cost silently disappears from -/// the submission. Keep in sync with `CURSOR_LEGACY_TOKENLESS_MODELS` in -/// packages/frontend/src/lib/validation/submission.ts. -fn is_legacy_tokenless_cursor_row(client: &tokscale_core::ClientContribution) -> bool { - client.client == "cursor" - && client.model_id == "premium-tool-call" - && client_token_total(&client.tokens) == 0 -} - -fn is_aggregate_only_warp_row(client: &tokscale_core::ClientContribution) -> bool { - client.client == "warp" - && client.model_id == "aggregate-requests" - && client_token_total(&client.tokens) == 0 -} - -/// A row the server's "Cost submitted without tokens" sanity check would -/// reject: real cost with every token bucket at zero, excluding the Cursor -/// `premium-tool-call` carve-out above. -fn is_tokenless_costed_row(client: &tokscale_core::ClientContribution) -> bool { - (is_aggregate_only_warp_row(client) || client.cost > 0.0) - && client_token_total(&client.tokens) == 0 - && !is_legacy_tokenless_cursor_row(client) -} - -/// Drop client rows that report cost without any tokens so the submission -/// passes the server's cost-without-tokens validation instead of being -/// rejected wholesale. -/// -/// Cursor's usage export lists historical request/On-Demand charges (e.g. -/// `auto`, `claude-3.5-sonnet`, `o3`) with empty token columns, and Warp/Oz -/// only exposes aggregate request/spend counters. The server rejects cost with -/// no tokens, and request counts must not be submitted as fabricated tokens, so -/// we exclude the offending rows here and report them to the user. -/// -/// Excluded rows always carry zero tokens, so only cost/messages change; token -/// totals, breakdowns, and intensities are untouched. Summary and year rollups -/// are recomputed from the trimmed contributions. -fn exclude_tokenless_cost_contributions( - graph_result: &mut tokscale_core::GraphResult, -) -> Vec { - let mut excluded: Vec = Vec::new(); - - for day in graph_result.contributions.iter_mut() { - let date = day.date.clone(); - let mut removed_cost = 0.0; - let mut removed_messages: i32 = 0; - - day.clients.retain(|client| { - if is_tokenless_costed_row(client) { - excluded.push(ExcludedTokenlessRow { - date: date.clone(), - client: client.client.clone(), - model_id: client.model_id.clone(), - provider_id: client.provider_id.clone(), - cost: client.cost, - }); - removed_cost += client.cost; - removed_messages = removed_messages.saturating_add(client.messages); - false - } else { - true - } - }); - - if removed_cost > 0.0 || removed_messages > 0 { - day.totals.cost = (day.totals.cost - removed_cost).max(0.0); - day.totals.messages = day.totals.messages.saturating_sub(removed_messages).max(0); - } - } - - if !excluded.is_empty() { - graph_result.summary = tokscale_core::calculate_summary(&graph_result.contributions); - graph_result.years = tokscale_core::calculate_years(&graph_result.contributions); - } - - excluded -} - -/// Print the rows dropped by [`exclude_tokenless_cost_contributions`] so the -/// user can see exactly what was left out, capping the per-row detail so a long -/// history of legacy Cursor charges doesn't flood the terminal. -fn report_excluded_tokenless_rows(excluded: &[ExcludedTokenlessRow]) { - use colored::Colorize; - - if excluded.is_empty() { - return; - } - - const MAX_DETAIL_ROWS: usize = 20; - let total_cost: f64 = excluded.iter().map(|row| row.cost).sum(); - - println!( - "{}", - format!( - " Excluded {} aggregate/cost-only row(s) with no token data:", - excluded.len() - ) - .yellow() - ); - - for row in excluded.iter().take(MAX_DETAIL_ROWS) { - let provider = if row.provider_id.is_empty() { - String::new() - } else { - format!(" (provider={})", row.provider_id) - }; - println!( - "{}", - format!( - " - {}/{}{} on {}: ${:.4}", - row.client, row.model_id, provider, row.date, row.cost - ) - .bright_black() - ); - } - - if excluded.len() > MAX_DETAIL_ROWS { - println!( - "{}", - format!(" ... and {} more", excluded.len() - MAX_DETAIL_ROWS).bright_black() - ); - } - - println!( - "{}", - format!( - " Excluded {} total; the rest is submitted.", - format_currency(total_cost) - ) - .bright_black() - ); - println!(); -} - fn run_submit_command( clients: Option>, since: Option, @@ -4699,12 +4507,6 @@ fn run_submit_command( let mut graph_result = graph_result; cap_graph_result_to_utc_today(&mut graph_result, &utc_today); - // Drop cost-only rows the server would reject (Cursor historical exports - // record per-request cost with empty token columns) and report what was - // left out, so a single legacy charge can't block the whole submission. - let excluded_rows = exclude_tokenless_cost_contributions(&mut graph_result); - report_excluded_tokenless_rows(&excluded_rows); - println!("{}", " Data to submit:".white()); println!( "{}", @@ -6213,149 +6015,6 @@ mod tests { assert_eq!(graph.years.len(), original_years.len()); } - fn client_contribution( - client: &str, - model_id: &str, - provider_id: &str, - total_tokens: i64, - cost: f64, - messages: i32, - ) -> ClientContribution { - ClientContribution { - client: client.to_string(), - model_id: model_id.to_string(), - provider_id: provider_id.to_string(), - tokens: token_breakdown(total_tokens), - cost, - messages, - } - } - - fn day_with_clients( - date: &str, - token_breakdown_total: i64, - clients: Vec, - ) -> DailyContribution { - let tokens: i64 = clients.iter().map(|c| client_token_total(&c.tokens)).sum(); - let cost: f64 = clients.iter().map(|c| c.cost).sum(); - let messages: i32 = clients.iter().map(|c| c.messages).sum(); - DailyContribution { - date: date.to_string(), - totals: DailyTotals { - tokens, - cost, - messages, - }, - intensity: 0, - token_breakdown: token_breakdown(token_breakdown_total), - clients, - active_time_ms: None, - } - } - - #[test] - fn test_exclude_tokenless_cost_drops_offenders_and_keeps_the_rest() { - // A token-bearing row shares the day with a tokenless cursor charge - // (cost, no tokens) and a grandfathered premium-tool-call row. - let mut graph = graph_result_with_contributions(vec![day_with_clients( - "2025-05-28", - 100, - vec![ - client_contribution("cursor", "claude-3.7-sonnet", "anthropic", 100, 0.03, 1), - client_contribution("cursor", "auto", "cursor", 0, 0.04, 1), - client_contribution("cursor", "premium-tool-call", "cursor", 0, 2.05, 44), - ], - )]); - - let excluded = exclude_tokenless_cost_contributions(&mut graph); - - // Only the tokenless `auto` row is dropped. - assert_eq!(excluded.len(), 1); - assert_eq!(excluded[0].model_id, "auto"); - assert!((excluded[0].cost - 0.04).abs() < 1e-9); - - let day = &graph.contributions[0]; - assert_eq!(day.clients.len(), 2); - assert!(day.clients.iter().all(|c| c.model_id != "auto")); - // premium-tool-call is preserved (server carve-out). - assert!(day - .clients - .iter() - .any(|c| c.model_id == "premium-tool-call")); - // Tokens untouched; cost/messages reduced by the dropped row only. - assert_eq!(day.totals.tokens, 100); - assert!((day.totals.cost - 2.08).abs() < 1e-9); - assert_eq!(day.totals.messages, 45); - assert!((graph.summary.total_cost - 2.08).abs() < 1e-9); - assert_eq!(graph.summary.total_tokens, 100); - } - - #[test] - fn test_exclude_tokenless_cost_zeroes_a_fully_tokenless_day() { - let mut graph = graph_result_with_contributions(vec![day_with_clients( - "2025-05-30", - 0, - vec![ - client_contribution("cursor", "auto", "cursor", 0, 0.04, 1), - client_contribution("cursor", "auto", "cursor", 0, 0.04, 1), - ], - )]); - - let excluded = exclude_tokenless_cost_contributions(&mut graph); - - assert_eq!(excluded.len(), 2); - let day = &graph.contributions[0]; - assert!(day.clients.is_empty()); - assert_eq!(day.totals.cost, 0.0); - assert_eq!(day.totals.tokens, 0); - assert_eq!(graph.summary.total_cost, 0.0); - } - - #[test] - fn test_exclude_tokenless_cost_is_noop_without_offenders() { - let mut graph = graph_result_with_contributions(vec![day_with_clients( - "2025-05-28", - 100, - vec![ - client_contribution("codex", "gpt-5", "openai", 100, 0.03, 1), - // Grandfathered cursor legacy row must not be dropped. - client_contribution("cursor", "premium-tool-call", "cursor", 0, 2.05, 44), - ], - )]); - let original_cost = graph.summary.total_cost; - - let excluded = exclude_tokenless_cost_contributions(&mut graph); - - assert!(excluded.is_empty()); - assert_eq!(graph.contributions[0].clients.len(), 2); - assert_eq!(graph.summary.total_cost, original_cost); - } - - #[test] - fn test_exclude_tokenless_cost_drops_warp_aggregate_requests() { - let mut graph = graph_result_with_contributions(vec![day_with_clients( - "2026-01-02", - 0, - vec![client_contribution( - "warp", - "aggregate-requests", - "warp", - 0, - 12.34, - 42, - )], - )]); - - let excluded = exclude_tokenless_cost_contributions(&mut graph); - - assert_eq!(excluded.len(), 1); - assert_eq!(excluded[0].client, "warp"); - assert_eq!(excluded[0].model_id, "aggregate-requests"); - assert!(graph.contributions[0].clients.is_empty()); - assert_eq!(graph.summary.total_tokens, 0); - assert_eq!(graph.summary.total_cost, 0.0); - } - #[test] fn test_submit_payload_includes_device_when_provided() { let graph = graph_result_with_contributions(vec![daily_contribution( @@ -6574,16 +6233,12 @@ mod tests { } #[test] - fn warp_setup_warning_explains_missing_aggregate_cache() { - let temp = tempfile::TempDir::new().unwrap(); - let warnings = warp_setup_warnings_for_report( - &Some(temp.path().to_string_lossy().to_string()), - &Some(vec!["warp".to_string()]), - ); + fn warp_setup_warning_explains_aggregate_cache_is_not_reported() { + let warnings = warp_setup_warnings_for_report(&Some(vec!["warp".to_string()])); assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("tokscale warp")); - assert!(warnings[0].contains("does not infer tokens from request counts")); + assert!(warnings[0].contains("not included in local reports")); + assert!(warnings[0].contains("no token buckets")); } #[test] diff --git a/crates/tokscale-cli/src/warp.rs b/crates/tokscale-cli/src/warp.rs index aadfa9641..7ece565f7 100644 --- a/crates/tokscale-cli/src/warp.rs +++ b/crates/tokscale-cli/src/warp.rs @@ -132,12 +132,6 @@ pub(crate) fn load_usage_cache() -> Option { serde_json::from_str(&content).ok() } -pub fn has_usage_cache_in_home(home_dir: &Path) -> bool { - home_dir - .join(".config/tokscale/warp-cache/usage.json") - .exists() -} - pub fn run_warp_login(token: Option, cookie: bool) -> Result<()> { println!("\n {}\n", "Warp/Oz - Login".cyan()); let auth_value = match token { diff --git a/crates/tokscale-cli/tests/cli_tests.rs b/crates/tokscale-cli/tests/cli_tests.rs index 8afec5094..f533f7265 100644 --- a/crates/tokscale-cli/tests/cli_tests.rs +++ b/crates/tokscale-cli/tests/cli_tests.rs @@ -1792,10 +1792,7 @@ fn test_models_json_offline_without_pricing_cache_still_succeeds() { assert_eq!(json["totalMessages"].as_i64().unwrap(), 3); assert_eq!(json["entries"].as_array().unwrap().len(), 2); let total_cost = json["totalCost"].as_f64().unwrap(); - assert!( - (total_cost - 0.10).abs() < 1e-9, - "unexpected totalCost without pricing: {total_cost}" - ); + assert_eq!(total_cost, 0.0); } #[test] @@ -1817,10 +1814,7 @@ fn test_monthly_json_offline_without_pricing_cache_still_succeeds() { assert_eq!(entries[0]["month"].as_str().unwrap(), "2024-06"); assert_eq!(entries[1]["month"].as_str().unwrap(), "2025-01"); let total_cost = json["totalCost"].as_f64().unwrap(); - assert!( - (total_cost - 0.10).abs() < 1e-9, - "unexpected totalCost without pricing: {total_cost}" - ); + assert_eq!(total_cost, 0.0); } #[test] @@ -1841,10 +1835,7 @@ fn test_graph_offline_without_pricing_cache_still_succeeds() { assert_eq!(json["summary"]["activeDays"].as_i64().unwrap(), 2); assert_eq!(json["contributions"].as_array().unwrap().len(), 2); let total_cost = json["summary"]["totalCost"].as_f64().unwrap(); - assert!( - (total_cost - 0.10).abs() < 1e-9, - "unexpected totalCost without pricing: {total_cost}" - ); + assert_eq!(total_cost, 0.0); } #[test] @@ -1894,10 +1885,7 @@ fn test_hourly_json_offline_without_pricing_cache_still_succeeds() { 1000 ); let total_cost = json["totalCost"].as_f64().unwrap(); - assert!( - (total_cost - 0.10).abs() < 1e-9, - "unexpected totalCost without pricing: {total_cost}" - ); + assert_eq!(total_cost, 0.0); } #[test] diff --git a/crates/tokscale-core/src/adapters/cache.rs b/crates/tokscale-core/src/adapters/cache.rs index f5b79788c..226888522 100644 --- a/crates/tokscale-core/src/adapters/cache.rs +++ b/crates/tokscale-core/src/adapters/cache.rs @@ -45,7 +45,7 @@ where { let Some(fingerprint) = fingerprint_for_unit(&unit) else { let (mut messages, _) = parse(&unit.path); - apply_pricing_to_messages(&mut messages, ctx.pricing); + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); return ParsedUnit { unit, messages: UnitMessageSource::Fresh(messages), @@ -66,6 +66,7 @@ where } let (mut messages, cacheable) = parse(&unit.path); + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); let cache_entry = if messages.is_empty() || !cacheable { None } else { @@ -77,7 +78,6 @@ where None, )) }; - apply_pricing_to_messages(&mut messages, ctx.pricing); ParsedUnit { unit, @@ -114,7 +114,7 @@ pub(crate) fn resolve_messages( UnitMessageSource::Fresh(messages) => messages, UnitMessageSource::CacheHit(path) => { let mut messages = ctx.source_cache.take_messages(&path).unwrap_or_default(); - apply_pricing_to_messages(&mut messages, ctx.pricing); + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); messages } UnitMessageSource::CodexCacheHit { .. } | UnitMessageSource::CodexAppend(_) => { @@ -138,13 +138,3 @@ fn fingerprint_for_unit(unit: &SourceUnit) -> Option None, } } - -fn apply_pricing_to_messages( - messages: &mut [UnifiedMessage], - pricing: Option<&crate::pricing::PricingService>, -) { - for message in messages { - message.refresh_derived_fields(); - crate::apply_pricing_if_available(message, pricing); - } -} diff --git a/crates/tokscale-core/src/adapters/codex.rs b/crates/tokscale-core/src/adapters/codex.rs index 3e464c7d4..4cb21c49c 100644 --- a/crates/tokscale-core/src/adapters/codex.rs +++ b/crates/tokscale-core/src/adapters/codex.rs @@ -112,16 +112,6 @@ fn apply_headless_agent(message: &mut UnifiedMessage, is_headless: bool) { } } -fn apply_pricing_to_messages( - messages: &mut [UnifiedMessage], - pricing: Option<&pricing::PricingService>, -) { - for message in messages { - message.refresh_derived_fields(); - crate::apply_pricing_if_available(message, pricing); - } -} - fn parse_full_log_source( unit: SourceUnit, pricing: Option<&pricing::PricingService>, @@ -178,7 +168,7 @@ fn finalize_codex_messages( message.set_timestamp(fallback_timestamp); } } - apply_pricing_to_messages(&mut messages, pricing); + crate::finalize_token_priced_messages(&mut messages, pricing); for message in &mut messages { apply_headless_agent(message, is_headless); } diff --git a/crates/tokscale-core/src/adapters/crush.rs b/crates/tokscale-core/src/adapters/crush.rs deleted file mode 100644 index 9b644631c..000000000 --- a/crates/tokscale-core/src/adapters/crush.rs +++ /dev/null @@ -1,181 +0,0 @@ -use std::path::{Path, PathBuf}; - -use rayon::prelude::*; -use serde::Deserialize; - -use crate::adapters::file::PricingPolicy; -use crate::adapters::{ - AdapterScanContext, FoldContext, LocalSourceAdapter, MessageSink, ParseContext, ParsedUnit, - SourceUnit, SourceUnitMeta, UnitMessageSource, -}; -use crate::clients::ClientId; -use crate::sessions; - -pub(crate) struct CrushAdapter; - -#[derive(Deserialize)] -struct CrushProjectList { - projects: Vec, -} - -#[derive(Deserialize)] -struct CrushProject { - path: String, - data_dir: String, -} - -impl LocalSourceAdapter for CrushAdapter { - fn client(&self) -> ClientId { - ClientId::Crush - } - - fn discover(&self, ctx: &AdapterScanContext<'_>) -> Vec { - let def = ClientId::Crush - .local_def() - .expect("Crush adapter must have local scan policy"); - let registry_path = - PathBuf::from(def.resolve_path_with_env_strategy(ctx.home_dir, ctx.use_env_roots)); - discover_crush_units(®istry_path) - } - - fn parse(&self, units: Vec, ctx: &ParseContext<'_>) -> Vec { - units - .into_par_iter() - .map(|unit| { - let mut messages = sessions::crush::parse_crush_sqlite(&unit.path); - let (workspace_key, workspace_label) = match &unit.meta { - SourceUnitMeta::Crush { - workspace_key, - workspace_label, - } => (workspace_key.clone(), workspace_label.clone()), - _ => unreachable!("unexpected Crush source unit meta"), - }; - for message in &mut messages { - message.set_workspace(workspace_key.clone(), workspace_label.clone()); - PricingPolicy::ApplyAlways.apply(message, ctx.pricing); - } - ParsedUnit { - unit, - messages: UnitMessageSource::Fresh(messages), - cache_entry: None, - invalidate_cache: false, - } - }) - .collect() - } - - fn fold( - &self, - parsed: Vec, - _ctx: &mut FoldContext<'_>, - sink: &mut dyn MessageSink, - ) { - for unit in parsed { - if let UnitMessageSource::Fresh(messages) = unit.messages { - sink.extend_messages(messages); - } - } - } -} - -fn discover_crush_units(registry_path: &Path) -> Vec { - let registry = match std::fs::read_to_string(registry_path) { - Ok(contents) => contents, - Err(_) => return Vec::new(), - }; - let list: CrushProjectList = match serde_json::from_str(®istry) { - Ok(list) => list, - Err(_) => return Vec::new(), - }; - - let mut units: Vec = list - .projects - .into_iter() - .filter_map(|project| serde_json::from_value::(project).ok()) - .filter_map(|project| { - let db_path = crush_db_path(&resolve_crush_data_dir(&project))?; - let workspace_key = sessions::normalize_workspace_key(&project.path); - let workspace_label = workspace_key - .as_deref() - .and_then(sessions::workspace_label_from_key); - Some( - SourceUnit::sqlite_with_wal(ClientId::Crush, db_path).with_meta( - SourceUnitMeta::Crush { - workspace_key, - workspace_label, - }, - ), - ) - }) - .collect(); - - units.sort_by(|left, right| left.path.cmp(&right.path)); - units.dedup_by(|left, right| left.path == right.path); - units -} - -fn resolve_crush_data_dir(project: &CrushProject) -> PathBuf { - let data_dir = PathBuf::from(&project.data_dir); - if data_dir.is_absolute() { - data_dir - } else { - PathBuf::from(&project.path).join(data_dir) - } -} - -fn crush_db_path(data_dir: &Path) -> Option { - let candidate = data_dir.join("crush.db"); - candidate.is_file().then_some(candidate) -} - -pub(crate) static CRUSH_ADAPTER: CrushAdapter = CrushAdapter; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn crush_adapter_discovers_registry_dbs_with_workspace_meta() { - let home = tempfile::TempDir::new().unwrap(); - let project_dir = home.path().join("work/project-a"); - let data_dir = project_dir.join(".crush-data"); - let db_path = data_dir.join("crush.db"); - std::fs::create_dir_all(&data_dir).unwrap(); - std::fs::write(&db_path, "").unwrap(); - - let registry_path = home.path().join(".local/share/crush/projects.json"); - std::fs::create_dir_all(registry_path.parent().unwrap()).unwrap(); - std::fs::write( - ®istry_path, - format!( - r#"{{"projects":[{{"path":"{}","data_dir":".crush-data"}}]}}"#, - project_dir.to_string_lossy() - ), - ) - .unwrap(); - let settings = crate::scanner::ScannerSettings::default(); - let ctx = AdapterScanContext { - home_dir: home.path().to_str().unwrap(), - use_env_roots: false, - scanner_settings: &settings, - }; - - let units = CRUSH_ADAPTER.discover(&ctx); - - assert_eq!(units.len(), 1); - assert_eq!(units[0].path, db_path); - match &units[0].meta { - SourceUnitMeta::Crush { - workspace_key, - workspace_label, - } => { - assert_eq!( - workspace_key.as_deref(), - Some(project_dir.to_string_lossy().as_ref()) - ); - assert_eq!(workspace_label.as_deref(), Some("project-a")); - } - other => panic!("unexpected Crush source meta: {other:?}"), - } - } -} diff --git a/crates/tokscale-core/src/adapters/file.rs b/crates/tokscale-core/src/adapters/file.rs index 25696a2a0..8ef09d3d7 100644 --- a/crates/tokscale-core/src/adapters/file.rs +++ b/crates/tokscale-core/src/adapters/file.rs @@ -21,46 +21,14 @@ pub(crate) struct CachedFileAdapter { parse: fn(&Path) -> Vec, } -#[derive(Clone, Copy)] -pub(crate) enum PricingPolicy { - ApplyAlways, - ApplyIfCostNonPositive, - Never, -} - -impl PricingPolicy { - pub(crate) fn apply( - self, - message: &mut UnifiedMessage, - pricing: Option<&crate::pricing::PricingService>, - ) { - match self { - PricingPolicy::ApplyAlways => crate::apply_pricing_if_available(message, pricing), - PricingPolicy::ApplyIfCostNonPositive if message.cost <= 0.0 => { - crate::apply_pricing_if_available(message, pricing); - } - PricingPolicy::ApplyIfCostNonPositive | PricingPolicy::Never => {} - } - } -} - pub(crate) struct NonCachedFileAdapter { client: ClientId, parse: fn(&Path) -> Vec, - pricing_policy: PricingPolicy, } impl NonCachedFileAdapter { - pub(crate) const fn new( - client: ClientId, - parse: fn(&Path) -> Vec, - pricing_policy: PricingPolicy, - ) -> Self { - Self { - client, - parse, - pricing_policy, - } + pub(crate) const fn new(client: ClientId, parse: fn(&Path) -> Vec) -> Self { + Self { client, parse } } } @@ -75,14 +43,11 @@ impl LocalSourceAdapter for NonCachedFileAdapter { fn parse(&self, units: Vec, ctx: &ParseContext<'_>) -> Vec { let parse = self.parse; - let pricing_policy = self.pricing_policy; units .into_par_iter() .map(|unit| { let mut messages = parse(&unit.path); - for message in &mut messages { - pricing_policy.apply(message, ctx.pricing); - } + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); ParsedUnit { unit, messages: UnitMessageSource::Fresh(messages), @@ -248,8 +213,6 @@ pub(crate) static GEMINI_ADAPTER: PolicyFileAdapter = PolicyFileAdapter::new(ClientId::Gemini, parse_gemini_file_with_policy); pub(crate) static GROK_ADAPTER: CachedFileAdapter = CachedFileAdapter::new(ClientId::Grok, sessions::grok::parse_grok_updates_file); -pub(crate) static WARP_ADAPTER: CachedFileAdapter = - CachedFileAdapter::new(ClientId::Warp, sessions::warp::parse_warp_file); pub(crate) static AMP_ADAPTER: CachedFileAdapter = CachedFileAdapter::new(ClientId::Amp, sessions::amp::parse_amp_file); pub(crate) static DROID_ADAPTER: CachedFileAdapter = @@ -267,7 +230,6 @@ pub(crate) static COMMANDCODE_ADAPTER: CachedFileAdapter = CachedFileAdapter::ne pub(crate) static ANTIGRAVITY_ADAPTER: NonCachedFileAdapter = NonCachedFileAdapter::new( ClientId::Antigravity, sessions::antigravity::parse_antigravity_file, - PricingPolicy::ApplyAlways, ); #[cfg(test)] diff --git a/crates/tokscale-core/src/adapters/gjc.rs b/crates/tokscale-core/src/adapters/gjc.rs index 15eda0ccb..66cd9591c 100644 --- a/crates/tokscale-core/src/adapters/gjc.rs +++ b/crates/tokscale-core/src/adapters/gjc.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; use rayon::prelude::*; use crate::adapters::discover as adapter_discover; -use crate::adapters::file::PricingPolicy; use crate::adapters::{ AdapterScanContext, FingerprintPolicy, FoldContext, LocalSourceAdapter, MessageSink, ParseContext, ParsedUnit, SourceUnit, UnitMessageSource, @@ -38,9 +37,7 @@ impl LocalSourceAdapter for GjcAdapter { .into_par_iter() .map(|unit| { let mut messages = sessions::gjc::parse_gjc_file(&unit.path); - for message in &mut messages { - PricingPolicy::ApplyIfCostNonPositive.apply(message, ctx.pricing); - } + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); ParsedUnit { unit, messages: UnitMessageSource::Fresh(messages), @@ -132,7 +129,7 @@ mod tests { } #[test] - fn gjc_adapter_preserves_embedded_cost_and_prices_missing_cost() { + fn gjc_adapter_ignores_embedded_cost_and_applies_token_pricing() { let dir = tempfile::TempDir::new().unwrap(); let path = dir.path().join("project/session.jsonl"); write_file( @@ -170,7 +167,7 @@ mod tests { .iter() .find(|message| message.dedup_key == Some(sessions::dedup_hash_str("gjc_ses:missing"))) .unwrap(); - assert_eq!(embedded.cost, 1.25); - assert!(missing.cost > 0.0); + assert_eq!(embedded.cost, 0.02); + assert_eq!(missing.cost, 0.02); } } diff --git a/crates/tokscale-core/src/adapters/goose.rs b/crates/tokscale-core/src/adapters/goose.rs index 60872bd85..8bd2b4752 100644 --- a/crates/tokscale-core/src/adapters/goose.rs +++ b/crates/tokscale-core/src/adapters/goose.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use rayon::prelude::*; use crate::adapters::discover as adapter_discover; -use crate::adapters::file::PricingPolicy; use crate::adapters::{ AdapterScanContext, FoldContext, LocalSourceAdapter, MessageSink, ParseContext, ParsedUnit, SourceUnit, UnitMessageSource, @@ -31,9 +30,7 @@ impl LocalSourceAdapter for GooseAdapter { .into_par_iter() .map(|unit| { let mut messages = sessions::goose::parse_goose_sqlite(&unit.path); - for message in &mut messages { - PricingPolicy::ApplyAlways.apply(message, ctx.pricing); - } + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); ParsedUnit { unit, messages: UnitMessageSource::Fresh(messages), diff --git a/crates/tokscale-core/src/adapters/hermes.rs b/crates/tokscale-core/src/adapters/hermes.rs index 2d428e3c2..639adf44d 100644 --- a/crates/tokscale-core/src/adapters/hermes.rs +++ b/crates/tokscale-core/src/adapters/hermes.rs @@ -3,7 +3,6 @@ use std::collections::HashSet; use rayon::prelude::*; use crate::adapters::discover as adapter_discover; -use crate::adapters::file::PricingPolicy; use crate::adapters::{ AdapterScanContext, FingerprintPolicy, FoldContext, LocalSourceAdapter, MessageSink, ParseContext, ParsedUnit, SourceUnit, UnitMessageSource, @@ -47,9 +46,7 @@ impl LocalSourceAdapter for HermesAdapter { .into_par_iter() .map(|unit| { let mut messages = sessions::hermes::parse_hermes_sqlite(&unit.path); - for message in &mut messages { - PricingPolicy::ApplyIfCostNonPositive.apply(message, ctx.pricing); - } + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); ParsedUnit { unit, messages: UnitMessageSource::Fresh(messages), diff --git a/crates/tokscale-core/src/adapters/kilo.rs b/crates/tokscale-core/src/adapters/kilo.rs index 8ff6d578c..2adfd28e7 100644 --- a/crates/tokscale-core/src/adapters/kilo.rs +++ b/crates/tokscale-core/src/adapters/kilo.rs @@ -1,7 +1,6 @@ use rayon::prelude::*; use crate::adapters::discover as adapter_discover; -use crate::adapters::file::PricingPolicy; use crate::adapters::{ AdapterScanContext, FoldContext, LocalSourceAdapter, MessageSink, ParseContext, ParsedUnit, SourceUnit, UnitMessageSource, @@ -38,9 +37,7 @@ impl LocalSourceAdapter for KiloAdapter { .into_par_iter() .map(|unit| { let mut messages = sessions::kilo::parse_kilo_sqlite(&unit.path); - for message in &mut messages { - PricingPolicy::ApplyAlways.apply(message, ctx.pricing); - } + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); ParsedUnit { unit, messages: UnitMessageSource::Fresh(messages), diff --git a/crates/tokscale-core/src/adapters/kiro.rs b/crates/tokscale-core/src/adapters/kiro.rs index 773d98f82..53386958f 100644 --- a/crates/tokscale-core/src/adapters/kiro.rs +++ b/crates/tokscale-core/src/adapters/kiro.rs @@ -4,7 +4,6 @@ use rayon::prelude::*; use crate::adapters::cache as adapter_cache; use crate::adapters::discover as adapter_discover; -use crate::adapters::file::PricingPolicy; use crate::adapters::{ AdapterScanContext, FingerprintPolicy, FoldContext, LocalSourceAdapter, MessageSink, ParseContext, ParsedUnit, SourceUnit, SourceUnitMeta, UnitMessageSource, @@ -63,9 +62,7 @@ impl LocalSourceAdapter for KiroAdapter { ), SourceUnitMeta::KiroSqlite => { let mut messages = sessions::kiro::parse_kiro_sqlite(&unit.path); - for message in &mut messages { - PricingPolicy::ApplyAlways.apply(message, ctx.pricing); - } + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); ParsedUnit { unit, messages: UnitMessageSource::Fresh(messages), @@ -79,7 +76,6 @@ impl LocalSourceAdapter for KiroAdapter { sessions::kiro::parse_kiro_file, ), SourceUnitMeta::None - | SourceUnitMeta::Crush { .. } | SourceUnitMeta::OpenCodeSqlite | SourceUnitMeta::OpenCodeJson | SourceUnitMeta::Codex { .. } => unreachable!("unexpected Kiro source unit meta"), diff --git a/crates/tokscale-core/src/adapters/mod.rs b/crates/tokscale-core/src/adapters/mod.rs index a1325f403..cd99d0649 100644 --- a/crates/tokscale-core/src/adapters/mod.rs +++ b/crates/tokscale-core/src/adapters/mod.rs @@ -3,7 +3,6 @@ pub(crate) mod cache; mod claude; mod codebuff; mod codex; -mod crush; pub(crate) mod discover; pub(crate) mod file; mod gjc; @@ -145,10 +144,6 @@ impl SourceUnit { pub(crate) enum SourceUnitMeta { #[default] None, - Crush { - workspace_key: Option, - workspace_label: Option, - }, OpenCodeSqlite, OpenCodeJson, KiroFile, @@ -190,7 +185,7 @@ pub(crate) struct ParsedUnit { pub invalidate_cache: bool, } -static LOCAL_SOURCE_ADAPTERS: [&dyn LocalSourceAdapter; 32] = [ +static LOCAL_SOURCE_ADAPTERS: [&dyn LocalSourceAdapter; 30] = [ &zed::ZED_ADAPTER, &pi::PI_ADAPTER, &omp::OMP_ADAPTER, @@ -201,7 +196,6 @@ static LOCAL_SOURCE_ADAPTERS: [&dyn LocalSourceAdapter; 32] = [ &file::CURSOR_ADAPTER, &file::GEMINI_ADAPTER, &file::GROK_ADAPTER, - &file::WARP_ADAPTER, &file::AMP_ADAPTER, &file::DROID_ADAPTER, &file::KIMI_ADAPTER, @@ -222,14 +216,13 @@ static LOCAL_SOURCE_ADAPTERS: [&dyn LocalSourceAdapter; 32] = [ &goose::GOOSE_ADAPTER, &kiro::KIRO_ADAPTER, &file::COMMANDCODE_ADAPTER, - &crush::CRUSH_ADAPTER, ]; pub(crate) fn local_source_adapters() -> &'static [&'static dyn LocalSourceAdapter] { &LOCAL_SOURCE_ADAPTERS } -#[allow(dead_code)] +#[cfg(test)] pub(crate) fn adapter_for(client: ClientId) -> Option<&'static dyn LocalSourceAdapter> { local_source_adapters() .iter() @@ -283,3 +276,16 @@ fn append_path_suffix(path: &std::path::Path, suffix: &str) -> PathBuf { os.push(suffix); PathBuf::from(os) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_cost_only_clients_are_not_registered_as_local_adapters() { + assert!(adapter_for(ClientId::Warp).is_none()); + assert!(adapter_for(ClientId::Crush).is_none()); + assert!(selected_adapters(&["warp".to_string()]).is_empty()); + assert!(selected_adapters(&["crush".to_string()]).is_empty()); + } +} diff --git a/crates/tokscale-core/src/adapters/opencode.rs b/crates/tokscale-core/src/adapters/opencode.rs index bd43f203e..e1cf267c6 100644 --- a/crates/tokscale-core/src/adapters/opencode.rs +++ b/crates/tokscale-core/src/adapters/opencode.rs @@ -85,7 +85,6 @@ impl LocalSourceAdapter for OpenCodeAdapter { }) } SourceUnitMeta::None - | SourceUnitMeta::Crush { .. } | SourceUnitMeta::KiroFile | SourceUnitMeta::KiroSqlite | SourceUnitMeta::KiroGlobalStorage @@ -211,7 +210,7 @@ mod tests { output: 5, ..Default::default() }, - 1.0, + 0.0, Some(key), ); let json_message = UnifiedMessage::new_with_dedup( @@ -225,7 +224,7 @@ mod tests { output: 5, ..Default::default() }, - 2.0, + 0.0, Some(key), ); let parsed = vec![ @@ -258,6 +257,5 @@ mod tests { assert_eq!(sink.len(), 1); assert_eq!(sink[0].session_id.as_ref(), "sqlite-session"); - assert_eq!(sink[0].cost, 1.0); } } diff --git a/crates/tokscale-core/src/adapters/trae.rs b/crates/tokscale-core/src/adapters/trae.rs index 5db841741..3711c06a6 100644 --- a/crates/tokscale-core/src/adapters/trae.rs +++ b/crates/tokscale-core/src/adapters/trae.rs @@ -1,7 +1,6 @@ use rayon::prelude::*; use crate::adapters::discover as adapter_discover; -use crate::adapters::file::PricingPolicy; use crate::adapters::{ AdapterScanContext, FingerprintPolicy, FoldContext, LocalSourceAdapter, MessageSink, ParseContext, ParsedUnit, SourceUnit, UnitMessageSource, @@ -29,9 +28,7 @@ impl LocalSourceAdapter for TraeAdapter { .into_par_iter() .map(|unit| { let mut messages = sessions::trae::parse_trae_file("trae", &unit.path); - for message in &mut messages { - PricingPolicy::Never.apply(message, ctx.pricing); - } + crate::finalize_token_priced_messages(&mut messages, ctx.pricing); ParsedUnit { unit, messages: UnitMessageSource::Fresh(messages), @@ -86,7 +83,7 @@ mod tests { } #[test] - fn trae_adapter_dedupes_latest_session_and_never_reprices() { + fn trae_adapter_dedupes_latest_session_and_applies_token_pricing() { let dir = tempfile::TempDir::new().unwrap(); let older = dir.path().join("older.json"); let newer = dir.path().join("newer.json"); @@ -123,6 +120,6 @@ mod tests { assert_eq!(sink.len(), 1); assert_eq!(sink[0].timestamp, 1_776_000_001_000); - assert_eq!(sink[0].cost, 0.2); + assert_eq!(sink[0].cost, 110.0); } } diff --git a/crates/tokscale-core/src/aggregate/engine.rs b/crates/tokscale-core/src/aggregate/engine.rs index 3c1c6ea3a..f14c0d051 100644 --- a/crates/tokscale-core/src/aggregate/engine.rs +++ b/crates/tokscale-core/src/aggregate/engine.rs @@ -91,7 +91,7 @@ impl AggregationEngine { let total_cost: f64 = entries.iter().map(|e| e.cost).sum(); MonthlyReport { entries, - total_cost, + total_cost: clean_total_cost(total_cost), processing_time_ms: 0, } }); @@ -101,7 +101,7 @@ impl AggregationEngine { let total_cost: f64 = entries.iter().map(|e| e.cost).sum(); HourlyReport { entries, - total_cost, + total_cost: clean_total_cost(total_cost), processing_time_ms: 0, } }); @@ -149,7 +149,16 @@ fn wrap_model_report(entries: Vec) -> ModelReport { total_cache_read, total_cache_write, total_messages, - total_cost, + total_cost: clean_total_cost(total_cost), processing_time_ms: 0, } } + +/// Normalize `-0.0` to `0.0` so serialized reports do not display negative zero. +fn clean_total_cost(cost: f64) -> f64 { + if cost == 0.0 { + 0.0 + } else { + cost + } +} diff --git a/crates/tokscale-core/src/aggregate/tui.rs b/crates/tokscale-core/src/aggregate/tui.rs index 0e365e181..0edde7372 100644 --- a/crates/tokscale-core/src/aggregate/tui.rs +++ b/crates/tokscale-core/src/aggregate/tui.rs @@ -68,7 +68,7 @@ fn hourly_model_display_name(group_by: &GroupBy, model: &str) -> String { /// Sanitize a message cost: non-finite/negative -> 0 (the TUI never shows debt). fn sane_cost(cost: f64) -> f64 { - if cost.is_finite() && cost >= 0.0 { + if cost.is_finite() && cost > 0.0 { cost } else { 0.0 @@ -753,7 +753,7 @@ impl TuiAcc { hourly, graph: Some(graph), total_tokens, - total_cost, + total_cost: sane_cost(total_cost), loading: false, error: None, current_streak, diff --git a/crates/tokscale-core/src/aggregator.rs b/crates/tokscale-core/src/aggregator.rs index de26343c9..0e8896948 100644 --- a/crates/tokscale-core/src/aggregator.rs +++ b/crates/tokscale-core/src/aggregator.rs @@ -103,15 +103,17 @@ pub(crate) fn aggregate_by_session(messages: Vec) -> Vec DataSummary { let total_tokens: i64 = contributions.iter().map(|c| c.totals.tokens).sum(); - let total_cost: f64 = contributions.iter().map(|c| c.totals.cost).sum(); + let total_cost = clean_total_cost(contributions.iter().map(|c| c.totals.cost).sum()); let active_days = contributions .iter() .filter(|c| c.totals.tokens > 0 || c.totals.cost > 0.0 || c.totals.messages > 0) .count() as i32; - let max_cost = contributions - .iter() - .map(|c| c.totals.cost) - .fold(0.0, f64::max); + let max_cost = clean_total_cost( + contributions + .iter() + .map(|c| c.totals.cost) + .fold(0.0, f64::max), + ); let mut clients_set = std::collections::HashSet::with_capacity(5); let mut models_set = std::collections::HashSet::with_capacity(20); @@ -147,6 +149,15 @@ pub fn calculate_summary(contributions: &[DailyContribution]) -> DataSummary { } } +/// Normalize `-0.0` to `0.0` so serialized reports do not display negative zero. +fn clean_total_cost(cost: f64) -> f64 { + if cost == 0.0 { + 0.0 + } else { + cost + } +} + /// Calculate year summaries pub fn calculate_years(contributions: &[DailyContribution]) -> Vec { let mut years_map: HashMap = HashMap::with_capacity(5); diff --git a/crates/tokscale-core/src/lib.rs b/crates/tokscale-core/src/lib.rs index b07ca89ca..7963104db 100644 --- a/crates/tokscale-core/src/lib.rs +++ b/crates/tokscale-core/src/lib.rs @@ -1064,11 +1064,32 @@ fn aggregate_model_usage_entries( } pub(crate) fn positive_token_total(tokens: &TokenBreakdown) -> i64 { - tokens.input.max(0) - + tokens.output.max(0) - + tokens.cache_read.max(0) - + tokens.cache_write.max(0) - + tokens.reasoning.max(0) + [ + tokens.input, + tokens.output, + tokens.cache_read, + tokens.cache_write, + tokens.reasoning, + ] + .into_iter() + .map(|value| value.max(0)) + .fold(0, i64::saturating_add) +} + +pub(crate) fn has_positive_tokens(tokens: &TokenBreakdown) -> bool { + tokens.input > 0 + || tokens.output > 0 + || tokens.cache_read > 0 + || tokens.cache_write > 0 + || tokens.reasoning > 0 +} + +fn normalize_token_breakdown(tokens: &mut TokenBreakdown) { + tokens.input = tokens.input.max(0); + tokens.output = tokens.output.max(0); + tokens.cache_read = tokens.cache_read.max(0); + tokens.cache_write = tokens.cache_write.max(0); + tokens.reasoning = tokens.reasoning.max(0); } fn resolve_report_request(options: &ReportOptions) -> Result<(String, Vec), String> { @@ -1300,46 +1321,37 @@ pub(crate) fn hourly_report_from_messages_pub(messages: Vec) -> report } -fn pricing_multiplier(message: &UnifiedMessage) -> f64 { - // Zed bills hosted models at provider list price + 10%. - // Source: https://zed.dev/docs/ai/plans-and-usage and https://zed.dev/docs/ai/models - // - // The multiplier is keyed on the message's `provider_id`, not on the - // provenance of the matched LiteLLM pricing row. Today this is safe because - // tokscale's bundled LiteLLM dataset only carries upstream-provider rows - // (anthropic, openai, google) for the underlying models. If a future - // LiteLLM update adds rows under provider `zed.dev` that already include - // Zed's markup, this function would double-bill — revisit by threading - // the matched-price provenance through `apply_pricing_if_available`. - if message.client.as_ref() == "zed" - && message - .provider_id - .eq_ignore_ascii_case(sessions::zed::ZED_HOSTED_PROVIDER) - { - 1.1 - } else { - 1.0 - } -} +fn apply_token_pricing(message: &mut UnifiedMessage, pricing: Option<&pricing::PricingService>) { + message.cost = 0.0; -fn apply_pricing_if_available( - message: &mut UnifiedMessage, - pricing: Option<&pricing::PricingService>, -) { let Some(pricing) = pricing else { return; }; let provider_hint = pricing_provider_hint(&message.model_id, &message.provider_id); let calculated_cost = - pricing.calculate_cost_with_provider(&message.model_id, provider_hint, &message.tokens) - * pricing_multiplier(message); + pricing.calculate_cost_with_provider(&message.model_id, provider_hint, &message.tokens); if calculated_cost > 0.0 { message.cost = calculated_cost; } } +pub(crate) fn finalize_token_priced_messages( + messages: &mut Vec, + pricing: Option<&pricing::PricingService>, +) { + messages.retain_mut(|message| { + normalize_token_breakdown(&mut message.tokens); + message.refresh_derived_fields(); + if !has_positive_tokens(&message.tokens) { + return false; + } + apply_token_pricing(message, pricing); + true + }); +} + fn pricing_provider_hint<'a>(model_id: &str, provider_id: &'a str) -> Option<&'a str> { let trimmed = provider_id.trim(); if trimmed.is_empty() { @@ -1671,15 +1683,16 @@ pub fn parsed_to_unified(msg: &ParsedMessage, cost: f64) -> UnifiedMessage { #[cfg(test)] mod tests { use super::{ - aggregate_model_usage_entries, apply_pricing_if_available, dedupe_latest_trae_messages, - generate_graph_with_loaded_pricing, load_aggregated_views_with_pricing, - load_cache_only_pricing_with_diagnostics, load_usage_data_with_pricing, message_cache, - normalize_model_for_grouping, parse_all_messages_with_pricing, - parse_all_messages_with_pricing_with_env_strategy, parse_local_clients, parsed_to_unified, - pricing, retain_for_requested_clients, scanner, select_local_parse_pricing, - unified_to_parsed, AggregatedViews, AggregationConfig, ClientId, DateRange, GraphResult, - GroupBy, LocalParseOptions, ReportOptions, TimeMetricsReport, TokenBreakdown, - UnifiedMessage, ViewSet, UNKNOWN_WORKSPACE_LABEL, + aggregate_model_usage_entries, apply_token_pricing, dedupe_latest_trae_messages, + finalize_token_priced_messages, generate_graph_with_loaded_pricing, + load_aggregated_views_with_pricing, load_cache_only_pricing_with_diagnostics, + load_usage_data_with_pricing, message_cache, normalize_model_for_grouping, + parse_all_messages_with_pricing, parse_all_messages_with_pricing_with_env_strategy, + parse_local_clients, parsed_to_unified, positive_token_total, pricing, + retain_for_requested_clients, scanner, select_local_parse_pricing, unified_to_parsed, + AggregatedViews, AggregationConfig, ClientId, DateRange, GraphResult, GroupBy, + LocalParseOptions, ReportOptions, TimeMetricsReport, TokenBreakdown, UnifiedMessage, + ViewSet, UNKNOWN_WORKSPACE_LABEL, }; use std::collections::{BTreeMap, HashMap, HashSet}; use std::ffi::OsString; @@ -4092,7 +4105,7 @@ model = "gpt-5.5" assert_eq!(messages.len(), 3); assert_eq!(messages.iter().map(|m| m.tokens.input).sum::(), 600); assert_eq!(messages.iter().map(|m| m.tokens.output).sum::(), 250); - assert_eq!(messages.iter().map(|m| m.cost).sum::(), 0.06); + assert_eq!(messages.iter().map(|m| m.cost).sum::(), 0.0); } match original_home { @@ -5096,7 +5109,7 @@ model = "gpt-5.5" } #[test] - fn test_apply_pricing_if_available_keeps_existing_cost_without_pricing() { + fn test_apply_token_pricing_clears_existing_cost_without_pricing() { let mut msg = UnifiedMessage::new_with_agent( "roocode", "gpt-4o", @@ -5114,13 +5127,90 @@ model = "gpt-5.5" Some("planner".to_string()), ); - apply_pricing_if_available(&mut msg, None); + apply_token_pricing(&mut msg, None); + + assert_eq!(msg.cost, 0.0); + } + + #[test] + fn test_finalize_token_priced_messages_drops_rows_without_positive_tokens() { + let mut litellm = HashMap::new(); + litellm.insert( + "gpt-4o".into(), + pricing::ModelPricing { + input_cost_per_token: Some(0.001), + output_cost_per_token: Some(0.002), + ..Default::default() + }, + ); + let pricing = pricing::PricingService::new(litellm, HashMap::new()); + + let mut messages = vec![ + UnifiedMessage::new( + "gemini", + "gpt-4o", + "openai", + "zero", + 1_733_011_200_000, + TokenBreakdown::default(), + 0.42, + ), + UnifiedMessage::new( + "gemini", + "gpt-4o", + "openai", + "negative", + 1_733_011_200_000, + TokenBreakdown { + input: -10, + output: -5, + cache_read: 0, + cache_write: 0, + reasoning: 0, + }, + 0.42, + ), + UnifiedMessage::new( + "gemini", + "gpt-4o", + "openai", + "mixed", + 1_733_011_200_000, + TokenBreakdown { + input: -10, + output: 5, + cache_read: 0, + cache_write: 0, + reasoning: 0, + }, + 0.42, + ), + ]; + + finalize_token_priced_messages(&mut messages, Some(&pricing)); + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].session_id.as_ref(), "mixed"); + assert_eq!(messages[0].tokens.input, 0); + assert_eq!(messages[0].tokens.output, 5); + assert_eq!(messages[0].cost, 0.01); + } + + #[test] + fn test_positive_token_total_saturates() { + let tokens = TokenBreakdown { + input: i64::MAX, + output: i64::MAX, + cache_read: i64::MAX, + cache_write: i64::MAX, + reasoning: i64::MAX, + }; - assert_eq!(msg.cost, 0.42); + assert_eq!(positive_token_total(&tokens), i64::MAX); } #[test] - fn test_apply_pricing_if_available_overrides_cost_when_pricing_exists() { + fn test_apply_token_pricing_overrides_cost_when_pricing_exists() { let mut litellm = HashMap::new(); litellm.insert( "gpt-4o".into(), @@ -5148,13 +5238,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.02); } #[test] - fn test_apply_pricing_if_available_resolves_longcat_quant_variant() { + fn test_apply_token_pricing_resolves_longcat_quant_variant() { let mut litellm = HashMap::new(); litellm.insert( "longcat-flash-3b".into(), @@ -5182,13 +5272,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.02); } #[test] - fn test_apply_pricing_if_available_applies_zed_hosted_markup() { + fn test_apply_token_pricing_uses_same_price_for_zed_and_other_clients() { let mut litellm = HashMap::new(); litellm.insert( "claude-sonnet-4-5".into(), @@ -5200,44 +5290,58 @@ model = "gpt-5.5" ); let pricing = pricing::PricingService::new(litellm, HashMap::new()); - let mut msg = UnifiedMessage::new( + let tokens = TokenBreakdown { + input: 10, + output: 5, + cache_read: 0, + cache_write: 0, + reasoning: 0, + }; + let mut zed_msg = UnifiedMessage::new( "zed", "claude-sonnet-4-5", crate::sessions::zed::ZED_HOSTED_PROVIDER, "session-1", 1_733_011_200_000, - TokenBreakdown { - input: 10, - output: 5, - cache_read: 0, - cache_write: 0, - reasoning: 0, - }, + tokens.clone(), + 0.0, + ); + let mut claude_msg = UnifiedMessage::new( + "claudecode", + "claude-sonnet-4-5", + crate::sessions::zed::ZED_HOSTED_PROVIDER, + "session-1", + 1_733_011_200_000, + tokens, 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut zed_msg, Some(&pricing)); + apply_token_pricing(&mut claude_msg, Some(&pricing)); - assert!((msg.cost - 0.022).abs() < 1e-12); + assert_eq!(zed_msg.cost, claude_msg.cost); + assert!((zed_msg.cost - 0.020).abs() < 1e-12); } #[test] - fn test_apply_pricing_if_available_skips_zed_markup_for_non_zed_client() { - // Non-zed client with provider_id "zed.dev" must not receive the +10% - // markup. The multiplier is gated on (client == "zed" AND provider). - let mut litellm = HashMap::new(); - litellm.insert( + fn test_apply_token_pricing_custom_zed_price_is_final_price() { + let mut custom = HashMap::new(); + custom.insert( "claude-sonnet-4-5".into(), pricing::ModelPricing { - input_cost_per_token: Some(0.001), - output_cost_per_token: Some(0.002), + input_cost_per_token: Some(0.003), + output_cost_per_token: Some(0.004), ..Default::default() }, ); - let pricing = pricing::PricingService::new(litellm, HashMap::new()); + let pricing = pricing::PricingService::new_with_custom( + pricing::custom::CustomPricing::from_models(custom), + HashMap::new(), + HashMap::new(), + ); let mut msg = UnifiedMessage::new( - "claudecode", + "zed", "claude-sonnet-4-5", crate::sessions::zed::ZED_HOSTED_PROVIDER, "session-1", @@ -5252,17 +5356,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); - // 10 * 0.001 + 5 * 0.002 = 0.020, no markup. - assert!((msg.cost - 0.020).abs() < 1e-12); + assert!((msg.cost - 0.050).abs() < 1e-12); } #[test] - fn test_apply_pricing_if_available_skips_zed_markup_for_byok_provider() { - // A Zed message whose provider_id is the upstream provider directly - // (BYOK / non-hosted path) must not be marked up — the user is paying - // the upstream API directly, not through Zed. + fn test_apply_token_pricing_uses_upstream_provider_for_zed_byok() { let mut litellm = HashMap::new(); litellm.insert( "claude-sonnet-4-5".into(), @@ -5290,13 +5390,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert!((msg.cost - 0.020).abs() < 1e-12); } #[test] - fn test_apply_pricing_if_available_uses_reasoning_for_gemini() { + fn test_apply_token_pricing_uses_reasoning_for_gemini() { let mut litellm = HashMap::new(); litellm.insert( "gemini-2.5-pro".into(), @@ -5324,13 +5424,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.034); } #[test] - fn test_apply_pricing_if_available_uses_cache_read_pricing_for_gemini() { + fn test_apply_token_pricing_uses_cache_read_pricing_for_gemini() { let mut litellm = HashMap::new(); litellm.insert( "gemini-2.5-pro".into(), @@ -5359,13 +5459,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.0267); } #[test] - fn test_apply_pricing_if_available_uses_market_rate_for_free_variant() { + fn test_apply_token_pricing_uses_market_rate_for_free_variant() { let mut openrouter = HashMap::new(); openrouter.insert( "z-ai/glm-4.7".into(), @@ -5393,13 +5493,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.02); } #[test] - fn test_apply_pricing_if_available_prefers_provider_aware_match() { + fn test_apply_token_pricing_prefers_provider_aware_match() { let mut litellm = HashMap::new(); litellm.insert( "xai/grok-code-fast-1-0825".into(), @@ -5435,13 +5535,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.2); } #[test] - fn test_apply_pricing_if_available_uses_nested_reseller_exact_match() { + fn test_apply_token_pricing_uses_nested_reseller_exact_match() { let mut litellm = HashMap::new(); litellm.insert( "gpt-4".into(), @@ -5477,13 +5577,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.2); } #[test] - fn test_apply_pricing_if_available_keeps_scoped_fireworks_cost_without_exact_pricing() { + fn test_apply_token_pricing_clears_cost_without_exact_pricing() { let mut litellm = HashMap::new(); litellm.insert( "fireworks_ai/accounts/fireworks/models/deepseek-r1-0528-distill-qwen3-8b".into(), @@ -5521,13 +5621,13 @@ model = "gpt-5.5" 0.123, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); - assert_eq!(msg.cost, 0.123); + assert_eq!(msg.cost, 0.0); } #[test] - fn test_apply_pricing_if_available_prefers_provider_specific_exact_match_over_plain_exact() { + fn test_apply_token_pricing_prefers_provider_specific_exact_match_over_plain_exact() { let mut litellm = HashMap::new(); litellm.insert( "gemini-2.5-pro".into(), @@ -5568,13 +5668,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.05); } #[test] - fn test_apply_pricing_if_available_normalizes_openai_codex_provider() { + fn test_apply_token_pricing_normalizes_openai_codex_provider() { let mut litellm = HashMap::new(); litellm.insert( "openai/gpt-5.2-preview".into(), @@ -5610,13 +5710,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.2); } #[test] - fn test_apply_pricing_if_available_normalizes_openai_pro_provider() { + fn test_apply_token_pricing_normalizes_openai_pro_provider() { let mut litellm = HashMap::new(); litellm.insert( "openai/gpt-5.2-preview".into(), @@ -5644,13 +5744,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.2); } #[test] - fn test_apply_pricing_if_available_prices_owl_gpt_as_openai() { + fn test_apply_token_pricing_prices_owl_gpt_as_openai() { let mut litellm = HashMap::new(); litellm.insert( "openai/gpt-5.2-preview".into(), @@ -5678,13 +5778,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.2); } #[test] - fn test_apply_pricing_if_available_prices_owl_claude_as_anthropic() { + fn test_apply_token_pricing_prices_owl_claude_as_anthropic() { let mut litellm = HashMap::new(); litellm.insert( "anthropic/claude-sonnet-4-5".into(), @@ -5712,13 +5812,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.2); } #[test] - fn test_apply_pricing_if_available_prices_owl_minimax_as_minimax() { + fn test_apply_token_pricing_prices_owl_minimax_as_minimax() { let mut litellm = HashMap::new(); litellm.insert( "minimax/minimax-m2.1".into(), @@ -5746,13 +5846,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.2); } #[test] - fn test_apply_pricing_if_available_prices_claude_code_gpt_5_3_codex() { + fn test_apply_token_pricing_prices_claude_code_gpt_5_3_codex() { let pricing = pricing::PricingService::new(HashMap::new(), HashMap::new()); let mut msg = UnifiedMessage::new( @@ -5771,14 +5871,14 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); let expected = 1.75 + 1.4 + 0.00875; assert!((msg.cost - expected).abs() < 1e-12); } #[test] - fn test_apply_pricing_if_available_prices_claude_code_minimax_model() { + fn test_apply_token_pricing_prices_claude_code_minimax_model() { let mut litellm = HashMap::new(); litellm.insert( "minimax/minimax-m2.1".into(), @@ -5806,13 +5906,13 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); assert_eq!(msg.cost, 0.2); } #[test] - fn test_apply_pricing_if_available_prices_kimi_k2p6_alias() { + fn test_apply_token_pricing_prices_kimi_k2p6_alias() { let mut openrouter = HashMap::new(); openrouter.insert( "moonshotai/kimi-k2.6".into(), @@ -5840,7 +5940,7 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(&pricing)); + apply_token_pricing(&mut msg, Some(&pricing)); let expected = 1_000_000.0 * 9.5e-7 + 250_000.0 * 0.000004; assert!((msg.cost - expected).abs() < 1e-12); @@ -5878,7 +5978,7 @@ model = "gpt-5.5" 0.0, ); - apply_pricing_if_available(&mut msg, Some(selected.as_ref())); + apply_token_pricing(&mut msg, Some(selected.as_ref())); assert!(msg.cost > 0.0); } diff --git a/crates/tokscale-core/src/local_clients.rs b/crates/tokscale-core/src/local_clients.rs index 5deff7c0c..3e7b77cf6 100644 --- a/crates/tokscale-core/src/local_clients.rs +++ b/crates/tokscale-core/src/local_clients.rs @@ -275,7 +275,7 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ relative_path: "crush/projects.json", pattern: "projects.json", headless: false, - parse_local: true, + parse_local: false, }, }, LocalClientEntry { @@ -394,7 +394,7 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ relative_path: "warp-cache", pattern: "usage*.json", headless: false, - parse_local: true, + parse_local: false, }, }, LocalClientEntry { @@ -503,6 +503,17 @@ mod tests { assert!(!ClientId::Cursor.parse_local()); } + #[test] + fn cost_only_clients_are_registered_but_not_locally_parsed() { + let crush = ClientId::Crush.local_def().expect("crush has scan policy"); + assert_eq!(crush.relative_path, "crush/projects.json"); + assert!(!ClientId::Crush.parse_local()); + + let warp = ClientId::Warp.local_def().expect("warp has scan policy"); + assert_eq!(warp.relative_path, "warp-cache"); + assert!(!ClientId::Warp.parse_local()); + } + #[test] fn omp_client_keeps_independent_pi_format_path() { let def = ClientId::Omp diff --git a/crates/tokscale-core/src/message_cache.rs b/crates/tokscale-core/src/message_cache.rs index ca00a8005..e2cb19225 100644 --- a/crates/tokscale-core/src/message_cache.rs +++ b/crates/tokscale-core/src/message_cache.rs @@ -24,7 +24,9 @@ use std::time::UNIX_EPOCH; // and Phase B shrinks per-message data; serialized layout changed. // 25: source-message cache is sharded one source per file; v24 monolith is not // migrated and is deleted on first v25 load. -const CACHE_SCHEMA_VERSION: u32 = 25; +// 26: local source costs are token-derived only; cached app-reported costs and +// cost-only rows must be rebuilt. +const CACHE_SCHEMA_VERSION: u32 = 26; const CACHE_FILENAME: &str = "source-message-cache.bin"; const CACHE_LOCK_FILENAME: &str = "source-message-cache.lock"; const SHARDS_DIRNAME: &str = "shards"; diff --git a/crates/tokscale-core/src/scanner.rs b/crates/tokscale-core/src/scanner.rs index cfbf9f9cd..67c10297a 100644 --- a/crates/tokscale-core/src/scanner.rs +++ b/crates/tokscale-core/src/scanner.rs @@ -8,10 +8,8 @@ use std::path::{Path, PathBuf}; use walkdir::WalkDir; use crate::clients::ClientId; -use crate::sessions::{normalize_workspace_key, workspace_label_from_key}; use crate::LocalClientDef; use serde::{Deserialize, Serialize}; -use serde_json::Value; /// Emit a one-time `tracing::warn!` if `path` does not start with the user's /// home directory. The scan is NOT blocked — this is a heads-up only. @@ -34,6 +32,10 @@ fn local_def(client_id: ClientId) -> &'static LocalClientDef { .expect("scanner client must have local scan policy") } +fn scanner_enabled_client(client: ClientId) -> bool { + !matches!(client, ClientId::Crush | ClientId::Warp) +} + /// User-controlled scanner settings loaded from a config file. /// /// This is the persistent, declarative counterpart to environment variables @@ -69,13 +71,6 @@ pub struct ScannerSettings { pub extra_scan_paths: BTreeMap>, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CrushDbSource { - pub db_path: PathBuf, - pub workspace_key: Option, - pub workspace_label: Option, -} - /// Result of scanning all session directories #[derive(Debug)] pub struct ScanResult { @@ -93,7 +88,6 @@ pub struct ScanResult { pub goose_db: Option, pub zed_db: Option, pub kiro_db: Option, - pub crush_dbs: Vec, /// Path to the OpenCode legacy JSON directory (for migration cache stat checks) pub opencode_json_dir: Option, } @@ -108,7 +102,6 @@ impl Default for ScanResult { goose_db: None, zed_db: None, kiro_db: None, - crush_dbs: Vec::new(), opencode_json_dir: None, } } @@ -422,18 +415,6 @@ pub fn built_in_extra_scan_paths_for( paths } -#[derive(Debug, Deserialize, Default)] -struct CrushProjectList { - #[serde(default)] - projects: Vec, -} - -#[derive(Debug, Deserialize)] -struct CrushProject { - path: String, - data_dir: String, -} - /// Discover every OpenCode SQLite database under the opencode data dir. /// /// Matches: @@ -504,57 +485,6 @@ fn is_opencode_db_filename(name: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) } -fn crush_db_path(data_dir: &Path) -> Option { - let candidate = data_dir.join("crush.db"); - candidate.is_file().then_some(candidate) -} - -fn resolve_crush_data_dir(project: &CrushProject) -> PathBuf { - let data_dir = PathBuf::from(&project.data_dir); - if data_dir.is_absolute() { - data_dir - } else { - PathBuf::from(&project.path).join(data_dir) - } -} - -fn scan_crush_registry(registry_path: &Path) -> Vec { - let registry = match std::fs::read_to_string(registry_path) { - Ok(contents) => contents, - Err(_) => return Vec::new(), - }; - - let list: CrushProjectList = match serde_json::from_str(®istry) { - Ok(list) => list, - Err(_) => return Vec::new(), - }; - - list.projects - .into_iter() - .filter_map(|project| serde_json::from_value::(project).ok()) - .filter_map(|project| { - let db_path = crush_db_path(&resolve_crush_data_dir(&project))?; - let workspace_key = normalize_workspace_key(&project.path); - let workspace_label = workspace_key.as_deref().and_then(workspace_label_from_key); - Some(CrushDbSource { - db_path, - workspace_key, - workspace_label, - }) - }) - .collect() -} - -fn discover_crush_dbs(home_dir: &str, use_env_roots: bool) -> Vec { - let registry_path = PathBuf::from( - local_def(ClientId::Crush).resolve_path_with_env_strategy(home_dir, use_env_roots), - ); - let mut dbs = scan_crush_registry(®istry_path); - dbs.sort_by(|a, b| a.db_path.cmp(&b.db_path)); - dbs.dedup_by(|a, b| a.db_path == b.db_path); - dbs -} - fn cline_additional_vscode_task_roots(home_dir: &str, use_env_roots: bool) -> Vec { let mut roots = vec![PathBuf::from(home_dir) .join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks")]; @@ -581,15 +511,11 @@ fn cline_additional_vscode_task_roots(home_dir: &str, use_env_roots: bool) -> Ve } fn supports_extra_dir_scanning(client_id: ClientId) -> bool { - // Kilo CLI currently loads a single SQLite DB via `scan_result.kilo_db` - // Roo/KiloCode require local + remote and server task roots, and Crush - // discovers SQLite DBs via the project registry rather than scanned file - // paths. Hermes/Zed profile databases are named consistently enough for - // `scan_directory` to find them from user-provided roots. - !matches!( - client_id, - ClientId::Kilo | ClientId::Crush | ClientId::Goose - ) + // Kilo CLI currently loads a single SQLite DB via `scan_result.kilo_db`. + // Roo/KiloCode require local + remote and server task roots. Hermes/Zed + // profile databases are named consistently enough for `scan_directory` to + // find them from user-provided roots. + !matches!(client_id, ClientId::Kilo | ClientId::Goose) } fn push_unique_scan_task( @@ -699,11 +625,14 @@ fn scan_all_clients_with_env_strategy_inner( let include_all = clients.is_empty(); let enabled: HashSet = if include_all { - ClientId::iter().collect() + ClientId::iter() + .filter(|client| scanner_enabled_client(*client)) + .collect() } else { clients .iter() .filter_map(|s| ClientId::from_str(s)) + .filter(|client| scanner_enabled_client(*client)) .collect() }; @@ -726,7 +655,6 @@ fn scan_all_clients_with_env_strategy_inner( | ClientId::Hermes | ClientId::Goose | ClientId::Zed - | ClientId::Crush | ClientId::Codebuff | ClientId::Kimi | ClientId::Gjc @@ -1034,10 +962,6 @@ fn scan_all_clients_with_env_strategy_inner( } } - if enabled.contains(&ClientId::Crush) { - result.crush_dbs = discover_crush_dbs(home_dir, use_env_roots); - } - if enabled.contains(&ClientId::Kiro) { let xdg_path = PathBuf::from(format!("{}/.local/share/kiro-cli/data.sqlite3", home_dir)); if xdg_path.is_file() { @@ -1657,11 +1581,6 @@ mod tests { File::create(server.join("ui_messages.json")).unwrap(); } - fn setup_mock_crush_registry(registry_path: &Path, projects_json: &str) { - fs::create_dir_all(registry_path.parent().unwrap()).unwrap(); - fs::write(registry_path, projects_json).unwrap(); - } - #[test] #[serial] fn test_headless_roots_default() { @@ -2586,153 +2505,74 @@ mod tests { } #[test] - fn test_scan_crush_registry_resolves_relative_and_absolute_data_dirs() { + fn test_scan_all_clients_skips_cost_only_clients() { let dir = TempDir::new().unwrap(); - let project_a = dir.path().join("project-a"); - let project_b_data = dir.path().join("project-b-data"); - fs::create_dir_all(project_a.join(".crush")).unwrap(); - fs::create_dir_all(&project_b_data).unwrap(); - File::create(project_a.join(".crush").join("crush.db")).unwrap(); - File::create(project_b_data.join("crush.db")).unwrap(); + let home = dir.path(); + let settings = ScannerSettings { + extra_scan_paths: BTreeMap::from([ + ( + "warp".to_string(), + vec![home.join(".config/tokscale/warp-cache")], + ), + ("crush".to_string(), vec![home.join(".local/share/crush")]), + ]), + ..Default::default() + }; - let registry_path = dir.path().join("projects.json"); - let projects_json = format!( - r#"{{ - "projects": [ - {{ "path": "{}", "data_dir": ".crush" }}, - {{ "path": "{}", "data_dir": "{}" }}, - {{ "path": "{}", "data_dir": ".crush" }} - ] -}}"#, - project_a.display(), - dir.path().join("project-b").display(), - project_b_data.display(), - dir.path().join("missing-project").display(), - ); - setup_mock_crush_registry(®istry_path, &projects_json); + let warp_file = home.join(".config/tokscale/warp-cache/usage.json"); + fs::create_dir_all(warp_file.parent().unwrap()).unwrap(); + fs::write(&warp_file, "{}").unwrap(); - let result = scan_crush_registry(®istry_path); - assert_eq!( - result, - vec![ - CrushDbSource { - db_path: project_a.join(".crush").join("crush.db"), - workspace_key: Some(project_a.display().to_string()), - workspace_label: Some("project-a".to_string()), - }, - CrushDbSource { - db_path: project_b_data.join("crush.db"), - workspace_key: Some(dir.path().join("project-b").display().to_string()), - workspace_label: Some("project-b".to_string()), - }, - ] - ); - } + let crush_db = home.join(".local/share/crush/project/crush.db"); + fs::create_dir_all(crush_db.parent().unwrap()).unwrap(); + File::create(&crush_db).unwrap(); - #[test] - fn test_scan_crush_registry_skips_malformed_project_entries() { - let dir = TempDir::new().unwrap(); - let valid_project = dir.path().join("valid-project"); - fs::create_dir_all(valid_project.join(".crush")).unwrap(); - File::create(valid_project.join(".crush").join("crush.db")).unwrap(); - - let registry_path = dir.path().join("projects.json"); - let projects_json = format!( - r#"{{ - "projects": [ - {{ "path": "{}", "data_dir": ".crush" }}, - {{ "path": 123, "data_dir": ".crush" }}, - {{ "data_dir": ".crush" }}, - "not-an-object" - ] -}}"#, - valid_project.display() + let all_clients = + scan_all_clients_with_scanner_settings(home.to_str().unwrap(), &[], false, &settings); + let explicit_warp = scan_all_clients_with_scanner_settings( + home.to_str().unwrap(), + &["warp".to_string()], + false, + &settings, ); - setup_mock_crush_registry(®istry_path, &projects_json); - - let result = scan_crush_registry(®istry_path); - assert_eq!( - result, - vec![CrushDbSource { - db_path: valid_project.join(".crush").join("crush.db"), - workspace_key: Some(valid_project.display().to_string()), - workspace_label: Some("valid-project".to_string()), - }] + let explicit_crush = scan_all_clients_with_scanner_settings( + home.to_str().unwrap(), + &["crush".to_string()], + false, + &settings, ); - } - #[test] - #[serial] - fn test_discover_crush_dbs_ignores_cwd_without_override() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); - let previous_dir = std::env::current_dir().unwrap(); - - let dir = TempDir::new().unwrap(); - let home = dir.path().join("home"); - let project = dir.path().join("workspace"); - let nested = project.join("src/subdir"); - let xdg = dir.path().join("xdg"); - - fs::create_dir_all(&nested).unwrap(); - fs::create_dir_all(xdg.join("crush")).unwrap(); - fs::create_dir_all(project.join(".crush")).unwrap(); - File::create(project.join(".crush").join("crush.db")).unwrap(); - fs::write( - xdg.join("crush").join("projects.json"), - r#"{"projects":[]}"#, - ) - .unwrap(); - - unsafe { std::env::set_var("XDG_DATA_HOME", &xdg) }; - std::env::set_current_dir(&nested).unwrap(); - - let result = discover_crush_dbs(home.to_str().unwrap(), false); - assert!(result.is_empty()); - - restore_current_dir(&previous_dir); - restore_env("XDG_DATA_HOME", previous_xdg); + assert!(all_clients.get(ClientId::Warp).is_empty()); + assert!(all_clients.get(ClientId::Crush).is_empty()); + assert_eq!(explicit_warp.total_files(), 0); + assert_eq!(explicit_crush.total_files(), 0); } #[test] - #[serial] - fn test_scan_all_clients_crush_populates_crush_db_paths() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); - + fn test_scan_all_clients_keeps_cursor_cache_scanning() { let dir = TempDir::new().unwrap(); - let home = dir.path().join("home"); - let xdg = dir.path().join("xdg"); - let project = dir.path().join("project"); - let data_dir = project.join(".crush"); + let home = dir.path(); - fs::create_dir_all(xdg.join("crush")).unwrap(); - fs::create_dir_all(&data_dir).unwrap(); - File::create(data_dir.join("crush.db")).unwrap(); + let cursor_file = home + .join(".config") + .join("tokscale") + .join("cursor-cache") + .join("usage.csv"); + fs::create_dir_all(cursor_file.parent().unwrap()).unwrap(); + fs::write(&cursor_file, "Date,Model,Input Tokens,Output Tokens\n").unwrap(); - let registry_path = xdg.join("crush").join("projects.json"); - let projects_json = format!( - r#"{{ - "projects": [ - {{ "path": "{}", "data_dir": ".crush" }} - ] -}}"#, - project.display() + let all_clients = scan_all_clients_with_env_strategy(home.to_str().unwrap(), &[], false); + let explicit_cursor = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["cursor".to_string()], + false, ); - setup_mock_crush_registry(®istry_path, &projects_json); - - unsafe { std::env::set_var("XDG_DATA_HOME", &xdg) }; - let result = scan_all_clients(home.to_str().unwrap(), &["crush".to_string()]); assert_eq!( - result.crush_dbs, - vec![CrushDbSource { - db_path: data_dir.join("crush.db"), - workspace_key: Some(project.display().to_string()), - workspace_label: Some("project".to_string()), - }] + all_clients.get(ClientId::Cursor), + &vec![cursor_file.clone()] ); - assert!(result.get(ClientId::Crush).is_empty()); - - restore_env("XDG_DATA_HOME", previous_xdg); + assert_eq!(explicit_cursor.get(ClientId::Cursor), &vec![cursor_file]); } #[test] diff --git a/crates/tokscale-core/src/sessions/amp.rs b/crates/tokscale-core/src/sessions/amp.rs index 205fa930c..9206b0d7b 100644 --- a/crates/tokscale-core/src/sessions/amp.rs +++ b/crates/tokscale-core/src/sessions/amp.rs @@ -13,7 +13,6 @@ use std::path::Path; pub struct AmpUsageEvent { pub timestamp: Option, pub model: Option, - pub credits: Option, pub tokens: Option, #[serde(rename = "operationType")] pub _operation_type: Option, @@ -45,7 +44,6 @@ pub struct AmpMessageUsage { pub cache_read_input_tokens: Option, #[serde(rename = "cacheCreationInputTokens")] pub cache_creation_input_tokens: Option, - pub credits: Option, } #[derive(Debug, Deserialize)] @@ -83,7 +81,6 @@ struct AmpUsageRecord { message_id: Option, ledger_to_message_id: Option, tokens: TokenBreakdown, - cost: f64, } impl AmpUsageRecord { @@ -99,7 +96,7 @@ impl AmpUsageRecord { thread_id, self.timestamp, self.tokens, - self.cost, + 0.0, ) } } @@ -148,20 +145,24 @@ fn parse_amp_ledger_records( cache_creation_input_tokens: Some(0), }); + let tokens = TokenBreakdown { + input: tokens.input.unwrap_or(0).max(0), + output: tokens.output.unwrap_or(0).max(0), + cache_read: tokens.cache_read_input_tokens.unwrap_or(0).max(0), + cache_write: tokens.cache_creation_input_tokens.unwrap_or(0).max(0), + reasoning: 0, + }; + if crate::positive_token_total(&tokens) == 0 { + return None; + } + Some(AmpUsageRecord { model, timestamp, has_explicit_timestamp: explicit_timestamp.is_some(), message_id: None, ledger_to_message_id: event.to_message_id.filter(|id| *id > 0), - tokens: TokenBreakdown { - input: tokens.input.unwrap_or(0).max(0), - output: tokens.output.unwrap_or(0).max(0), - cache_read: tokens.cache_read_input_tokens.unwrap_or(0).max(0), - cache_write: tokens.cache_creation_input_tokens.unwrap_or(0).max(0), - reasoning: 0, - }, - cost: event.credits.unwrap_or(0.0).max(0.0), + tokens, }) }) .collect() @@ -194,20 +195,24 @@ fn parse_amp_message_records( let message_id = msg.message_id.unwrap_or(0).max(0); let timestamp = base_timestamp.saturating_add(message_id.saturating_mul(1000)); + let tokens = TokenBreakdown { + input: usage.input_tokens.unwrap_or(0).max(0), + output: usage.output_tokens.unwrap_or(0).max(0), + cache_read: usage.cache_read_input_tokens.unwrap_or(0).max(0), + cache_write: usage.cache_creation_input_tokens.unwrap_or(0).max(0), + reasoning: 0, + }; + if crate::positive_token_total(&tokens) == 0 { + return None; + } + Some(AmpUsageRecord { model, timestamp, has_explicit_timestamp: false, message_id: Some(message_id).filter(|id| *id > 0), ledger_to_message_id: None, - tokens: TokenBreakdown { - input: usage.input_tokens.unwrap_or(0).max(0), - output: usage.output_tokens.unwrap_or(0).max(0), - cache_read: usage.cache_read_input_tokens.unwrap_or(0).max(0), - cache_write: usage.cache_creation_input_tokens.unwrap_or(0).max(0), - reasoning: 0, - }, - cost: usage.credits.unwrap_or(0.0).max(0.0), + tokens, }) }) .collect() @@ -243,14 +248,9 @@ fn merge_amp_records( message_record: &AmpUsageRecord, ) -> AmpUsageRecord { if ledger_record.has_explicit_timestamp { - if ledger_record.cost > 0.0 || message_record.cost <= 0.0 { - ledger_record - } else { - AmpUsageRecord { - cost: message_record.cost, - message_id: message_record.message_id, - ..ledger_record - } + AmpUsageRecord { + message_id: message_record.message_id, + ..ledger_record } } else { AmpUsageRecord { @@ -260,11 +260,6 @@ fn merge_amp_records( message_id: message_record.message_id, ledger_to_message_id: ledger_record.ledger_to_message_id, tokens: ledger_record.tokens, - cost: if ledger_record.cost > 0.0 { - ledger_record.cost - } else { - message_record.cost - }, } } } diff --git a/crates/tokscale-core/src/sessions/cline.rs b/crates/tokscale-core/src/sessions/cline.rs index 32246c83b..507d34513 100644 --- a/crates/tokscale-core/src/sessions/cline.rs +++ b/crates/tokscale-core/src/sessions/cline.rs @@ -57,7 +57,7 @@ mod tests { assert_eq!(messages[0].tokens.output, 15); assert_eq!(messages[0].tokens.cache_read, 7); assert_eq!(messages[0].tokens.cache_write, 3); - assert_eq!(messages[0].cost, 0.05); + assert_eq!(messages[0].cost, 0.0); } #[test] diff --git a/crates/tokscale-core/src/sessions/codebuff.rs b/crates/tokscale-core/src/sessions/codebuff.rs index f11c4349b..71760dc94 100644 --- a/crates/tokscale-core/src/sessions/codebuff.rs +++ b/crates/tokscale-core/src/sessions/codebuff.rs @@ -87,7 +87,7 @@ pub fn parse_codebuff_file(path: &Path) -> Vec { cache_write: usage.cache_creation_input_tokens.max(0), reasoning: 0, }, - usage.credits.max(0.0), + 0.0, Some(crate::sessions::dedup_hash_str(&dedup_key)), )); } @@ -205,7 +205,6 @@ fn message_timestamp(msg: &Value) -> Option { #[derive(Default, Debug, Clone)] struct AssistantUsage { model: Option, - credits: f64, input_tokens: i64, output_tokens: i64, cache_read_input_tokens: i64, @@ -218,7 +217,6 @@ impl AssistantUsage { || self.output_tokens > 0 || self.cache_read_input_tokens > 0 || self.cache_creation_input_tokens > 0 - || self.credits > 0.0 } fn merge_fallback(&mut self, other: AssistantUsage) { @@ -237,9 +235,6 @@ impl AssistantUsage { if self.model.is_none() { self.model = other.model; } - if self.credits <= 0.0 { - self.credits = other.credits; - } } } @@ -266,12 +261,6 @@ fn extract_assistant_usage(msg: &Value) -> AssistantUsage { } } - if let Some(credits) = msg.get("credits").and_then(|v| v.as_f64()) { - if credits > 0.0 && usage.credits <= 0.0 { - usage.credits = credits; - } - } - usage } @@ -382,9 +371,6 @@ fn parse_usage_object(value: &Value) -> AssistantUsage { usage.cache_read_input_tokens = cache_read.unwrap_or(0); usage.cache_creation_input_tokens = cache_write.unwrap_or(0); - if let Some(credits) = value.get("credits").and_then(|v| v.as_f64()) { - usage.credits = credits; - } if let Some(model) = value.get("model").and_then(|v| v.as_str()) { usage.model = Some(model.to_string()); } @@ -449,7 +435,6 @@ mod tests { assert_eq!(usage.output_tokens, 400); assert_eq!(usage.cache_read_input_tokens, 200); assert_eq!(usage.cache_creation_input_tokens, 50); - assert_eq!(usage.credits, 1.5); assert_eq!(usage.model.as_deref(), Some("claude-sonnet-4-20250514")); } diff --git a/crates/tokscale-core/src/sessions/crush.rs b/crates/tokscale-core/src/sessions/crush.rs deleted file mode 100644 index b7bf7b8e6..000000000 --- a/crates/tokscale-core/src/sessions/crush.rs +++ /dev/null @@ -1,442 +0,0 @@ -//! Crush session parser -//! -//! Crush persists usage in a per-project SQLite database (`crush.db`). -//! The database exposes reliable session-level cost, but not reliable -//! per-message token accounting for import. - -use super::utils::open_readonly_sqlite; -use super::UnifiedMessage; -use crate::TokenBreakdown; -use chrono::{Local, TimeZone}; -use rusqlite::Connection; -use std::collections::{BTreeMap, HashMap}; -use std::path::Path; - -const CRUSH_MODEL_ID: &str = "session-total"; -const CRUSH_PROVIDER_ID: &str = "crush"; - -#[derive(Debug)] -struct CrushSession { - id: String, - cost: f64, - created_at: i64, - updated_at: i64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct DayBucket { - timestamp_ms: i64, - message_count: i32, -} - -/// Parse root Crush sessions from a `crush.db` file. -/// -/// Crush stores reliable cost at the root-session level, but does not expose a -/// stable per-message token breakdown. Tokscale v1 therefore preserves cost -/// and assistant-message counts without fabricating token precision: -/// - assistant messages are grouped by local day -/// - session cost is allocated across those days proportionally -/// - token fields remain zero -pub fn parse_crush_sqlite(db_path: &Path) -> Vec { - let Some(conn) = open_readonly_sqlite(db_path) else { - return Vec::new(); - }; - - let root_sessions = load_root_sessions(&conn); - if root_sessions.is_empty() { - return Vec::new(); - } - - let assistant_buckets = load_assistant_buckets(&conn); - let db_namespace = db_path.to_string_lossy().to_string(); - let mut messages = Vec::new(); - - for session in root_sessions { - let session_key = format!("{}:{}", db_namespace, session.id); - - if let Some(day_buckets) = assistant_buckets.get(&session.id) { - let total_assistant_messages: i32 = - day_buckets.iter().map(|bucket| bucket.message_count).sum(); - let safe_cost = session.cost.max(0.0); - let mut allocated_cost = 0.0; - - for (index, bucket) in day_buckets.iter().enumerate() { - let bucket_cost = if index + 1 == day_buckets.len() { - (safe_cost - allocated_cost).max(0.0) - } else { - safe_cost * f64::from(bucket.message_count) - / f64::from(total_assistant_messages) - }; - allocated_cost += bucket_cost; - - let mut message = UnifiedMessage::new( - "crush", - CRUSH_MODEL_ID, - CRUSH_PROVIDER_ID, - session_key.clone(), - bucket.timestamp_ms, - TokenBreakdown::default(), - bucket_cost, - ); - message.message_count = bucket.message_count.max(0); - messages.push(message); - } - - continue; - } - - if session.cost <= 0.0 { - continue; - } - - let Some(timestamp_ms) = - fallback_session_timestamp_ms(session.updated_at, session.created_at) - else { - continue; - }; - - let mut message = UnifiedMessage::new( - "crush", - CRUSH_MODEL_ID, - CRUSH_PROVIDER_ID, - session_key, - timestamp_ms, - TokenBreakdown::default(), - session.cost.max(0.0), - ); - message.message_count = 0; - messages.push(message); - } - - messages.sort_by(|a, b| { - a.timestamp - .cmp(&b.timestamp) - .then_with(|| a.session_id.cmp(&b.session_id)) - }); - messages -} - -fn load_root_sessions(conn: &Connection) -> Vec { - let query = r#" - SELECT id, cost, created_at, updated_at - FROM sessions - WHERE parent_session_id IS NULL - AND (COALESCE(message_count, 0) > 0 OR COALESCE(cost, 0) > 0) - ORDER BY created_at ASC - "#; - - let mut stmt = match conn.prepare(query) { - Ok(stmt) => stmt, - Err(_) => return Vec::new(), - }; - - let rows = match stmt.query_map([], |row| { - Ok(CrushSession { - id: row.get(0)?, - cost: row.get::<_, Option>(1)?.unwrap_or(0.0), - created_at: row.get::<_, Option>(2)?.unwrap_or(0), - updated_at: row.get::<_, Option>(3)?.unwrap_or(0), - }) - }) { - Ok(rows) => rows, - Err(_) => return Vec::new(), - }; - - rows.flatten().collect() -} - -fn load_assistant_buckets(conn: &Connection) -> HashMap> { - let query = r#" - WITH RECURSIVE session_tree(root_session_id, session_id) AS ( - SELECT id, id - FROM sessions - WHERE parent_session_id IS NULL - - UNION ALL - - SELECT st.root_session_id, s.id - FROM sessions s - JOIN session_tree st ON s.parent_session_id = st.session_id - ) - SELECT st.root_session_id, m.created_at - FROM session_tree st - JOIN messages m ON m.session_id = st.session_id - WHERE m.role = 'assistant' - ORDER BY st.root_session_id ASC, m.created_at ASC - "#; - - let mut stmt = match conn.prepare(query) { - Ok(stmt) => stmt, - Err(_) => return HashMap::new(), - }; - - let rows = match stmt.query_map([], |row| { - let session_id: String = row.get(0)?; - let created_at: i64 = row.get::<_, Option>(1)?.unwrap_or(0); - Ok((session_id, created_at)) - }) { - Ok(rows) => rows, - Err(_) => return HashMap::new(), - }; - - let mut session_days: HashMap> = HashMap::new(); - - for row in rows.flatten() { - let (session_id, created_at) = row; - let Some(timestamp_ms) = normalize_crush_timestamp_ms(created_at) else { - continue; - }; - let Some(local_day) = local_day_key(timestamp_ms) else { - continue; - }; - - let day_map = session_days.entry(session_id).or_default(); - let bucket = day_map.entry(local_day).or_insert(DayBucket { - timestamp_ms, - message_count: 0, - }); - bucket.timestamp_ms = bucket.timestamp_ms.min(timestamp_ms); - bucket.message_count = bucket.message_count.saturating_add(1); - } - - session_days - .into_iter() - .map(|(session_id, day_map)| (session_id, day_map.into_values().collect())) - .collect() -} - -fn normalize_crush_timestamp_ms(raw: i64) -> Option { - if raw <= 0 { - return None; - } - - if raw >= 100_000_000_000 { - Some(raw) - } else { - raw.checked_mul(1000) - } -} - -fn local_day_key(timestamp_ms: i64) -> Option { - match Local.timestamp_millis_opt(timestamp_ms) { - chrono::LocalResult::Single(dt) => Some(dt.format("%Y-%m-%d").to_string()), - _ => None, - } -} - -fn fallback_session_timestamp_ms(updated_at: i64, created_at: i64) -> Option { - normalize_crush_timestamp_ms(updated_at).or_else(|| normalize_crush_timestamp_ms(created_at)) -} - -#[cfg(test)] -mod tests { - use super::*; - use rusqlite::params; - use tempfile::TempDir; - - fn create_test_db(dir: &TempDir) -> std::path::PathBuf { - let db_path = dir.path().join("crush.db"); - let conn = Connection::open(&db_path).unwrap(); - conn.execute_batch( - r#" - CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - parent_session_id TEXT, - title TEXT, - message_count INTEGER NOT NULL DEFAULT 0, - prompt_tokens INTEGER NOT NULL DEFAULT 0, - completion_tokens INTEGER NOT NULL DEFAULT 0, - cost REAL NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE messages ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - role TEXT NOT NULL, - parts TEXT NOT NULL DEFAULT '[]', - model TEXT, - provider TEXT, - is_summary_message INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL DEFAULT 0, - finished_at INTEGER - ); - "#, - ) - .unwrap(); - db_path - } - - fn insert_root_session( - conn: &Connection, - id: &str, - message_count: i64, - cost: f64, - updated_at: i64, - created_at: i64, - ) { - conn.execute( - "INSERT INTO sessions (id, parent_session_id, title, message_count, cost, updated_at, created_at) - VALUES (?1, NULL, ?2, ?3, ?4, ?5, ?6)", - params![id, "Root", message_count, cost, updated_at, created_at], - ) - .unwrap(); - } - - fn insert_child_session(conn: &Connection, id: &str, parent_id: &str) { - conn.execute( - "INSERT INTO sessions (id, parent_session_id, title, message_count, cost, updated_at, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![id, parent_id, "Child", 2_i64, 99.0_f64, 1_742_342_001_i64, 1_742_300_100_i64], - ) - .unwrap(); - } - - fn insert_message( - conn: &Connection, - id: &str, - session_id: &str, - role: &str, - created_at: i64, - is_summary_message: i64, - ) { - conn.execute( - "INSERT INTO messages (id, session_id, role, parts, model, provider, is_summary_message, created_at, updated_at) - VALUES (?1, ?2, ?3, '[]', 'gpt-5.4', 'crush', ?4, ?5, ?5)", - params![id, session_id, role, is_summary_message, created_at], - ) - .unwrap(); - } - - #[test] - fn test_parse_crush_sqlite_allocates_cost_across_assistant_message_days() { - let dir = TempDir::new().unwrap(); - let db_path = create_test_db(&dir); - let conn = Connection::open(&db_path).unwrap(); - - let day_one = 1_742_300_000_i64; - let day_two = 1_742_386_400_i64; - - insert_root_session(&conn, "root-1", 5, 30.0, day_two, day_one); - insert_child_session(&conn, "child-1", "root-1"); - insert_message(&conn, "msg-1", "root-1", "assistant", day_one, 0); - insert_message(&conn, "msg-2", "root-1", "user", day_one + 10, 0); - insert_message(&conn, "msg-3", "root-1", "assistant", day_two, 0); - insert_message(&conn, "msg-4", "root-1", "assistant", day_two + 10, 1); - insert_message(&conn, "msg-5", "child-1", "assistant", day_two + 20, 0); - - let messages = parse_crush_sqlite(&db_path); - assert_eq!(messages.len(), 2); - - assert_eq!(messages[0].client.as_ref(), "crush"); - assert_eq!(messages[0].model_id.as_ref(), CRUSH_MODEL_ID); - assert_eq!(messages[0].provider_id.as_ref(), CRUSH_PROVIDER_ID); - assert_eq!(messages[0].timestamp, day_one * 1000); - assert_eq!(messages[0].message_count, 1); - assert!((messages[0].cost - 7.5).abs() < 1e-9); - - assert_eq!(messages[1].timestamp, day_two * 1000); - assert_eq!(messages[1].message_count, 3); - assert!((messages[1].cost - 22.5).abs() < 1e-9); - assert!( - (messages.iter().map(|msg| msg.cost).sum::() - 30.0).abs() < 1e-9, - "allocated cost must sum back to the stored session total" - ); - assert!(messages - .iter() - .all(|msg| msg.session_id.ends_with(":root-1"))); - assert!(messages.iter().all(|msg| msg.tokens.total() == 0)); - } - - #[test] - fn test_parse_crush_sqlite_uses_updated_at_when_costed_session_has_no_assistant_messages() { - let dir = TempDir::new().unwrap(); - let db_path = create_test_db(&dir); - let conn = Connection::open(&db_path).unwrap(); - - insert_root_session( - &conn, - "root-1", - 3, - 4.5, - 1_742_342_000_i64, - 1_742_300_000_i64, - ); - insert_message(&conn, "msg-1", "root-1", "user", 1_742_300_100_i64, 0); - - let messages = parse_crush_sqlite(&db_path); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].timestamp, 1_742_342_000_000_i64); - assert_eq!(messages[0].message_count, 0); - assert_eq!(messages[0].cost, 4.5); - } - - #[test] - fn test_parse_crush_sqlite_preserves_millisecond_timestamps() { - let dir = TempDir::new().unwrap(); - let db_path = create_test_db(&dir); - let conn = Connection::open(&db_path).unwrap(); - - let created_at_ms = 1_742_300_000_123_i64; - insert_root_session(&conn, "root-1", 1, 2.0, created_at_ms, created_at_ms); - insert_message(&conn, "msg-1", "root-1", "assistant", created_at_ms, 0); - - let messages = parse_crush_sqlite(&db_path); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].timestamp, created_at_ms); - assert_eq!(messages[0].message_count, 1); - assert_eq!(messages[0].cost, 2.0); - } - - #[test] - fn test_parse_crush_sqlite_includes_child_session_assistant_messages() { - let dir = TempDir::new().unwrap(); - let db_path = create_test_db(&dir); - let conn = Connection::open(&db_path).unwrap(); - - let day_one = 1_742_300_000_i64; - let day_two = 1_742_386_400_i64; - - insert_root_session(&conn, "root-1", 4, 40.0, day_two, day_one); - insert_child_session(&conn, "child-1", "root-1"); - insert_message(&conn, "msg-1", "root-1", "assistant", day_one, 0); - insert_message(&conn, "msg-2", "child-1", "assistant", day_two, 0); - - let messages = parse_crush_sqlite(&db_path); - assert_eq!( - messages.len(), - 2, - "root-session cost should be distributed across assistant messages in descendant sessions too" - ); - assert_eq!(messages[0].timestamp, day_one * 1000); - assert_eq!(messages[0].message_count, 1); - assert!((messages[0].cost - 20.0).abs() < 1e-9); - - assert_eq!(messages[1].timestamp, day_two * 1000); - assert_eq!(messages[1].message_count, 1); - assert!((messages[1].cost - 20.0).abs() < 1e-9); - assert!(messages - .iter() - .all(|msg| msg.session_id.ends_with(":root-1"))); - } - - #[test] - fn test_parse_crush_sqlite_returns_empty_for_missing_db() { - let messages = parse_crush_sqlite(Path::new("/nonexistent/crush.db")); - assert!(messages.is_empty()); - } - - #[test] - fn test_parse_crush_sqlite_skips_sessions_without_valid_timestamps() { - let dir = TempDir::new().unwrap(); - let db_path = create_test_db(&dir); - let conn = Connection::open(&db_path).unwrap(); - - insert_root_session(&conn, "root-1", 3, 4.5, 0, 0); - - let messages = parse_crush_sqlite(&db_path); - assert!(messages.is_empty()); - } -} diff --git a/crates/tokscale-core/src/sessions/cursor.rs b/crates/tokscale-core/src/sessions/cursor.rs index 75b234848..e24c1f5da 100644 --- a/crates/tokscale-core/src/sessions/cursor.rs +++ b/crates/tokscale-core/src/sessions/cursor.rs @@ -52,23 +52,6 @@ fn infer_provider(model: &str) -> &'static str { provider_identity::inferred_provider_from_model(model).unwrap_or("cursor") } -/// Parse a cost string like "$0.50" or "0.50" to f64 -/// Returns 0.0 for empty strings, NaN values, or invalid formats -fn parse_cost(cost_str: &str) -> f64 { - let cleaned = cost_str.replace(['$', ','], ""); - let trimmed = cleaned.trim(); - - // Handle empty, NaN, or non-numeric values (e.g., "Included", "-" in v3) - if trimmed.is_empty() - || trimmed.eq_ignore_ascii_case("nan") - || !trimmed.chars().any(|c| c.is_ascii_digit()) - { - return 0.0; - } - - trimmed.parse().unwrap_or(0.0) -} - /// Parse a Cursor usage CSV file /// /// Handles both formats: @@ -100,23 +83,17 @@ pub fn parse_cursor_file(path: &Path) -> Vec { let column_count = header_fields.len(); // Column indices based on format - let ( - model_idx, - input_cache_write_idx, - input_no_cache_idx, - cache_read_idx, - output_idx, - cost_idx, - ) = if has_kind_column && column_count >= 11 { - // v3 format: Date,Cloud Agent ID,Automation ID,Kind,Model,... - (4, 6, 7, 8, 9, 11) - } else if has_kind_column { - // v2 format: Date,Kind,Model,Max Mode,Input (w/ Cache Write),... - (2, 4, 5, 6, 7, 9) - } else { - // v1 format: Date,Model,Input (w/ Cache Write),... - (1, 2, 3, 4, 5, 7) - }; + let (model_idx, input_cache_write_idx, input_no_cache_idx, cache_read_idx, output_idx) = + if has_kind_column && column_count >= 11 { + // v3 format: Date,Cloud Agent ID,Automation ID,Kind,Model,... + (4, 6, 7, 8, 9) + } else if has_kind_column { + // v2 format: Date,Kind,Model,Max Mode,Input (w/ Cache Write),... + (2, 4, 5, 6, 7) + } else { + // v1 format: Date,Model,Input (w/ Cache Write),... + (1, 2, 3, 4, 5) + }; let account_id = account_id_from_cursor_cache_path(path); @@ -129,7 +106,7 @@ pub fn parse_cursor_file(path: &Path) -> Vec { let fields: Vec<&str> = parse_csv_line(line); // Need at least enough columns for the format - let min_fields = cost_idx + 1; + let min_fields = output_idx + 1; if fields.len() < min_fields { continue; } @@ -156,8 +133,6 @@ pub fn parse_cursor_file(path: &Path) -> Vec { .trim_matches('"') .parse() .unwrap_or(0); - let cost_str = fields[cost_idx].trim().trim_matches('"'); - let cost = parse_cost(cost_str); // Skip empty or errored entries if model.is_empty() { @@ -174,6 +149,16 @@ pub fn parse_cursor_file(path: &Path) -> Vec { let cache_write = (input_with_cache_write - input_without_cache_write).max(0); // Input tokens = input_without_cache_write let input = input_without_cache_write; + let tokens = TokenBreakdown { + input: input.max(0), + output: output_tokens.max(0), + cache_read: cache_read.max(0), + cache_write, // Already clamped above with .max(0) + reasoning: 0, + }; + if crate::positive_token_total(&tokens) == 0 { + continue; + } messages.push(UnifiedMessage::new( "cursor", @@ -181,14 +166,8 @@ pub fn parse_cursor_file(path: &Path) -> Vec { infer_provider(model), format!("cursor-{}-{}", account_id, date_str), timestamp, - TokenBreakdown { - input: input.max(0), - output: output_tokens.max(0), - cache_read: cache_read.max(0), - cache_write, // Already clamped above with .max(0) - reasoning: 0, - }, - cost.max(0.0), + tokens, + 0.0, )); } @@ -270,20 +249,6 @@ mod tests { assert_eq!(infer_provider("unknown-model"), "cursor"); } - #[test] - fn test_parse_cost() { - assert_eq!(parse_cost("$0.50"), 0.50); - assert_eq!(parse_cost("0.50"), 0.50); - assert_eq!(parse_cost("$1,234.56"), 1234.56); - assert_eq!(parse_cost(""), 0.0); - assert_eq!(parse_cost("NaN"), 0.0); - assert_eq!(parse_cost("nan"), 0.0); - assert_eq!(parse_cost(" "), 0.0); - // v3 format values - assert_eq!(parse_cost("Included"), 0.0); - assert_eq!(parse_cost("-"), 0.0); - } - #[test] fn test_parse_csv_line() { let line = "2025-02-01,gpt-4o,10,5,0,15,30,$0.10,$0.10"; @@ -332,7 +297,7 @@ mod tests { assert_eq!(messages[0].tokens.input, 5); assert_eq!(messages[0].tokens.output, 15); assert_eq!(messages[0].tokens.cache_write, 5); // 10 - 5 - assert!((messages[0].cost - 0.10).abs() < 0.001); + assert_eq!(messages[0].cost, 0.0); assert_eq!(messages[1].model_id.as_ref(), "gpt-4o-mini"); } @@ -359,7 +324,7 @@ mod tests { assert_eq!(messages[0].tokens.output, 21282); assert_eq!(messages[0].tokens.cache_read, 105891); assert_eq!(messages[0].tokens.cache_write, 28342 - 775); // 27567 - assert!((messages[0].cost - 0.19).abs() < 0.001); + assert_eq!(messages[0].cost, 0.0); // Second message: gpt-5-codex assert_eq!(messages[1].model_id.as_ref(), "gpt-5-codex"); @@ -389,9 +354,9 @@ mod tests { assert_eq!(messages[0].cost, 0.0); assert_eq!(messages[0].tokens.cache_read, 29045760); - // Second message: actual cost from "On-Demand" + // Second message: app-reported cost is ignored. assert_eq!(messages[1].model_id.as_ref(), "composer-2"); - assert!((messages[1].cost - 0.11).abs() < 0.001); + assert_eq!(messages[1].cost, 0.0); // Third message: "-" cost should be 0 (Errored, No Charge) assert_eq!(messages[2].model_id.as_ref(), "composer-2"); diff --git a/crates/tokscale-core/src/sessions/gjc.rs b/crates/tokscale-core/src/sessions/gjc.rs index e05a0e651..fcf17dadb 100644 --- a/crates/tokscale-core/src/sessions/gjc.rs +++ b/crates/tokscale-core/src/sessions/gjc.rs @@ -7,13 +7,8 @@ //! - `session` — header carrying `id` (session id) and `cwd` (workspace). No //! message is emitted for it. //! - `service_tier_change` — skipped. -//! - `message` — emits ONLY assistant messages. The assistant `message` object -//! carries `model`/`provider`/`api`, a unix-ms `timestamp`, and a `usage` -//! object that includes an authoritative `usage.cost` (USD) breakdown. -//! -//! Cost policy (A1): the embedded `usage.cost.total` (USD) is reused verbatim -//! when present, finite, and non-negative. Otherwise cost is left at `0.0` so -//! the lib.rs dispatch Hermes guard can reprice from tokens. +//! - `message` — emits ONLY assistant messages with token usage. App-reported +//! `usage.cost` is ignored; report cost is derived by Tokscale pricing. //! //! Dedup (codebuff-style): a stable `dedup_key` of `:` //! is preferred; when ids are absent a deterministic fallback derived from the @@ -62,22 +57,6 @@ struct GjcUsage { cache_write: Option, #[allow(dead_code)] total_tokens: Option, - cost: Option, -} - -#[derive(Debug, Deserialize)] -struct GjcCost { - /// Authoritative total cost in USD. - total: Option, -} - -/// Reuse the embedded `usage.cost.total` (USD) only when present, finite, and -/// non-negative. Otherwise return `0.0` so the dispatch Hermes guard reprices. -fn embedded_cost(usage: &GjcUsage) -> f64 { - match usage.cost.as_ref().and_then(|c| c.total) { - Some(total) if total.is_finite() && total >= 0.0 => total, - _ => 0.0, - } } /// Build a deterministic fallback dedup key for messages lacking a stable @@ -192,8 +171,9 @@ pub fn parse_gjc_file(path: &Path) -> Vec { cache_write: usage.cache_write.unwrap_or(0).max(0), reasoning: 0, }; - - let cost = embedded_cost(&usage); + if crate::positive_token_total(&tokens) == 0 { + continue; + } let session = session_id.clone().unwrap_or_else(|| "unknown".to_string()); let dedup_key = match entry.id.filter(|s| !s.is_empty()) { @@ -208,7 +188,7 @@ pub fn parse_gjc_file(path: &Path) -> Vec { session, timestamp, tokens, - cost, + 0.0, Some(crate::sessions::dedup_hash_str(&dedup_key)), ); unified.set_workspace(workspace_key.clone(), workspace_label.clone()); @@ -323,13 +303,13 @@ not valid json at all } #[test] - fn test_parse_gjc_reads_embedded_cost_total() { + fn test_parse_gjc_ignores_embedded_cost_total() { let content = r#"{"type":"session","id":"gjc_ses_007","cwd":"/tmp"} {"type":"message","id":"msg_c","message":{"role":"assistant","model":"some-model","provider":"anthropic","timestamp":1767225601000,"usage":{"input":10,"output":5,"cost":{"input":0.5,"output":0.7,"total":1.25}}}}"#; let file = create_test_file(content); let messages = parse_gjc_file(file.path()); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].cost, 1.25); + assert_eq!(messages[0].cost, 0.0); } #[test] @@ -415,7 +395,7 @@ not valid json at all #[test] fn test_adv_negative_token_values_clamped_to_zero() { let content = r#"{"type":"session","id":"gjc_adv_e","cwd":"/tmp"} -{"type":"message","id":"msg_neg","message":{"role":"assistant","model":"m","provider":"p","timestamp":1700000001000,"usage":{"input":-100,"output":-50,"cacheRead":-10,"cacheWrite":-5,"cost":{"total":0.0}}}}"#; +{"type":"message","id":"msg_neg","message":{"role":"assistant","model":"m","provider":"p","timestamp":1700000001000,"usage":{"input":-100,"output":50,"cacheRead":-10,"cacheWrite":-5,"cost":{"total":0.0}}}}"#; let file = create_test_file(content); let messages = parse_gjc_file(file.path()); assert_eq!( @@ -425,23 +405,20 @@ not valid json at all ); let t = &messages[0].tokens; assert_eq!(t.input, 0, "negative input clamped to 0"); - assert_eq!(t.output, 0, "negative output clamped to 0"); + assert_eq!(t.output, 50); assert_eq!(t.cache_read, 0, "negative cache_read clamped to 0"); assert_eq!(t.cache_write, 0, "negative cache_write clamped to 0"); } - /// (f) Embedded cost.total negative -> falls back to 0.0. + /// (f) Embedded cost.total negative is ignored. #[test] - fn test_adv_negative_cost_total_falls_back_to_zero() { + fn test_adv_negative_cost_total_is_ignored() { let content = r#"{"type":"session","id":"gjc_adv_f","cwd":"/tmp"} {"type":"message","id":"msg_negcost","message":{"role":"assistant","model":"m","provider":"p","timestamp":1700000001000,"usage":{"input":5,"output":3,"cost":{"total":-9.99}}}}"#; let file = create_test_file(content); let messages = parse_gjc_file(file.path()); assert_eq!(messages.len(), 1); - assert_eq!( - messages[0].cost, 0.0, - "negative cost.total must fall back to 0.0" - ); + assert_eq!(messages[0].cost, 0.0, "cost.total must be ignored"); } /// (g) cost.total absent entirely -> 0.0. diff --git a/crates/tokscale-core/src/sessions/hermes.rs b/crates/tokscale-core/src/sessions/hermes.rs index 7309c643d..01ae60c00 100644 --- a/crates/tokscale-core/src/sessions/hermes.rs +++ b/crates/tokscale-core/src/sessions/hermes.rs @@ -3,6 +3,9 @@ //! Parses aggregated session rows from Hermes Agent's SQLite state database: //! - `~/.hermes/state.db` //! - `$HERMES_HOME/state.db` +//! +//! App-reported estimated and actual costs are ignored. Tokscale reports cost +//! only from token usage and its own pricing table. use super::UnifiedMessage; use crate::{provider_identity, TokenBreakdown}; @@ -55,9 +58,7 @@ pub fn parse_hermes_sqlite(db_path: &Path) -> Vec { output_tokens, cache_read_tokens, cache_write_tokens, - reasoning_tokens, - estimated_cost_usd, - actual_cost_usd + reasoning_tokens FROM sessions WHERE model IS NOT NULL AND TRIM(model) != '' @@ -66,8 +67,7 @@ pub fn parse_hermes_sqlite(db_path: &Path) -> Vec { COALESCE(output_tokens, 0) > 0 OR COALESCE(cache_read_tokens, 0) > 0 OR COALESCE(cache_write_tokens, 0) > 0 OR - COALESCE(reasoning_tokens, 0) > 0 OR - COALESCE(actual_cost_usd, estimated_cost_usd, 0) > 0 + COALESCE(reasoning_tokens, 0) > 0 ) "#; @@ -95,8 +95,6 @@ pub fn parse_hermes_sqlite(db_path: &Path) -> Vec { row.get::<_, Option>(7)?.unwrap_or(0), row.get::<_, Option>(8)?.unwrap_or(0), row.get::<_, Option>(9)?.unwrap_or(0), - row.get::<_, Option>(10)?, - row.get::<_, Option>(11)?, )) }) { Ok(r) => r, @@ -133,8 +131,6 @@ pub fn parse_hermes_sqlite(db_path: &Path) -> Vec { cache_read, cache_write, reasoning, - estimated_cost, - actual_cost, )| { let provider = resolved_provider(billing_provider, &model_id); let mut msg = UnifiedMessage::new_with_agent( @@ -150,7 +146,7 @@ pub fn parse_hermes_sqlite(db_path: &Path) -> Vec { cache_write: cache_write.max(0), reasoning: reasoning.max(0), }, - actual_cost.or(estimated_cost).unwrap_or(0.0).max(0.0), + 0.0, Some(HERMES_AGENT_NAME.to_string()), ); msg.message_count = message_count.max(0); diff --git a/crates/tokscale-core/src/sessions/kilo.rs b/crates/tokscale-core/src/sessions/kilo.rs index a6726d769..44165ec99 100644 --- a/crates/tokscale-core/src/sessions/kilo.rs +++ b/crates/tokscale-core/src/sessions/kilo.rs @@ -22,7 +22,6 @@ pub struct KiloMessage { pub model_id: Option, #[serde(rename = "providerID", default)] pub provider_id: Option, - pub cost: Option, pub tokens: Option, pub time: Option, pub agent: Option, @@ -133,20 +132,25 @@ pub fn parse_kilo_sqlite_with_fallback( .unwrap_or("kilo") .to_string(); + let token_breakdown = TokenBreakdown { + input: tokens.input.max(0), + output: tokens.output.max(0), + cache_read: tokens.cache.read.max(0), + cache_write: tokens.cache.write.max(0), + reasoning: tokens.reasoning.unwrap_or(0).max(0), + }; + if crate::positive_token_total(&token_breakdown) == 0 { + continue; + } + let mut unified = UnifiedMessage::new_with_agent( "kilo", model_id, provider, session_id, timestamp, - TokenBreakdown { - input: tokens.input.max(0), - output: tokens.output.max(0), - cache_read: tokens.cache.read.max(0), - cache_write: tokens.cache.write.max(0), - reasoning: tokens.reasoning.unwrap_or(0).max(0), - }, - msg.cost.unwrap_or(0.0).max(0.0), + token_breakdown, + 0.0, agent, ); unified.dedup_key = dedup_key; @@ -207,7 +211,6 @@ mod tests { let mut bytes = json.as_bytes().to_vec(); let msg: KiloMessage = simd_json::from_slice(&mut bytes).unwrap(); assert_eq!(msg.role, "assistant"); - assert_eq!(msg.cost, Some(0.15)); assert_eq!(msg.model_id, Some("minimax/m2.5".to_string())); } @@ -250,7 +253,7 @@ mod tests { assert_eq!(msg.tokens.reasoning, 40); assert_eq!(msg.tokens.cache_read, 75); assert_eq!(msg.tokens.cache_write, 25); - assert_eq!(msg.cost, 0.42); + assert_eq!(msg.cost, 0.0); assert_eq!(msg.agent.as_deref(), Some("architect")); assert_eq!( msg.dedup_key, @@ -307,7 +310,7 @@ mod tests { "mode": "debug", "tokens": { "input": -100, - "output": -50, + "output": 50, "reasoning": -5, "cache": {"read": -20, "write": -10} } @@ -324,7 +327,7 @@ mod tests { assert_eq!(msg.provider_id.as_ref(), "openai"); assert_eq!(msg.timestamp, 1_800_000_000_000); assert_eq!(msg.tokens.input, 0); - assert_eq!(msg.tokens.output, 0); + assert_eq!(msg.tokens.output, 50); assert_eq!(msg.tokens.reasoning, 0); assert_eq!(msg.tokens.cache_read, 0); assert_eq!(msg.tokens.cache_write, 0); diff --git a/crates/tokscale-core/src/sessions/kilocode.rs b/crates/tokscale-core/src/sessions/kilocode.rs index a5b3d1e22..746e03fb7 100644 --- a/crates/tokscale-core/src/sessions/kilocode.rs +++ b/crates/tokscale-core/src/sessions/kilocode.rs @@ -55,7 +55,7 @@ mod tests { assert_eq!(messages[0].tokens.output, 15); assert_eq!(messages[0].tokens.cache_read, 7); assert_eq!(messages[0].tokens.cache_write, 3); - assert_eq!(messages[0].cost, 0.05); + assert_eq!(messages[0].cost, 0.0); } #[test] diff --git a/crates/tokscale-core/src/sessions/mod.rs b/crates/tokscale-core/src/sessions/mod.rs index 6da4daa04..a6b00e4a4 100644 --- a/crates/tokscale-core/src/sessions/mod.rs +++ b/crates/tokscale-core/src/sessions/mod.rs @@ -11,7 +11,6 @@ pub mod codebuff; pub mod codex; pub mod commandcode; pub mod copilot; -pub mod crush; pub mod cursor; pub mod droid; pub mod gemini; @@ -33,7 +32,6 @@ pub mod qwen; pub mod roocode; pub mod trae; pub(crate) mod utils; -pub mod warp; pub mod zed; use crate::TokenBreakdown; @@ -444,72 +442,6 @@ mod tests { use super::*; use chrono::FixedOffset; - #[test] - fn warp_cache_parser_preserves_requests_and_spend_without_tokens() { - let file = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - file.path(), - r#"{ - "version": 1, - "syncedAt": "2026-05-29T12:00:00Z", - "usage": { - "requestsUsed": 42, - "requestLimit": 100, - "spendCents": 1234, - "nextRefreshTime": "2026-06-01T00:00:00Z" - }, - "workspaces": [ - { - "id": "workspace-1", - "name": "Personal", - "requestsUsed": 12, - "spendCents": 345 - } - ] -}"#, - ) - .unwrap(); - - let messages = crate::sessions::warp::parse_warp_file(file.path()); - assert_eq!(messages.len(), 1); - - let workspace = messages - .iter() - .find(|message| message.session_id.as_ref() == "warp-aggregate-workspace-1") - .unwrap(); - assert_eq!(workspace.client.as_ref(), "warp"); - assert_eq!(workspace.model_id.as_ref(), "aggregate-requests"); - assert_eq!(workspace.provider_id.as_ref(), "warp"); - assert_eq!(workspace.workspace_label.as_deref(), Some("Personal")); - assert_eq!(workspace.message_count, 12); - assert_eq!(workspace.tokens, TokenBreakdown::default()); - assert!((workspace.cost - 3.45).abs() < 1e-9); - - std::fs::write( - file.path(), - r#"{ - "version": 1, - "syncedAt": "2026-05-29T12:00:00Z", - "usage": { - "requestsUsed": 42, - "requestLimit": 100, - "spendCents": 1234, - "nextRefreshTime": "2026-06-01T00:00:00Z" - }, - "workspaces": [] -}"#, - ) - .unwrap(); - - let messages = crate::sessions::warp::parse_warp_file(file.path()); - assert_eq!(messages.len(), 1); - let account = &messages[0]; - assert_eq!(account.session_id.as_ref(), "warp-aggregate-account"); - assert_eq!(account.message_count, 42); - assert_eq!(account.tokens, TokenBreakdown::default()); - assert!((account.cost - 12.34).abs() < 1e-9); - } - #[test] fn test_timestamp_to_date_with_positive_offset() { let kst = FixedOffset::east_opt(9 * 60 * 60).unwrap(); diff --git a/crates/tokscale-core/src/sessions/mux.rs b/crates/tokscale-core/src/sessions/mux.rs index 07dcb2620..2b71cf9cc 100644 --- a/crates/tokscale-core/src/sessions/mux.rs +++ b/crates/tokscale-core/src/sessions/mux.rs @@ -32,7 +32,6 @@ pub struct MuxModelUsage { #[derive(Debug, Deserialize)] pub struct MuxTokenBucket { pub tokens: Option, - pub cost_usd: Option, } #[derive(Debug, Deserialize)] @@ -76,18 +75,11 @@ pub fn parse_mux_file(path: &Path) -> Vec { .filter_map(|(model_key, model_usage)| { let tokens = |b: &Option| b.as_ref().and_then(|b| b.tokens).unwrap_or(0).max(0); - let cost = - |b: &Option| b.as_ref().and_then(|b| b.cost_usd).unwrap_or(0.0); let input = tokens(&model_usage.input); let cached = tokens(&model_usage.cached); let cache_create = tokens(&model_usage.cache_create); let output = tokens(&model_usage.output); let reasoning = tokens(&model_usage.reasoning); - let source_cost = cost(&model_usage.input) - + cost(&model_usage.cached) - + cost(&model_usage.cache_create) - + cost(&model_usage.output) - + cost(&model_usage.reasoning); if input == 0 && cached == 0 && cache_create == 0 && output == 0 && reasoning == 0 { return None; @@ -116,7 +108,7 @@ pub fn parse_mux_file(path: &Path) -> Vec { cache_write: cache_create, reasoning, }, - source_cost, + 0.0, )) }) .collect() @@ -275,7 +267,7 @@ mod tests { } #[test] - fn test_source_cost_summed() { + fn test_source_cost_ignored() { let json = r#"{ "version": 1, "byModel": { @@ -292,8 +284,7 @@ mod tests { let f = write_temp_json(json); let msgs = parse_mux_file(f.path()); assert_eq!(msgs.len(), 1); - let expected_cost = 0.01 + 0.02 + 0.005 + 0.03; - assert!((msgs[0].cost - expected_cost).abs() < 1e-10); + assert_eq!(msgs[0].cost, 0.0); } #[test] diff --git a/crates/tokscale-core/src/sessions/openclaw.rs b/crates/tokscale-core/src/sessions/openclaw.rs index 78457ff8d..dd6e665b3 100644 --- a/crates/tokscale-core/src/sessions/openclaw.rs +++ b/crates/tokscale-core/src/sessions/openclaw.rs @@ -65,12 +65,6 @@ struct OpenClawUsage { #[serde(rename = "totalTokens")] #[allow(dead_code)] total_tokens: Option, - cost: Option, -} - -#[derive(Debug, Deserialize)] -struct OpenClawCost { - total: Option, } pub fn parse_openclaw_index(index_path: &Path) -> Vec { @@ -226,22 +220,19 @@ fn parse_openclaw_session(session_path: &Path, session_id: &str) -> Vec, #[serde(rename = "providerID")] pub provider_id: Option, - pub cost: Option, pub tokens: Option, pub time: OpenCodeTime, pub agent: Option, @@ -88,7 +87,6 @@ struct OpenCodeSqliteFingerprint { reasoning: i64, cache_read: i64, cache_write: i64, - cost_bits: u64, agent: Option, } @@ -167,20 +165,25 @@ pub fn parse_opencode_file(path: &Path) -> Option { .or_else(|| path.file_stem().and_then(|s| s.to_str())) .map(crate::sessions::dedup_hash_str); + let token_breakdown = TokenBreakdown { + input: tokens.input.max(0), + output: tokens.output.max(0), + cache_read: tokens.cache.read.max(0), + cache_write: tokens.cache.write.max(0), + reasoning: tokens.reasoning.unwrap_or(0).max(0), + }; + if crate::positive_token_total(&token_breakdown) == 0 { + return None; + } + let mut unified = UnifiedMessage::new_with_agent( "opencode", model_id, msg.provider_id.unwrap_or_else(|| "unknown".to_string()), session_id, msg.time.created as i64, - TokenBreakdown { - input: tokens.input.max(0), - output: tokens.output.max(0), - cache_read: tokens.cache.read.max(0), - cache_write: tokens.cache.write.max(0), - reasoning: tokens.reasoning.unwrap_or(0).max(0), - }, - msg.cost.unwrap_or(0.0).max(0.0), + token_breakdown, + 0.0, agent, ); unified.duration_ms = opencode_duration_ms(&msg.time); @@ -275,7 +278,16 @@ pub fn parse_opencode_sqlite(db_path: &Path) -> Vec { let reasoning = tokens.reasoning.unwrap_or(0).max(0); let cache_read = tokens.cache.read.max(0); let cache_write = tokens.cache.write.max(0); - let cost = msg.cost.unwrap_or(0.0).max(0.0); + let token_breakdown = TokenBreakdown { + input, + output, + cache_read, + cache_write, + reasoning, + }; + if crate::positive_token_total(&token_breakdown) == 0 { + continue; + } let dedup_key = message_id.clone().unwrap_or(row_id); let fingerprint = OpenCodeSqliteFingerprint { created_bits: msg.time.created.to_bits(), @@ -287,7 +299,6 @@ pub fn parse_opencode_sqlite(db_path: &Path) -> Vec { reasoning, cache_read, cache_write, - cost_bits: cost.to_bits(), agent: agent.clone(), }; @@ -297,14 +308,8 @@ pub fn parse_opencode_sqlite(db_path: &Path) -> Vec { provider_id, session_id, msg.time.created as i64, - TokenBreakdown { - input, - output, - cache_read, - cache_write, - reasoning, - }, - cost, + token_breakdown, + 0.0, agent, ); unified.duration_ms = opencode_duration_ms(&msg.time); @@ -549,7 +554,7 @@ mod tests { "cost": -0.05, "tokens": { "input": -100, - "output": -50, + "output": 50, "reasoning": -25, "cache": { "read": -200, "write": -10 } }, @@ -564,10 +569,7 @@ mod tests { let msg = result.unwrap(); assert_eq!(msg.tokens.input, 0, "Negative input should be clamped to 0"); - assert_eq!( - msg.tokens.output, 0, - "Negative output should be clamped to 0" - ); + assert_eq!(msg.tokens.output, 50, "Positive output should be preserved"); assert_eq!( msg.tokens.cache_read, 0, "Negative cache_read should be clamped to 0" @@ -581,8 +583,8 @@ mod tests { "Negative reasoning should be clamped to 0" ); assert!( - msg.cost >= 0.0, - "Negative cost should be clamped to 0.0, got {}", + msg.cost == 0.0, + "App-reported cost should be ignored, got {}", msg.cost ); } diff --git a/crates/tokscale-core/src/sessions/roocode.rs b/crates/tokscale-core/src/sessions/roocode.rs index 8711aba00..2f5d0c294 100644 --- a/crates/tokscale-core/src/sessions/roocode.rs +++ b/crates/tokscale-core/src/sessions/roocode.rs @@ -62,6 +62,16 @@ pub(crate) fn parse_roo_kilo_file(path: &Path, source: &str) -> Vec Vec) -> Option { } struct ApiReqStartedPayload { - cost: f64, tokens_in: i64, tokens_out: i64, cache_reads: i64, @@ -190,7 +193,6 @@ fn parse_api_req_started_payload(text: &str) -> Option { let mut bytes = text.as_bytes().to_vec(); let value: Value = simd_json::from_slice(&mut bytes).ok()?; - let cost = extract_f64(value.get("cost")).unwrap_or(0.0).max(0.0); let tokens_in = extract_i64(value.get("tokensIn")).unwrap_or(0).max(0); let tokens_out = extract_i64(value.get("tokensOut")).unwrap_or(0).max(0); let cache_reads = extract_i64(value.get("cacheReads")).unwrap_or(0).max(0); @@ -201,7 +203,6 @@ fn parse_api_req_started_payload(text: &str) -> Option { .map(|s| s.to_string()); Some(ApiReqStartedPayload { - cost, tokens_in, tokens_out, cache_reads, @@ -210,15 +211,6 @@ fn parse_api_req_started_payload(text: &str) -> Option { }) } -fn extract_f64(value: Option<&Value>) -> Option { - value.and_then(|val| { - val.as_f64() - .or_else(|| val.as_i64().map(|v| v as f64)) - .or_else(|| val.as_u64().map(|v| v as f64)) - .or_else(|| val.as_str().and_then(|s| s.parse::().ok())) - }) -} - fn provider_from_api_protocol(api_protocol: Option<&str>) -> String { api_protocol .map(str::trim) @@ -284,7 +276,7 @@ after"#; assert_eq!(messages[0].tokens.output, 50); assert_eq!(messages[0].tokens.cache_read, 20); assert_eq!(messages[0].tokens.cache_write, 5); - assert_eq!(messages[0].cost, 0.12); + assert_eq!(messages[0].cost, 0.0); assert_eq!(messages[0].agent.as_deref(), Some("architect")); } diff --git a/crates/tokscale-core/src/sessions/trae.rs b/crates/tokscale-core/src/sessions/trae.rs index 87f2a4306..83ee864e0 100644 --- a/crates/tokscale-core/src/sessions/trae.rs +++ b/crates/tokscale-core/src/sessions/trae.rs @@ -3,8 +3,8 @@ //! Reads `trae-cache/sessions/*.json` — the raw JSON dumped //! from the usage API — and converts each entry into a `UnifiedMessage`. //! -//! The API already returns exact token counts, so this parser does not -//! go through `pricing/lookup`. +//! The API returns token counts and vendor spend. Tokscale ignores vendor spend +//! and derives report cost from token usage through its own pricing table. use super::UnifiedMessage; use crate::TokenBreakdown; @@ -52,7 +52,7 @@ fn parse_session(client: &str, session: &serde_json::Value) -> Option` (e.g. - // `trae-auto`) so the cost is still attributed instead of disappearing + // `trae-auto`) so token usage is still attributed instead of disappearing // into an empty Model cell. let model_id = if !model_raw.is_empty() { normalize_trae_model(model_raw) @@ -76,15 +76,15 @@ fn parse_session(client: &str, session: &serde_json::Value) -> Option Option, - usage: Option, - #[serde(default)] - workspaces: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WarpAggregateUsage { - requests_used: Option, - spend_cents: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WarpWorkspaceUsage { - id: Option, - name: Option, - requests_used: Option, - spend_cents: Option, -} - -pub fn parse_warp_file(path: &Path) -> Vec { - let content = match std::fs::read_to_string(path) { - Ok(content) => content, - Err(_) => return Vec::new(), - }; - let cache: WarpUsageCache = match serde_json::from_str(&content) { - Ok(cache) => cache, - Err(_) => return Vec::new(), - }; - let timestamp = cache - .synced_at - .as_deref() - .and_then(parse_rfc3339_millis) - .unwrap_or(0); - if timestamp <= 0 { - return Vec::new(); - } - - let workspace_messages: Vec = cache - .workspaces - .iter() - .filter_map(|workspace| workspace_to_message(workspace, timestamp)) - .collect(); - if !workspace_messages.is_empty() { - return workspace_messages; - } - - cache - .usage - .as_ref() - .and_then(|usage| usage_to_message(usage, timestamp)) - .into_iter() - .collect() -} - -fn usage_to_message(usage: &WarpAggregateUsage, timestamp: i64) -> Option { - let requests = non_negative_i32(usage.requests_used); - let spend_cents = non_negative_i64(usage.spend_cents); - if requests == 0 && spend_cents == 0 { - return None; - } - - let mut message = UnifiedMessage::new( - "warp", - "aggregate-requests", - "warp", - "warp-aggregate-account", - timestamp, - TokenBreakdown::default(), - cents_to_dollars(spend_cents), - ); - message.message_count = requests; - Some(message) -} - -fn workspace_to_message(workspace: &WarpWorkspaceUsage, timestamp: i64) -> Option { - let requests = non_negative_i32(workspace.requests_used); - let spend_cents = non_negative_i64(workspace.spend_cents); - if requests == 0 && spend_cents == 0 { - return None; - } - - let workspace_id = workspace - .id - .as_deref() - .map(sanitize_id) - .filter(|id| !id.is_empty()) - .unwrap_or_else(|| "unknown".to_string()); - let mut message = UnifiedMessage::new( - "warp", - "aggregate-requests", - "warp", - format!("warp-aggregate-{workspace_id}"), - timestamp, - TokenBreakdown::default(), - cents_to_dollars(spend_cents), - ); - message.message_count = requests; - message.set_workspace( - workspace.id.clone().filter(|id| !id.trim().is_empty()), - workspace - .name - .clone() - .filter(|name| !name.trim().is_empty()), - ); - Some(message) -} - -fn parse_rfc3339_millis(value: &str) -> Option { - DateTime::parse_from_rfc3339(value) - .ok() - .map(|dt| dt.with_timezone(&Utc).timestamp_millis()) -} - -fn non_negative_i64(value: Option) -> i64 { - value.unwrap_or(0).max(0) -} - -fn non_negative_i32(value: Option) -> i32 { - non_negative_i64(value).min(i32::MAX as i64) as i32 -} - -fn cents_to_dollars(cents: i64) -> f64 { - cents as f64 / 100.0 -} - -fn sanitize_id(value: &str) -> String { - value - .trim() - .to_lowercase() - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { - ch - } else { - '-' - } - }) - .collect::() - .trim_matches('-') - .to_string() -} diff --git a/crates/tokscale-core/tests/codebuff.rs b/crates/tokscale-core/tests/codebuff.rs index 2c94bb65b..5510b7886 100644 --- a/crates/tokscale-core/tests/codebuff.rs +++ b/crates/tokscale-core/tests/codebuff.rs @@ -79,7 +79,7 @@ fn test_parse_codebuff_emits_one_event_per_assistant_message_with_usage() { assert_eq!(first.tokens.output, 200); assert_eq!(first.tokens.cache_write, 300); assert_eq!(first.tokens.cache_read, 100); - assert_eq!(first.cost, 1.25); + assert_eq!(first.cost, 0.0); assert!(first .session_id .ends_with("/my-project/2025-12-20T12-00-00.000Z")); diff --git a/crates/tokscale-core/tests/gjc.rs b/crates/tokscale-core/tests/gjc.rs index 7fc5390d4..66ab56118 100644 --- a/crates/tokscale-core/tests/gjc.rs +++ b/crates/tokscale-core/tests/gjc.rs @@ -1,12 +1,8 @@ -//! G7 end-to-end cost-precedence integration test for the gjc (gajae-code) client. +//! GJC end-to-end token-pricing integration test. //! -//! Tests that embedded `usage.cost.total` values in gjc JSONL session files take -//! precedence over recomputed pricing (A1 / Hermes guard), and that messages -//! without an embedded cost ARE repriced by the PricingService (A2 path). -//! -//! Binding note N1: the gjc dispatch cluster in lib.rs applies the Hermes guard -//! (`if msg.cost <= 0.0 { apply_pricing_if_available(...) }`) to honour -//! `usage.cost.total` verbatim. This test is the integration-level proof. +//! GJC may store embedded `usage.cost.total` values, but Tokscale local report +//! cost is token-derived. Both embedded-cost and no-cost messages must be +//! priced from their token buckets by the PricingService. use std::collections::HashMap; use std::io::Write; @@ -17,9 +13,8 @@ use tokscale_core::{parse_local_unified_messages_with_pricing, LocalParseOptions /// Build a minimal `PricingService` that knows about one model. /// input_cost = 0.001 per token, output_cost = 0.002 per token. -/// With 100 input tokens and 50 output tokens (message B below): +/// With 100 input tokens and 50 output tokens: /// recomputed = 100 * 0.001 + 50 * 0.002 = 0.100 + 0.100 = 0.200 -/// That is clearly != 0.3 (the embedded cost on message A). fn make_pricing_service() -> PricingService { let mut litellm_data: HashMap = HashMap::new(); litellm_data.insert( @@ -33,22 +28,19 @@ fn make_pricing_service() -> PricingService { PricingService::new(litellm_data, HashMap::new()) } -/// The expected recomputed cost for message B (no embedded cost): +/// The expected token-derived cost: /// 100 * 0.001 + 50 * 0.002 = 0.200 -const EXPECTED_RECOMPUTED_COST: f64 = 100.0 * 0.001 + 50.0 * 0.002; - -/// The embedded cost on message A. -const EXPECTED_EMBEDDED_COST: f64 = 0.3; +const EXPECTED_TOKEN_PRICED_COST: f64 = 100.0 * 0.001 + 50.0 * 0.002; -/// G7: Embedded cost wins; absent-cost messages get recomputed. +/// Embedded app cost is ignored; both messages get token pricing. /// /// - Message A: `gjc-priceable-model` WITH `usage.cost.total = 0.3` -/// → reported cost must equal 0.3 (embedded wins; N1 guard holds) -/// - Message B: `gjc-priceable-model` WITHOUT a cost object (cost = 0.0 in parser) -/// → reported cost must equal EXPECTED_RECOMPUTED_COST (repriced by PricingService) +/// -> reported cost must equal EXPECTED_TOKEN_PRICED_COST +/// - Message B: `gjc-priceable-model` WITHOUT a cost object +/// -> reported cost must also equal EXPECTED_TOKEN_PRICED_COST #[tokio::test] -async fn test_gjc_cost_precedence_end_to_end() { - // ── Build a temporary home directory with the gjc session file ────────── +async fn test_gjc_embedded_cost_is_ignored_end_to_end() { + // Build a temporary home directory with the gjc session file. let home_dir = tempfile::TempDir::new().expect("failed to create temp dir"); let home_path = home_dir.path(); @@ -92,10 +84,8 @@ async fn test_gjc_cost_precedence_end_to_end() { f.flush().expect("failed to flush"); } - // ── Build PricingService ───────────────────────────────────────────────── let pricing = make_pricing_service(); - // ── Call parse_local_unified_messages_with_pricing ─────────────────────── // use_env_roots: false ensures we only scan home-derived paths (no env vars). let options = LocalParseOptions { home_dir: Some(home_path.to_str().unwrap().to_string()), @@ -111,7 +101,6 @@ async fn test_gjc_cost_precedence_end_to_end() { .await .expect("parse failed"); - // ── Assertions ─────────────────────────────────────────────────────────── assert_eq!( messages.len(), 2, @@ -133,34 +122,21 @@ async fn test_gjc_cost_precedence_end_to_end() { assert_eq!(msg_b.client.as_ref(), "gjc"); assert_eq!(msg_b.model_id.as_ref(), "gjc-priceable-model"); - // G7 / A1: message A embedded cost MUST be preserved (0.3), NOT repriced. - // If this fails, the N1 binding is violated: the Hermes guard is overwriting - // authoritative embedded costs with recomputed values. assert!( - (msg_a.cost - EXPECTED_EMBEDDED_COST).abs() < 1e-10, - "G7 FAIL (N1 violation): message A cost should be embedded 0.3 but got {}", + (msg_a.cost - EXPECTED_TOKEN_PRICED_COST).abs() < 1e-10, + "message A cost should be token-priced {EXPECTED_TOKEN_PRICED_COST} but got {}", msg_a.cost ); - // G7 / A2: message B had no embedded cost — PricingService must have repriced it. - assert!( - (msg_b.cost - EXPECTED_RECOMPUTED_COST).abs() < 1e-10, - "G7 FAIL: message B cost should be recomputed {EXPECTED_RECOMPUTED_COST} but got {}", - msg_b.cost - ); - - // Sanity: the two values must be different (proves the test distinguishes them). assert!( - (msg_a.cost - msg_b.cost).abs() > 1e-10, - "G7 FAIL: embedded cost ({}) and recomputed cost ({}) must be different", - msg_a.cost, + (msg_b.cost - EXPECTED_TOKEN_PRICED_COST).abs() < 1e-10, + "message B cost should be token-priced {EXPECTED_TOKEN_PRICED_COST} but got {}", msg_b.cost ); - // Recomputed cost must be > 0 (confirms the PricingService actually fired). assert!( msg_b.cost > 0.0, - "G7 FAIL: recomputed cost for message B must be > 0, got {}", + "token-priced cost for message B must be > 0, got {}", msg_b.cost ); } diff --git a/crates/tokscale-core/tests/hermes.rs b/crates/tokscale-core/tests/hermes.rs index b2406ba25..57311d20b 100644 --- a/crates/tokscale-core/tests/hermes.rs +++ b/crates/tokscale-core/tests/hermes.rs @@ -76,7 +76,7 @@ fn test_parse_hermes_sqlite_reads_session_rows_and_preserves_message_count() { assert_eq!(msg.tokens.cache_read, 50); assert_eq!(msg.tokens.cache_write, 20); assert_eq!(msg.tokens.reasoning, 10); - assert_eq!(msg.cost, 0.34); + assert_eq!(msg.cost, 0.0); assert_eq!( msg.dedup_key, Some(tokscale_core::sessions::dedup_hash_str("session-1")) @@ -84,8 +84,7 @@ fn test_parse_hermes_sqlite_reads_session_rows_and_preserves_message_count() { } #[test] -fn test_parse_hermes_sqlite_skips_empty_sessions_and_falls_back_to_estimated_cost_and_provider_inference( -) { +fn test_parse_hermes_sqlite_skips_empty_sessions_and_uses_provider_inference() { let dir = TempDir::new().unwrap(); let db_path = create_test_db(&dir); let conn = Connection::open(&db_path).unwrap(); @@ -174,7 +173,7 @@ fn test_parse_hermes_sqlite_skips_empty_sessions_and_falls_back_to_estimated_cos let msg = &messages[0]; assert_eq!(msg.session_id.as_ref(), "session-valid"); assert_eq!(msg.provider_id.as_ref(), "openai"); - assert_eq!(msg.cost, 1.25); + assert_eq!(msg.cost, 0.0); assert_eq!(msg.message_count, 3); } diff --git a/docs/adr/0011-token-derived-local-cost.md b/docs/adr/0011-token-derived-local-cost.md new file mode 100644 index 000000000..0729ea93b --- /dev/null +++ b/docs/adr/0011-token-derived-local-cost.md @@ -0,0 +1,47 @@ +# ADR 0011: Local report cost derives from token pricing + +Status: Accepted + +## Context + +`personal/local-clients` reports two related but distinct values: + +1. token usage read from local client state +2. the equivalent cost of those tokens under Tokscale's pricing table + +Several upstream client parsers mixed those concerns by copying app-reported +spend, request cost, or credits into `UnifiedMessage.cost`. Those fields are +not comparable across products: some are subscription credits, some include +vendor markup, some are rounded UI totals, and some are session-level charges +without token buckets. Treating them as Tokscale cost makes cross-client +reports look precise while measuring different things. + +## Decision + +- Local parsers emit token usage. App/vendor fields such as `cost`, `credits`, + `cost_usd`, `dollar_float`, `spendCents`, `estimated_cost_usd`, + `actual_cost_usd`, and `usage.cost.total` are ignored. +- `UnifiedMessage.cost` in local reports is derived only by applying + Tokscale pricing to token buckets. If no pricing match exists, cost is + `0.0`. +- The pricing step clears any parser-provided cost before calculating. This + prevents old cached messages or future parser mistakes from preserving + app-reported cost. +- Rows with no positive token bucket are not usage rows. Cost-only or + credits-only records are dropped instead of being converted into zero-token + cost. +- Aggregate-only clients without a token-level source do not contribute usage + rows. Crush and Warp are disabled under this rule; Warp BYOK support can be + added later only if a token-level source is found. +- Cache schema changes that affect local cost semantics bump + `CACHE_SCHEMA_VERSION` so stale parser costs are rebuilt. + +## Consequences + +- Reports use one cost meaning across local clients: "what these tokens cost + under Tokscale pricing", not "what this app said it charged". +- App invoice totals may differ from Tokscale totals when the app applies + subscriptions, credits, bundled pricing, reseller markup, or rounding. +- Clients that only expose spend cannot be added as normal usage sources until + a token-level source is found. +- First run after this change rebuilds the source-message cache.