mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<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);
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
@@ -135,7 +139,13 @@ export function RequestDetailPanel({
|
||||
{t("usage.inputTokens", "输入 Tokens")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
{request.inputTokens.toLocaleString()}
|
||||
{freshInput.toLocaleString()}
|
||||
{isCacheInclusive && (
|
||||
<span className="ml-2 text-xs text-muted-foreground/70 font-normal">
|
||||
({t("usage.rawInputLabel", "原始")}:{" "}
|
||||
{request.inputTokens.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -167,9 +177,7 @@ export function RequestDetailPanel({
|
||||
{t("usage.totalTokens", "总计")}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold">
|
||||
{(
|
||||
request.inputTokens + request.outputTokens
|
||||
).toLocaleString()}
|
||||
{(freshInput + request.outputTokens).toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -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({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
|
||||
<SelectItem value="claude">Claude</SelectItem>
|
||||
<SelectItem value="codex">Codex</SelectItem>
|
||||
<SelectItem value="gemini">Gemini</SelectItem>
|
||||
{KNOWN_APP_TYPES.map((at) => (
|
||||
<SelectItem key={at} value={at}>
|
||||
{t(`usage.appFilter.${at}`, { defaultValue: at })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -323,9 +330,23 @@ export function RequestLogTable({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
<div className="tabular-nums">
|
||||
{fmtInt(log.inputTokens, locale)}
|
||||
</div>
|
||||
{(() => {
|
||||
const freshInput = getFreshInputTokens(log);
|
||||
const isCacheInclusive =
|
||||
log.inputTokens !== freshInput;
|
||||
return (
|
||||
<div
|
||||
className="tabular-nums"
|
||||
title={
|
||||
isCacheInclusive
|
||||
? `Raw: ${log.inputTokens.toLocaleString()}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{fmtInt(freshInput, locale)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(log.cacheReadTokens > 0 ||
|
||||
log.cacheCreationTokens > 0) && (
|
||||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsageSummaryCards
|
||||
<UsageHero
|
||||
range={range}
|
||||
appType={appType}
|
||||
appType={appType === "all" ? undefined : appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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<AppType | "all", TitleTheme> = {
|
||||
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 (
|
||||
<Card className="border border-border/50 bg-card/40 backdrop-blur-sm">
|
||||
<CardContent className="flex items-center justify-center min-h-[200px]">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hitPercent = Math.max(0, Math.min(100, hitRate * 100));
|
||||
const hitPercentLabel = hitPercent.toFixed(hitPercent >= 99.95 ? 0 : 1);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card className="relative overflow-hidden border border-border/50 bg-gradient-to-br from-primary/5 via-card/50 to-background/50 backdrop-blur-xl shadow-sm">
|
||||
<CardContent className="p-6 md:p-8">
|
||||
{/* Header: title + cost */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("p-2 rounded-lg", titleTheme.iconBg)}>
|
||||
<Zap className={cn("h-4 w-4", titleTheme.accent)} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{appLabel && (
|
||||
<>
|
||||
<span className={cn("font-semibold", titleTheme.accent)}>
|
||||
{appLabel}
|
||||
</span>
|
||||
<span className="mx-1.5 text-muted-foreground/40">·</span>
|
||||
</>
|
||||
)}
|
||||
{t("usage.realTotal", "真实消耗 Tokens")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-right">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("usage.totalRequests")}
|
||||
</span>
|
||||
<span className="text-sm font-semibold flex items-center gap-1 justify-end">
|
||||
<Activity className="h-3.5 w-3.5 text-blue-500" />
|
||||
{requests.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("usage.totalCost")}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{totalCost == null ? "--" : fmtUsd(totalCost, 4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero number */}
|
||||
<div className="flex flex-col items-start mb-6">
|
||||
<div
|
||||
className="text-4xl md:text-5xl font-bold tracking-tight tabular-nums leading-tight"
|
||||
title={realTotal.toLocaleString()}
|
||||
>
|
||||
{realTotal.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
≈ {formatTokensShort(realTotal, lang, 2)}{" "}
|
||||
{t("usage.tokensSuffix", "tokens")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown row: 4 mini stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
|
||||
<MiniStat
|
||||
icon={<ArrowDownToLine className="h-3.5 w-3.5" />}
|
||||
label={t("usage.freshInput", "新增输入")}
|
||||
value={formatTokensShort(input, lang)}
|
||||
accent="text-blue-500"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={<ArrowUpFromLine className="h-3.5 w-3.5" />}
|
||||
label={t("usage.output")}
|
||||
value={formatTokensShort(output, lang)}
|
||||
accent="text-purple-500"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={<Database className="h-3.5 w-3.5" />}
|
||||
label={t("usage.cacheWrite", "缓存写入")}
|
||||
value={cacheWriteDisplay.value}
|
||||
accent="text-amber-500"
|
||||
muted={cacheWriteDisplay.muted}
|
||||
tooltip={cacheWriteDisplay.tooltip}
|
||||
/>
|
||||
<MiniStat
|
||||
icon={<Sparkles className="h-3.5 w-3.5" />}
|
||||
label={t("usage.cacheRead", "缓存命中")}
|
||||
value={formatTokensShort(cacheRead, lang)}
|
||||
accent="text-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hit rate progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("usage.cacheHitRate", "缓存命中率")}
|
||||
</span>
|
||||
<span className="font-semibold text-emerald-500 tabular-nums">
|
||||
{hitPercentLabel}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2 rounded-full bg-muted/50 overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-emerald-500/80 to-emerald-400 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${hitPercent}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="flex flex-col gap-1 rounded-lg border border-border/40 bg-background/40 px-3 py-2.5"
|
||||
title={tooltip}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs text-muted-foreground ${accent}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-foreground/70">{label}</span>
|
||||
{tooltip && (
|
||||
<Info className="h-3 w-3 text-muted-foreground/60 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-base font-semibold tabular-nums",
|
||||
muted && "text-muted-foreground/70",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.input")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(inputTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.output")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(outputTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("usage.cacheTokens"),
|
||||
value: totalCacheTokens.toLocaleString(),
|
||||
icon: Database,
|
||||
color: "text-orange-500",
|
||||
bg: "bg-orange-500/10",
|
||||
subValue: (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.cacheWrite")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheWriteTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.cacheRead")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheReadTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [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 (
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="border border-border/50 bg-card/40 backdrop-blur-sm shadow-sm"
|
||||
>
|
||||
<CardContent className="p-6 flex items-center justify-center min-h-[160px]">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid gap-4 md:grid-cols-4"
|
||||
>
|
||||
{stats.map((stat, i) => (
|
||||
<motion.div key={i} variants={item}>
|
||||
<Card className="relative h-full overflow-hidden border border-border/50 bg-gradient-to-br from-card/50 to-background/50 backdrop-blur-xl hover:from-card/60 hover:to-background/60 transition-all shadow-sm">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{stat.title}
|
||||
</p>
|
||||
<div className={`p-2 rounded-lg ${stat.bg}`}>
|
||||
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-2xl font-bold truncate" title={stat.value}>
|
||||
{stat.value}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{stat.subValue || (
|
||||
/* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */
|
||||
<div className="mt-3 pt-3 border-t border-transparent h-[52px]"></div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "コスト倍率",
|
||||
|
||||
@@ -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": "成本倍率",
|
||||
|
||||
@@ -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<UsageSummaryByApp[]> => {
|
||||
return invoke("get_usage_summary_by_app", { startDate, endDate });
|
||||
},
|
||||
|
||||
getUsageTrends: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+65
-1
@@ -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<AppType> = [
|
||||
"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<string> = 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;
|
||||
|
||||
Reference in New Issue
Block a user