From edf28b6422b5c5072ea46146f265c5c41c1b284b Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 13 May 2026 10:27:29 +0800 Subject: [PATCH] feat(usage): filter-driven Hero with cache-normalized totals - Normalize OpenAI/Gemini input_tokens semantics in SQL via the new fresh_input_sql helper (cache_read subtracted at query time, no data migration). Recovers correct cache hit rates for Codex/Gemini. - Add get_usage_summary_by_app endpoint for per-app split (single UNION ALL + GROUP BY, avoids N+1). - Replace UsageSummaryCards + AppBreakdownRail with a single filter-driven UsageHero card; clicking a filter button now truly changes the displayed numbers and the title accent color. - Tighten KNOWN_APP_TYPES to the 3 app_types whose token data is reliably collected (claude/codex/gemini); hide claude-desktop, hermes, opencode, openclaw filter buttons and i18n keys. - Flag cache_creation as N/A for OpenAI-style protocols (Codex, Gemini); show a "partial" tooltip when the All view mixes both protocol families. --- src-tauri/src/commands/usage.rs | 10 + src-tauri/src/lib.rs | 1 + src-tauri/src/services/mod.rs | 3 +- src-tauri/src/services/sql_helpers.rs | 135 ++++++++ src-tauri/src/services/usage_stats.rs | 232 +++++++++++-- src/components/usage/RequestDetailPanel.tsx | 16 +- src/components/usage/RequestLogTable.tsx | 35 +- src/components/usage/UsageDashboard.tsx | 19 +- src/components/usage/UsageHero.tsx | 357 ++++++++++++++++++++ src/components/usage/UsageSummaryCards.tsx | 173 ---------- src/components/usage/format.ts | 34 ++ src/i18n/locales/en.json | 10 +- src/i18n/locales/ja.json | 10 +- src/i18n/locales/zh.json | 10 +- src/lib/api/usage.ts | 8 + src/lib/query/usage.ts | 31 ++ src/types/usage.ts | 66 +++- 17 files changed, 931 insertions(+), 219 deletions(-) create mode 100644 src-tauri/src/services/sql_helpers.rs create mode 100644 src/components/usage/UsageHero.tsx delete mode 100644 src/components/usage/UsageSummaryCards.tsx diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 8c9047310..1401adb0a 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -18,6 +18,16 @@ pub fn get_usage_summary( .get_usage_summary(start_date, end_date, app_type.as_deref()) } +/// 获取按 app_type 拆分的使用量汇总 +#[tauri::command] +pub fn get_usage_summary_by_app( + state: State<'_, AppState>, + start_date: Option, + end_date: Option, +) -> Result, AppError> { + state.db.get_usage_summary_by_app(start_date, end_date) +} + /// 获取每日趋势 #[tauri::command] pub fn get_usage_trends( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f0c8fd1ab..9abae6a28 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1230,6 +1230,7 @@ pub fn run() { commands::set_auto_failover_enabled, // Usage statistics commands::get_usage_summary, + commands::get_usage_summary_by_app, commands::get_usage_trends, commands::get_provider_stats, commands::get_model_stats, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index d9fb5077c..8748792e2 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -14,6 +14,7 @@ pub mod session_usage_codex; pub mod session_usage_gemini; pub mod skill; pub mod speedtest; +pub mod sql_helpers; pub mod stream_check; pub mod subscription; pub mod usage_cache; @@ -35,5 +36,5 @@ pub use usage_cache::UsageCache; #[allow(unused_imports)] pub use usage_stats::{ DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats, - RequestLogDetail, UsageSummary, + RequestLogDetail, UsageSummary, UsageSummaryByApp, }; diff --git a/src-tauri/src/services/sql_helpers.rs b/src-tauri/src/services/sql_helpers.rs new file mode 100644 index 000000000..e819e04bf --- /dev/null +++ b/src-tauri/src/services/sql_helpers.rs @@ -0,0 +1,135 @@ +//! SQL fragment helpers shared across usage aggregation queries. +//! +//! Anthropic reports `input_tokens` as fresh (cache reads counted +//! separately); OpenAI Responses API and Google Gemini's +//! `promptTokenCount` both include the cached portion. Any aggregation +//! summing `input_tokens` across providers must route through +//! [`fresh_input_sql`] to recover a consistent semantics. + +/// Set of `app_type` values whose stored `input_tokens` already includes +/// `cache_read_tokens`. Aggregations subtract cache reads from these rows +/// to recover the fresh-input semantics used by Claude. +/// +/// Why list providers explicitly: new providers default to the +/// Claude-style "input excludes cache" semantics, which is safer if the +/// caller forgets to update this list. The wrong direction (a new OpenAI- +/// style provider not added here) shows up loudly as a too-low cache hit +/// rate, which is easier to catch than the silent over-deduction that +/// would happen with the opposite default. +const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"]; + +/// Build an SQL expression that returns the cache-normalized `input_tokens` +/// for a single row in `proxy_request_logs` or `usage_daily_rollups`. +/// +/// For rows whose `app_type` is in [`CACHE_INCLUSIVE_APP_TYPES`] and +/// `input_tokens >= cache_read_tokens`, returns +/// `input_tokens - cache_read_tokens`. For all other rows the original +/// `input_tokens` is returned unchanged. +/// +/// Pass an empty string to reference the columns directly (no alias), +/// or a table alias such as `"l"` to emit `l.input_tokens` style references. +pub fn fresh_input_sql(alias: &str) -> String { + let prefix = if alias.is_empty() { + String::new() + } else { + format!("{alias}.") + }; + let app_type_list = CACHE_INCLUSIVE_APP_TYPES + .iter() + .map(|t| format!("'{t}'")) + .collect::>() + .join(", "); + format!( + "CASE WHEN {prefix}app_type IN ({app_type_list}) AND {prefix}input_tokens >= {prefix}cache_read_tokens \ + THEN ({prefix}input_tokens - {prefix}cache_read_tokens) \ + ELSE {prefix}input_tokens END" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn setup_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE proxy_request_logs ( + request_id TEXT PRIMARY KEY, + app_type TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0 + );", + ) + .unwrap(); + conn + } + + #[test] + fn fresh_input_with_alias_emits_prefixed_columns() { + let sql = fresh_input_sql("l"); + assert!(sql.contains("l.app_type")); + assert!(sql.contains("l.input_tokens")); + assert!(sql.contains("l.cache_read_tokens")); + } + + #[test] + fn fresh_input_without_alias_uses_bare_columns() { + let sql = fresh_input_sql(""); + assert!(!sql.contains(".")); + assert!(sql.contains("'codex'")); + assert!(sql.contains("'gemini'")); + } + + #[test] + fn fresh_input_subtracts_cache_for_cache_inclusive_providers() { + let conn = setup_conn(); + // Codex row: OpenAI semantics — input_tokens includes the 600 cached. + conn.execute( + "INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens) + VALUES ('codex-1', 'codex', 1000, 600)", + [], + ) + .unwrap(); + // Gemini row: Google semantics — promptTokenCount includes cachedContentTokenCount. + conn.execute( + "INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens) + VALUES ('gemini-1', 'gemini', 800, 300)", + [], + ) + .unwrap(); + // Claude row: Anthropic semantics — input_tokens already excludes cache. + conn.execute( + "INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens) + VALUES ('claude-1', 'claude', 200, 5000)", + [], + ) + .unwrap(); + + let expr = fresh_input_sql("l"); + let sql = format!("SELECT COALESCE(SUM({expr}), 0) FROM proxy_request_logs l"); + let total: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap(); + // Codex: 1000-600=400; Gemini: 800-300=500; Claude: 200 unchanged. + assert_eq!(total, 400 + 500 + 200); + } + + #[test] + fn fresh_input_handles_codex_with_cache_exceeding_input() { + // Defensive: if a malformed Codex row somehow has cache > input, + // we keep the original value rather than producing a negative number. + let conn = setup_conn(); + conn.execute( + "INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens) + VALUES ('codex-broken', 'codex', 100, 999)", + [], + ) + .unwrap(); + let expr = fresh_input_sql("l"); + let sql = format!("SELECT {expr} FROM proxy_request_logs l"); + let value: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap(); + assert_eq!(value, 100); + } + +} diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index e0746a6a6..a3a186eb2 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -4,6 +4,7 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; +use crate::services::sql_helpers::fresh_input_sql; use chrono::{Local, NaiveDate, TimeZone, Timelike}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; @@ -22,6 +23,39 @@ pub struct UsageSummary { pub total_cache_creation_tokens: u64, pub total_cache_read_tokens: u64, pub success_rate: f32, + /// input + output + cache_creation + cache_read — the total tokens + /// actually processed by the model (including cache hits). Used as the + /// headline "real consumption" number in the usage hero. + pub real_total_tokens: u64, + /// cache_read / (input + cache_creation + cache_read). Range 0.0–1.0. + /// Reported as a fraction; multiply by 100 in UI for percentage display. + pub cache_hit_rate: f64, +} + +/// Per-app-type usage summary used by the dashboard breakdown rail. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageSummaryByApp { + pub app_type: String, + pub summary: UsageSummary, +} + +/// Helper: compute (real_total, hit_rate) from the four token counters. +/// All inputs must already be cache-normalized (i.e. input excludes cache). +fn derive_real_total_and_hit_rate( + fresh_input: u64, + output: u64, + cache_creation: u64, + cache_read: u64, +) -> (u64, f64) { + let real_total = fresh_input + output + cache_creation + cache_read; + let cacheable_input = fresh_input + cache_creation + cache_read; + let hit_rate = if cacheable_input > 0 { + cache_read as f64 / cacheable_input as f64 + } else { + 0.0 + }; + (real_total, hit_rate) } /// 每日统计 @@ -451,6 +485,8 @@ impl Database { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let fresh_input_detail = fresh_input_sql("l"); + let fresh_input_rollup = fresh_input_sql(""); let sql = format!( "SELECT COALESCE(d.total_requests, 0) + COALESCE(r.total_requests, 0), @@ -463,17 +499,17 @@ impl Database { FROM (SELECT COUNT(*) as total_requests, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM({fresh_input_detail}), 0) as total_input_tokens, + COALESCE(SUM(l.output_tokens), 0) as total_output_tokens, + COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count FROM proxy_request_logs l {where_clause}) d, (SELECT COALESCE(SUM(request_count), 0) as total_requests, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM({fresh_input_rollup}), 0) as total_input_tokens, COALESCE(SUM(output_tokens), 0) as total_output_tokens, COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, @@ -501,6 +537,13 @@ impl Database { 0.0 }; + let (real_total_tokens, cache_hit_rate) = derive_real_total_and_hit_rate( + total_input_tokens as u64, + total_output_tokens as u64, + total_cache_creation_tokens as u64, + total_cache_read_tokens as u64, + ); + Ok(UsageSummary { total_requests: total_requests as u64, total_cost: format!("{total_cost:.6}"), @@ -509,12 +552,146 @@ impl Database { total_cache_creation_tokens: total_cache_creation_tokens as u64, total_cache_read_tokens: total_cache_read_tokens as u64, success_rate, + real_total_tokens, + cache_hit_rate, }) })?; Ok(result) } + /// 按 app_type 维度拆分的使用量汇总,用于 Dashboard 的分应用展示条。 + /// 返回所有有数据的 app_type,按 real_total_tokens 降序。 + /// + /// Single SQL with `GROUP BY app_type` — avoids the N+1 round-trip that + /// would result from invoking `get_usage_summary` once per app_type. + pub fn get_usage_summary_by_app( + &self, + start_date: Option, + end_date: Option, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let mut detail_conditions = vec![effective_usage_log_filter("l")]; + let mut detail_params: Vec> = Vec::new(); + if let Some(start) = start_date { + detail_conditions.push("l.created_at >= ?".to_string()); + detail_params.push(Box::new(start)); + } + if let Some(end) = end_date { + detail_conditions.push("l.created_at <= ?".to_string()); + detail_params.push(Box::new(end)); + } + let detail_where = format!("WHERE {}", detail_conditions.join(" AND ")); + + let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?; + let mut rollup_conditions: Vec = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); + push_rollup_date_filters( + &mut rollup_conditions, + &mut rollup_params, + "date", + &rollup_bounds, + ); + let rollup_where = if rollup_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", rollup_conditions.join(" AND ")) + }; + + let fresh_input_detail = fresh_input_sql("l"); + let fresh_input_rollup = fresh_input_sql(""); + + let sql = format!( + "SELECT app_type, + SUM(req_count) as req_count, + SUM(cost) as cost, + SUM(input_t) as input_t, + SUM(output_t) as output_t, + SUM(cache_create_t) as cache_create_t, + SUM(cache_read_t) as cache_read_t, + SUM(success_count) as success_count + FROM ( + SELECT l.app_type, + COUNT(*) as req_count, + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as cost, + COALESCE(SUM({fresh_input_detail}), 0) as input_t, + COALESCE(SUM(l.output_tokens), 0) as output_t, + COALESCE(SUM(l.cache_creation_tokens), 0) as cache_create_t, + COALESCE(SUM(l.cache_read_tokens), 0) as cache_read_t, + COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count + FROM proxy_request_logs l {detail_where} + GROUP BY l.app_type + UNION ALL + SELECT app_type, + COALESCE(SUM(request_count), 0), + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), + COALESCE(SUM({fresh_input_rollup}), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cache_creation_tokens), 0), + COALESCE(SUM(cache_read_tokens), 0), + COALESCE(SUM(success_count), 0) + FROM usage_daily_rollups {rollup_where} + GROUP BY app_type + ) + GROUP BY app_type" + ); + + let mut combined: Vec> = detail_params; + combined.extend(rollup_params); + let refs: Vec<&dyn rusqlite::ToSql> = combined.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(refs.as_slice(), |row| { + let app_type: String = row.get(0)?; + let total_requests: i64 = row.get(1)?; + let total_cost: f64 = row.get(2)?; + let total_input_tokens: i64 = row.get(3)?; + let total_output_tokens: i64 = row.get(4)?; + let total_cache_creation_tokens: i64 = row.get(5)?; + let total_cache_read_tokens: i64 = row.get(6)?; + let success_count: i64 = row.get(7)?; + + let success_rate = if total_requests > 0 { + (success_count as f32 / total_requests as f32) * 100.0 + } else { + 0.0 + }; + let (real_total_tokens, cache_hit_rate) = derive_real_total_and_hit_rate( + total_input_tokens as u64, + total_output_tokens as u64, + total_cache_creation_tokens as u64, + total_cache_read_tokens as u64, + ); + + Ok(UsageSummaryByApp { + app_type, + summary: UsageSummary { + total_requests: total_requests as u64, + total_cost: format!("{total_cost:.6}"), + total_input_tokens: total_input_tokens as u64, + total_output_tokens: total_output_tokens as u64, + total_cache_creation_tokens: total_cache_creation_tokens as u64, + total_cache_read_tokens: total_cache_read_tokens as u64, + success_rate, + real_total_tokens, + cache_hit_rate, + }, + }) + })?; + + let mut summaries = Vec::new(); + for row in rows { + let item = row?; + if item.summary.total_requests == 0 && item.summary.real_total_tokens == 0 { + continue; + } + summaries.push(item); + } + summaries.sort_by(|a, b| b.summary.real_total_tokens.cmp(&a.summary.real_total_tokens)); + Ok(summaries) + } + /// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致) pub fn get_daily_trends( &self, @@ -551,13 +728,14 @@ impl Database { }; let effective_filter = effective_usage_log_filter("l"); + let fresh_input = fresh_input_sql("l"); let sql = format!( "SELECT CAST((l.created_at - ?1) / ?3 AS INTEGER) as bucket_idx, COUNT(*) as request_count, COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, - COALESCE(SUM(l.input_tokens), 0) as total_input_tokens, + COALESCE(SUM({fresh_input} + l.output_tokens), 0) as total_tokens, + COALESCE(SUM({fresh_input}), 0) as total_input_tokens, COALESCE(SUM(l.output_tokens), 0) as total_output_tokens, COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens @@ -640,13 +818,14 @@ impl Database { }; let effective_filter = effective_usage_log_filter("l"); + let fresh_input = fresh_input_sql("l"); let detail_sql = format!( "SELECT date(l.created_at, 'unixepoch', 'localtime') as bucket_date, COUNT(*) as request_count, COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, - COALESCE(SUM(l.input_tokens), 0) as total_input_tokens, + COALESCE(SUM({fresh_input} + l.output_tokens), 0) as total_tokens, + COALESCE(SUM({fresh_input}), 0) as total_input_tokens, COALESCE(SUM(l.output_tokens), 0) as total_output_tokens, COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens @@ -708,13 +887,14 @@ impl Database { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let fresh_input_rollup = fresh_input_sql(""); let rollup_sql = format!( "SELECT date, COALESCE(SUM(request_count), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), - COALESCE(SUM(input_tokens + output_tokens), 0), - COALESCE(SUM(input_tokens), 0), + COALESCE(SUM({fresh_input_rollup} + output_tokens), 0), + COALESCE(SUM({fresh_input_rollup}), 0), COALESCE(SUM(output_tokens), 0), COALESCE(SUM(cache_creation_tokens), 0), COALESCE(SUM(cache_read_tokens), 0) @@ -845,6 +1025,8 @@ impl Database { // UNION detail logs + rollup data, then aggregate let detail_pname = provider_name_coalesce("l", "p"); let rollup_pname = provider_name_coalesce("r", "p2"); + let fresh_input_detail = fresh_input_sql("l"); + let fresh_input_rollup = fresh_input_sql("r"); let sql = format!( "SELECT provider_id, app_type, provider_name, @@ -859,7 +1041,7 @@ impl Database { SELECT l.provider_id, l.app_type, {detail_pname} as provider_name, COUNT(*) as request_count, - COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, + COALESCE(SUM({fresh_input_detail} + l.output_tokens), 0) as total_tokens, COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost, COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count, COALESCE(SUM(l.latency_ms), 0) as latency_sum @@ -871,7 +1053,7 @@ impl Database { SELECT r.provider_id, r.app_type, {rollup_pname} as provider_name, COALESCE(SUM(r.request_count), 0), - COALESCE(SUM(r.input_tokens + r.output_tokens), 0), + COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0), COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0), COALESCE(SUM(r.success_count), 0), COALESCE(SUM(r.avg_latency_ms * r.request_count), 0) @@ -967,6 +1149,8 @@ impl Database { }; // UNION detail logs + rollup data + let fresh_input_detail = fresh_input_sql("l"); + let fresh_input_rollup = fresh_input_sql("r"); let sql = format!( "SELECT model, @@ -976,16 +1160,16 @@ impl Database { FROM ( SELECT l.model, COUNT(*) as request_count, - COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, + COALESCE(SUM({fresh_input_detail} + l.output_tokens), 0) as total_tokens, COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost FROM proxy_request_logs l {detail_where} GROUP BY l.model UNION ALL SELECT r.model, - COALESCE(SUM(request_count), 0), - COALESCE(SUM(input_tokens + output_tokens), 0), - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) + COALESCE(SUM(r.request_count), 0), + COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0), + COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0) FROM usage_daily_rollups r {rollup_where} GROUP BY r.model @@ -1962,10 +2146,18 @@ mod tests { let summary = db.get_usage_summary(None, None, None)?; assert_eq!(summary.total_requests, 4); - assert_eq!(summary.total_input_tokens, 650); + // codex-proxy contributes 100-10=90; gemini-proxy contributes 200-30=170 + // (both cache-inclusive providers). claude-proxy=300, codex-session-only=50. + // 90 + 170 + 300 + 50 = 610. + assert_eq!(summary.total_input_tokens, 610); assert_eq!(summary.total_output_tokens, 125); assert_eq!(summary.total_cache_read_tokens, 60); assert_eq!(summary.total_cache_creation_tokens, 12); + // real_total = fresh_input(610) + output(125) + cache_create(12) + cache_read(60) = 807 + assert_eq!(summary.real_total_tokens, 807); + // hit_rate = 60 / (610 + 12 + 60) = 60 / 682 + let expected_hit_rate = 60.0_f64 / 682.0_f64; + assert!((summary.cache_hit_rate - expected_hit_rate).abs() < 1e-9); let trends = db.get_daily_trends(Some(0), Some(40_000), None)?; assert_eq!(trends.iter().map(|stat| stat.request_count).sum::(), 4); diff --git a/src/components/usage/RequestDetailPanel.tsx b/src/components/usage/RequestDetailPanel.tsx index 4b9b191c3..9f21f7049 100644 --- a/src/components/usage/RequestDetailPanel.tsx +++ b/src/components/usage/RequestDetailPanel.tsx @@ -6,6 +6,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useRequestDetail } from "@/lib/query/usage"; +import { getFreshInputTokens } from "@/types/usage"; interface RequestDetailPanelProps { requestId: string; @@ -50,6 +51,9 @@ export function RequestDetailPanel({ ); } + const freshInput = getFreshInputTokens(request); + const isCacheInclusive = request.inputTokens !== freshInput; + return ( @@ -135,7 +139,13 @@ export function RequestDetailPanel({ {t("usage.inputTokens", "输入 Tokens")}
- {request.inputTokens.toLocaleString()} + {freshInput.toLocaleString()} + {isCacheInclusive && ( + + ({t("usage.rawInputLabel", "原始")}:{" "} + {request.inputTokens.toLocaleString()}) + + )}
@@ -167,9 +177,7 @@ export function RequestDetailPanel({ {t("usage.totalTokens", "总计")}
- {( - request.inputTokens + request.outputTokens - ).toLocaleString()} + {(freshInput + request.outputTokens).toLocaleString()}
diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index f1f2835f9..7d824f94d 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -18,7 +18,12 @@ import { SelectValue, } from "@/components/ui/select"; import { useRequestLogs } from "@/lib/query/usage"; -import type { LogFilters, UsageRangeSelection } from "@/types/usage"; +import { + KNOWN_APP_TYPES, + getFreshInputTokens, + type LogFilters, + type UsageRangeSelection, +} from "@/types/usage"; import { ChevronLeft, ChevronRight, Search, X } from "lucide-react"; import { UsageDateRangePicker } from "./UsageDateRangePicker"; import { @@ -138,9 +143,11 @@ export function RequestLogTable({ {t("usage.allApps")} - Claude - Codex - Gemini + {KNOWN_APP_TYPES.map((at) => ( + + {t(`usage.appFilter.${at}`, { defaultValue: at })} + + ))} @@ -323,9 +330,23 @@ export function RequestLogTable({ -
- {fmtInt(log.inputTokens, locale)} -
+ {(() => { + const freshInput = getFreshInputTokens(log); + const isCacheInclusive = + log.inputTokens !== freshInput; + return ( +
+ {fmtInt(freshInput, locale)} +
+ ); + })()} {(log.cacheReadTokens > 0 || log.cacheCreationTokens > 0) && (
diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 97bc1a80c..f9de1cf60 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -1,11 +1,15 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { UsageSummaryCards } from "./UsageSummaryCards"; +import { UsageHero } from "./UsageHero"; import { UsageTrendChart } from "./UsageTrendChart"; import { RequestLogTable } from "./RequestLogTable"; import { ProviderStatsTable } from "./ProviderStatsTable"; import { ModelStatsTable } from "./ModelStatsTable"; -import type { AppTypeFilter, UsageRangeSelection } from "@/types/usage"; +import { + KNOWN_APP_TYPES, + type AppTypeFilter, + type UsageRangeSelection, +} from "@/types/usage"; import { motion } from "framer-motion"; import { BarChart3, @@ -30,12 +34,7 @@ import { getUsageRangePresetLabel, resolveUsageRange } from "@/lib/usageRange"; import { UsageDateRangePicker } from "./UsageDateRangePicker"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -const APP_FILTER_OPTIONS: AppTypeFilter[] = [ - "all", - "claude", - "codex", - "gemini", -]; +const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES]; export function UsageDashboard() { const { t, i18n } = useTranslation(); @@ -127,9 +126,9 @@ export function UsageDashboard() {
- diff --git a/src/components/usage/UsageHero.tsx b/src/components/usage/UsageHero.tsx new file mode 100644 index 000000000..dc7b5f28c --- /dev/null +++ b/src/components/usage/UsageHero.tsx @@ -0,0 +1,357 @@ +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { Card, CardContent } from "@/components/ui/card"; +import { useUsageSummaryByApp } from "@/lib/query/usage"; +import { cn } from "@/lib/utils"; +import { + Activity, + ArrowDownToLine, + ArrowUpFromLine, + Database, + Info, + Loader2, + Sparkles, + Zap, +} from "lucide-react"; +import { + fmtUsd, + formatTokensShort, + getResolvedLang, + parseFiniteNumber, +} from "./format"; +import { + CACHE_INCLUSIVE_APP_TYPES, + type AppType, + type UsageRangeSelection, + type UsageSummary, + type UsageSummaryByApp, +} from "@/types/usage"; + +interface UsageHeroProps { + range: UsageRangeSelection; + appType?: string; + refreshIntervalMs: number; +} + +interface TitleTheme { + /** Foreground color for the icon glyph (text-* class). */ + accent: string; + /** Background tint for the icon square (bg-* class). */ + iconBg: string; +} + +const TITLE_THEMES: Record = { + all: { accent: "text-primary", iconBg: "bg-primary/10" }, + claude: { + accent: "text-amber-600 dark:text-amber-400", + iconBg: "bg-amber-500/10", + }, + codex: { + accent: "text-emerald-600 dark:text-emerald-400", + iconBg: "bg-emerald-500/10", + }, + gemini: { + accent: "text-sky-600 dark:text-sky-400", + iconBg: "bg-sky-500/10", + }, +}; + +/** + * Combine per-app summaries into a single rolled-up summary. + * + * The backend's per-app rows already use fresh-input semantics (cache-inclusive + * providers have been normalized in SQL), so plain addition is correct here. + * `cacheHitRate` and `successRate` must be re-derived from the summed counts + * rather than averaged across rows. + */ +function aggregateSummaries(items: UsageSummary[]): UsageSummary { + let totalRequests = 0; + let successCount = 0; + let totalCostNum = 0; + let input = 0; + let output = 0; + let cacheCreation = 0; + let cacheRead = 0; + + for (const s of items) { + totalRequests += s.totalRequests; + successCount += Math.round((s.totalRequests * s.successRate) / 100); + totalCostNum += parseFiniteNumber(s.totalCost) ?? 0; + input += s.totalInputTokens; + output += s.totalOutputTokens; + cacheCreation += s.totalCacheCreationTokens; + cacheRead += s.totalCacheReadTokens; + } + + const cacheableInput = input + cacheCreation + cacheRead; + return { + totalRequests, + totalCost: totalCostNum.toFixed(6), + totalInputTokens: input, + totalOutputTokens: output, + totalCacheCreationTokens: cacheCreation, + totalCacheReadTokens: cacheRead, + successRate: totalRequests > 0 ? (successCount / totalRequests) * 100 : 0, + realTotalTokens: input + output + cacheCreation + cacheRead, + cacheHitRate: cacheableInput > 0 ? cacheRead / cacheableInput : 0, + }; +} + +function pickSummary( + apps: UsageSummaryByApp[], + appType: string | undefined, +): UsageSummary | undefined { + if (apps.length === 0) return undefined; + if (appType) { + return apps.find((a) => a.appType === appType)?.summary; + } + return aggregateSummaries(apps.map((a) => a.summary)); +} + +type CacheWriteState = "ok" | "partial" | "na"; + +/** + * Anthropic-style protocols report cache creation; OpenAI-style protocols + * (Codex/Gemini) do not — so a mix shows the number with a caveat, all-OpenAI + * shows N/A. `appTypes` is the set actually contributing to the displayed + * summary (a single app, or every app that participated in "all"). + */ +function deriveCacheWriteState(appTypes: string[]): CacheWriteState { + if (appTypes.length === 0) return "ok"; + const inclusive = appTypes.filter((t) => + CACHE_INCLUSIVE_APP_TYPES.has(t), + ).length; + if (inclusive === appTypes.length) return "na"; + if (inclusive === 0) return "ok"; + return "partial"; +} + +export function UsageHero({ + range, + appType, + refreshIntervalMs, +}: UsageHeroProps) { + const { t, i18n } = useTranslation(); + const lang = getResolvedLang(i18n); + + const { data, isLoading } = useUsageSummaryByApp(range, { + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }); + + // No client-side filtering: Hero's totals must match the Trend/Logs/Stats + // below, which all go through the backend's full set of app_types. The + // KNOWN_APP_TYPES list only governs which filter buttons appear, not which + // rows participate in the "all" aggregate. + const allApps = data ?? []; + const summary = pickSummary(allApps, appType); + + const titleTheme = + TITLE_THEMES[(appType ?? "all") as keyof typeof TITLE_THEMES] ?? + TITLE_THEMES.all; + const appLabel = + appType && appType in TITLE_THEMES ? t(`usage.appFilter.${appType}`) : null; + + const cacheWriteState = deriveCacheWriteState( + appType ? [appType] : allApps.map((a) => a.appType), + ); + + const input = summary?.totalInputTokens ?? 0; + const output = summary?.totalOutputTokens ?? 0; + const cacheWrite = summary?.totalCacheCreationTokens ?? 0; + const cacheRead = summary?.totalCacheReadTokens ?? 0; + const realTotal = summary?.realTotalTokens ?? 0; + const hitRate = summary?.cacheHitRate ?? 0; + const totalCost = parseFiniteNumber(summary?.totalCost); + const requests = summary?.totalRequests ?? 0; + + const cacheWriteDisplay = { + value: + cacheWriteState === "na" ? "N/A" : formatTokensShort(cacheWrite, lang), + muted: cacheWriteState === "na", + tooltip: + cacheWriteState === "na" + ? t( + "usage.cacheWriteNotReported", + "OpenAI 协议不区分缓存写入,仅上报缓存命中", + ) + : cacheWriteState === "partial" + ? t( + "usage.cacheWritePartial", + "部分协议(如 OpenAI)不上报缓存写入,数值可能偏低", + ) + : undefined, + }; + + if (isLoading) { + return ( + + + + + + ); + } + + const hitPercent = Math.max(0, Math.min(100, hitRate * 100)); + const hitPercentLabel = hitPercent.toFixed(hitPercent >= 99.95 ? 0 : 1); + + return ( + + + + {/* Header: title + cost */} +
+
+
+ +
+ + {appLabel && ( + <> + + {appLabel} + + · + + )} + {t("usage.realTotal", "真实消耗 Tokens")} + +
+
+
+ + {t("usage.totalRequests")} + + + + {requests.toLocaleString()} + +
+
+ + {t("usage.totalCost")} + + + {totalCost == null ? "--" : fmtUsd(totalCost, 4)} + +
+
+
+ + {/* Hero number */} +
+
+ {realTotal.toLocaleString()} +
+
+ ≈ {formatTokensShort(realTotal, lang, 2)}{" "} + {t("usage.tokensSuffix", "tokens")} +
+
+ + {/* Breakdown row: 4 mini stats */} +
+ } + label={t("usage.freshInput", "新增输入")} + value={formatTokensShort(input, lang)} + accent="text-blue-500" + /> + } + label={t("usage.output")} + value={formatTokensShort(output, lang)} + accent="text-purple-500" + /> + } + label={t("usage.cacheWrite", "缓存写入")} + value={cacheWriteDisplay.value} + accent="text-amber-500" + muted={cacheWriteDisplay.muted} + tooltip={cacheWriteDisplay.tooltip} + /> + } + label={t("usage.cacheRead", "缓存命中")} + value={formatTokensShort(cacheRead, lang)} + accent="text-emerald-500" + /> +
+ + {/* Hit rate progress */} +
+
+ + {t("usage.cacheHitRate", "缓存命中率")} + + + {hitPercentLabel}% + +
+
+ +
+
+
+
+
+ ); +} + +interface MiniStatProps { + icon: React.ReactNode; + label: string; + value: string; + accent: string; + /** Optional hover tooltip — used to flag protocol-level caveats. */ + tooltip?: string; + /** Visually de-emphasize the value (e.g. for "N/A" cases). */ + muted?: boolean; +} + +function MiniStat({ + icon, + label, + value, + accent, + tooltip, + muted, +}: MiniStatProps) { + return ( +
+
+ {icon} + {label} + {tooltip && ( + + )} +
+
+ {value} +
+
+ ); +} diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx deleted file mode 100644 index 5542c8dad..000000000 --- a/src/components/usage/UsageSummaryCards.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Card, CardContent } from "@/components/ui/card"; -import { useUsageSummary } from "@/lib/query/usage"; -import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react"; -import { motion } from "framer-motion"; -import { fmtUsd, parseFiniteNumber } from "./format"; -import type { UsageRangeSelection } from "@/types/usage"; - -interface UsageSummaryCardsProps { - range: UsageRangeSelection; - appType?: string; - refreshIntervalMs: number; -} - -export function UsageSummaryCards({ - range, - appType, - refreshIntervalMs, -}: UsageSummaryCardsProps) { - const { t } = useTranslation(); - - const { data: summary, isLoading } = useUsageSummary(range, appType, { - refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, - }); - - const stats = useMemo(() => { - const totalRequests = summary?.totalRequests ?? 0; - const totalCost = parseFiniteNumber(summary?.totalCost); - - const inputTokens = summary?.totalInputTokens ?? 0; - const outputTokens = summary?.totalOutputTokens ?? 0; - const totalTokens = inputTokens + outputTokens; - - const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0; - const cacheReadTokens = summary?.totalCacheReadTokens ?? 0; - const totalCacheTokens = cacheWriteTokens + cacheReadTokens; - - return [ - { - title: t("usage.totalRequests"), - value: totalRequests.toLocaleString(), - icon: Activity, - color: "text-blue-500", - bg: "bg-blue-500/10", - subValue: null, - }, - { - title: t("usage.totalCost"), - value: totalCost == null ? "--" : fmtUsd(totalCost, 4), - icon: DollarSign, - color: "text-green-500", - bg: "bg-green-500/10", - subValue: null, - }, - { - title: t("usage.totalTokens"), - value: totalTokens.toLocaleString(), - icon: Layers, - color: "text-purple-500", - bg: "bg-purple-500/10", - subValue: ( -
-
- {t("usage.input")} - - {(inputTokens / 1000).toFixed(1)}k - -
-
- {t("usage.output")} - - {(outputTokens / 1000).toFixed(1)}k - -
-
- ), - }, - { - title: t("usage.cacheTokens"), - value: totalCacheTokens.toLocaleString(), - icon: Database, - color: "text-orange-500", - bg: "bg-orange-500/10", - subValue: ( -
-
- {t("usage.cacheWrite")} - - {(cacheWriteTokens / 1000).toFixed(1)}k - -
-
- {t("usage.cacheRead")} - - {(cacheReadTokens / 1000).toFixed(1)}k - -
-
- ), - }, - ]; - }, [summary, t]); - - const container = { - hidden: { opacity: 0 }, - show: { - opacity: 1, - transition: { - staggerChildren: 0.1, - }, - }, - }; - - const item = { - hidden: { opacity: 0, y: 20 }, - show: { opacity: 1, y: 0 }, - }; - - if (isLoading) { - return ( -
- {[...Array(4)].map((_, i) => ( - - - - - - ))} -
- ); - } - - return ( - - {stats.map((stat, i) => ( - - - -
-

- {stat.title} -

-
- -
-
- -
-

- {stat.value} -

-
- - {stat.subValue || ( - /* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */ -
- )} -
-
-
- ))} -
- ); -} diff --git a/src/components/usage/format.ts b/src/components/usage/format.ts index e6df42238..404f4b056 100644 --- a/src/components/usage/format.ts +++ b/src/components/usage/format.ts @@ -37,3 +37,37 @@ export function getLocaleFromLanguage(language: string): string { if (language.startsWith("ja")) return "ja-JP"; return "en-US"; } + +interface I18nLike { + resolvedLanguage?: string; + language?: string; +} + +export function getResolvedLang(i18n: I18nLike): string { + return i18n.resolvedLanguage || i18n.language || "en"; +} + +/** + * Token 数量的紧凑显示。 + * + * Why: 中日文用户期待 "亿/万" 量纲;英文用户期待 K/M/B。共用同一份格式化 + * 逻辑避免 Hero 卡和分应用卡显示不一致。`compactDecimals=2` 用于 Hero + * 大数副标(更精确),默认 1 位用于卡片副字段。 + */ +export function formatTokensShort( + value: number, + lang: string, + compactDecimals: 1 | 2 = 1, +): string { + if (!Number.isFinite(value) || value <= 0) return "0"; + const decimals = compactDecimals; + if (lang.startsWith("zh") || lang.startsWith("ja")) { + if (value >= 1e8) return `${(value / 1e8).toFixed(2)} 亿`; + if (value >= 1e4) return `${(value / 1e4).toFixed(decimals)} 万`; + return value.toLocaleString(); + } + if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`; + if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`; + if (value >= 1e3) return `${(value / 1e3).toFixed(decimals)}K`; + return value.toLocaleString(); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0f06444bd..02c96e0bc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1135,8 +1135,13 @@ "rangeToday": "Last 24 hours (hourly)", "rangeLast7Days": "Last 7 days", "rangeLast30Days": "Last 30 days", - "totalTokens": "Total Tokens", + "totalTokens": "New Tokens (Input+Output)", "cacheTokens": "Cache Tokens", + "realTotal": "Tokens Processed", + "realTotalHint": "Input + Output + Cache Write + Cache Read", + "tokensSuffix": "tokens", + "freshInput": "Fresh Input", + "cacheHitRate": "Cache Hit Rate", "requestLogs": "Request Logs", "providerStats": "Provider Stats", "modelStats": "Model Stats", @@ -1165,6 +1170,7 @@ "codex": "Codex", "gemini": "Gemini" }, + "rawInputLabel": "Raw", "dataSources": "Data Sources", "dataSource": { "proxy": "Routing", @@ -1225,6 +1231,8 @@ "input": "Input", "output": "Output", "cacheWrite": "Creation", + "cacheWriteNotReported": "OpenAI protocol doesn't distinguish cache creation; only cache hits are reported", + "cacheWritePartial": "Some protocols (e.g. OpenAI) don't report cache creation; the value may be incomplete", "cacheRead": "Hit", "baseCost": "Base", "costMultiplier": "Cost Multiplier", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ab4f750ca..fd3560fd3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1135,8 +1135,13 @@ "rangeToday": "直近24時間 (時間別)", "rangeLast7Days": "過去7日間", "rangeLast30Days": "過去30日間", - "totalTokens": "総トークン数", + "totalTokens": "新規トークン (Input+Output)", "cacheTokens": "キャッシュトークン", + "realTotal": "実消費トークン", + "realTotalHint": "Input + Output + キャッシュ書込 + キャッシュ命中", + "tokensSuffix": "tokens", + "freshInput": "新規入力", + "cacheHitRate": "キャッシュヒット率", "requestLogs": "リクエストログ", "providerStats": "プロバイダー統計", "modelStats": "モデル統計", @@ -1165,6 +1170,7 @@ "codex": "Codex", "gemini": "Gemini" }, + "rawInputLabel": "原始", "dataSources": "データソース", "dataSource": { "proxy": "ルーティング", @@ -1225,6 +1231,8 @@ "input": "Input", "output": "Output", "cacheWrite": "作成", + "cacheWriteNotReported": "OpenAI プロトコルはキャッシュ作成を区別せず、キャッシュヒットのみ報告します", + "cacheWritePartial": "一部のプロトコル(OpenAIなど)はキャッシュ作成を報告しないため、値は不完全な可能性があります", "cacheRead": "ヒット", "baseCost": "基本", "costMultiplier": "コスト倍率", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ffe8cb5c9..6e44b418c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1135,8 +1135,13 @@ "rangeToday": "过去 24 小时 (按小时)", "rangeLast7Days": "过去 7 天", "rangeLast30Days": "过去 30 天", - "totalTokens": "总 Token 数", + "totalTokens": "新生成 Token (Input+Output)", "cacheTokens": "缓存 Token", + "realTotal": "真实消耗 Tokens", + "realTotalHint": "Input + Output + 缓存写入 + 缓存命中", + "tokensSuffix": "tokens", + "freshInput": "新增输入", + "cacheHitRate": "缓存命中率", "requestLogs": "请求日志", "providerStats": "Provider 统计", "modelStats": "模型统计", @@ -1165,6 +1170,7 @@ "codex": "Codex", "gemini": "Gemini" }, + "rawInputLabel": "原始", "dataSources": "数据来源", "dataSource": { "proxy": "路由", @@ -1225,6 +1231,8 @@ "input": "Input", "output": "Output", "cacheWrite": "创建", + "cacheWriteNotReported": "OpenAI 协议不区分缓存写入,仅上报缓存命中", + "cacheWritePartial": "部分协议(如 OpenAI)不上报缓存写入,数值可能偏低", "cacheRead": "命中", "baseCost": "基础", "costMultiplier": "成本倍率", diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index 33ef3c9a0..4c4ffbff5 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { UsageSummary, + UsageSummaryByApp, DailyStats, ProviderStats, ModelStats, @@ -55,6 +56,13 @@ export const usageApi = { return invoke("get_usage_summary", { startDate, endDate, appType }); }, + getUsageSummaryByApp: async ( + startDate?: number, + endDate?: number, + ): Promise => { + return invoke("get_usage_summary_by_app", { startDate, endDate }); + }, + getUsageTrends: async ( startDate?: number, endDate?: number, diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index 22cea395a..bed48ed1e 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -45,6 +45,18 @@ export const usageKeys = { customEndDate ?? 0, appType ?? "all", ] as const, + summaryByApp: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + ) => + [ + ...usageKeys.all, + "summary-by-app", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + ] as const, trends: ( preset: UsageRangeSelection["preset"], customStartDate: number | undefined, @@ -133,6 +145,25 @@ export function useUsageSummary( }); } +export function useUsageSummaryByApp( + range: UsageRangeSelection, + options?: UsageQueryOptions, +) { + return useQuery({ + queryKey: usageKeys.summaryByApp( + range.preset, + range.customStartDate, + range.customEndDate, + ), + queryFn: () => { + const { startDate, endDate } = resolveUsageRange(range); + return usageApi.getUsageSummaryByApp(startDate, endDate); + }, + refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, + refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, + }); +} + export function useUsageTrends( range: UsageRangeSelection, appType?: string, diff --git a/src/types/usage.ts b/src/types/usage.ts index c595c1cf7..993c094b3 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -71,6 +71,15 @@ export interface UsageSummary { totalCacheCreationTokens: number; totalCacheReadTokens: number; successRate: number; + /** input + output + cache_creation + cache_read, all cache-normalized */ + realTotalTokens: number; + /** cache_read / (input + cache_creation + cache_read), range 0–1 */ + cacheHitRate: number; +} + +export interface UsageSummaryByApp { + appType: string; + summary: UsageSummary; } export interface DailyStats { @@ -129,7 +138,62 @@ export interface UsageRangeSelection { customEndDate?: number; } -export type AppTypeFilter = "all" | "claude" | "codex" | "gemini"; +/** + * App types whose token usage is reliably collected by the proxy. + * + * The proxy has handlers for `claude-desktop` too, but in practice those + * requests overwhelmingly fail (500/503) and contribute near-zero tokens, so + * it is hidden from the Dashboard. `opencode` / `openclaw` / `hermes` have + * no proxy handler at all — they appear only as managed apps elsewhere. + */ +export type AppType = "claude" | "codex" | "gemini"; + +export type AppTypeFilter = "all" | AppType; + +export const KNOWN_APP_TYPES: ReadonlyArray = [ + "claude", + "codex", + "gemini", +]; + +/** + * App types whose proxy uses an OpenAI-style protocol. Two consequences: + * + * 1. `inputTokens` already includes the cached portion (must subtract + * `cacheReadTokens` to get fresh-input semantics — see + * [getFreshInputTokens]). + * 2. The protocol does not report cache _creation_ separately, only cache + * _reads_. So `cacheCreationTokens` is always 0 for these app types and + * the UI should label it as N/A rather than 0. + * + * Mirror of the Rust `CACHE_INCLUSIVE_APP_TYPES` whitelist. + */ +export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet = new Set([ + "codex", + "gemini", +]); + +/** Subset of request-log fields needed to derive cache-normalized input. */ +export interface CacheNormalizableLog { + appType: string; + inputTokens: number; + cacheReadTokens: number; +} + +/** + * For a single request log, return the input token count with cache reads + * removed. Anthropic-style providers already report `inputTokens` without + * cache, so they pass through unchanged. + */ +export function getFreshInputTokens(log: CacheNormalizableLog): number { + if ( + CACHE_INCLUSIVE_APP_TYPES.has(log.appType) && + log.inputTokens >= log.cacheReadTokens + ) { + return log.inputTokens - log.cacheReadTokens; + } + return log.inputTokens; +} export interface StatsFilters { timeRange: UsageRangePreset;