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.
This commit is contained in:
Jason
2026-05-13 10:27:29 +08:00
parent c12364a940
commit edf28b6422
17 changed files with 931 additions and 219 deletions
+10
View File
@@ -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<i64>,
end_date: Option<i64>,
) -> Result<Vec<UsageSummaryByApp>, AppError> {
state.db.get_usage_summary_by_app(start_date, end_date)
}
/// 获取每日趋势
#[tauri::command]
pub fn get_usage_trends(
+1
View File
@@ -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,
+2 -1
View File
@@ -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,
};
+135
View File
@@ -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::<Vec<_>>()
.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);
}
}
+212 -20
View File
@@ -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.01.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<i64>,
end_date: Option<i64>,
) -> Result<Vec<UsageSummaryByApp>, AppError> {
let conn = lock_conn!(self.conn);
let mut detail_conditions = vec![effective_usage_log_filter("l")];
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = 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<String> = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = 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<Box<dyn rusqlite::ToSql>> = 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::<u64>(), 4);