diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs index c25671310..089c8478e 100644 --- a/src-tauri/src/proxy/usage/calculator.rs +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -59,7 +59,8 @@ impl CostCalculator { pricing: &ModelPricing, cost_multiplier: Decimal, ) -> CostBreakdown { - let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild"); + let input_includes_cache_read = + crate::services::sql_helpers::is_cache_inclusive_app(app_type); Self::calculate_with_cache_semantics( usage, pricing, diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index 5baf3f8c8..f90ce9479 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -122,7 +122,7 @@ impl<'a> UsageLogger<'a> { let created_at = chrono::Utc::now().timestamp(); let input_token_semantics = - if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") { + if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) { INPUT_TOKEN_SEMANTICS_TOTAL } else { INPUT_TOKEN_SEMANTICS_FRESH diff --git a/src-tauri/src/services/sql_helpers.rs b/src-tauri/src/services/sql_helpers.rs index a32049161..6a582f90f 100644 --- a/src-tauri/src/services/sql_helpers.rs +++ b/src-tauri/src/services/sql_helpers.rs @@ -16,7 +16,16 @@ /// 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", "grokbuild"]; +/// 单一语义集(SSOT):写入侧(proxy logger/calculator)、回填侧 +/// (usage_stats 成本重算)与展示侧(本文件的 SQL 归一)都必须引用这里, +/// 防止同一语义散落多处后新增 app 时漏改(grokbuild 曾在回填侧漏掉)。 +/// 前端 `src/types/usage.ts` 的同名常量是跨语言的对应物,改动须同步。 +pub(crate) const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"]; + +/// `app_type` 的存储 `input_tokens` 是否已包含 cache read/write。 +pub(crate) fn is_cache_inclusive_app(app_type: &str) -> bool { + CACHE_INCLUSIVE_APP_TYPES.contains(&app_type) +} pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0; pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1; diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 3581f3b39..26d6f670b 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -1850,10 +1850,11 @@ impl Database { let million = rust_decimal::Decimal::from(1_000_000u64); // 与 CostCalculator::calculate_for_app 保持一致的计算逻辑: - // 1. 历史 Codex/Gemini 行只包含 cache read;新 total 行还包含 cache write。 + // 1. 历史 cache-inclusive 行只包含 cache read;新 total 行还包含 cache write。 // 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减 // 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价 - let cache_inclusive_app = matches!(log.app_type.as_str(), "codex" | "gemini"); + let cache_inclusive_app = + crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()); let billable_input_tokens = if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH { log.input_tokens as u64 @@ -2613,6 +2614,53 @@ mod tests { Ok(()) } + #[test] + fn test_backfill_deducts_cache_read_for_grokbuild_total_rows() -> Result<(), AppError> { + // 回归:回填侧的 cache-inclusive 判定曾硬编码 codex|gemini 漏掉 + // grokbuild,导致 TOTAL 行按全量 input 计价、cache_read 双算。 + // 判定收敛到 sql_helpers::is_cache_inclusive_app 后按 450 fresh 计价。 + let db = Database::memory()?; + { + let conn = lock_conn!(db.conn); + insert_usage_log( + &conn, + "grokbuild-total-backfill", + "grokbuild", + "_grok_session", + "grok-4.5", + "grok_session", + 1000, + 700, + 100, + 250, + 0, + 200, + "0", + )?; + conn.execute( + "UPDATE proxy_request_logs + SET input_token_semantics = ?1 + WHERE request_id = 'grokbuild-total-backfill'", + [INPUT_TOKEN_SEMANTICS_TOTAL], + )?; + } + + assert_eq!(db.backfill_missing_usage_costs()?, 1); + + let conn = lock_conn!(db.conn); + let (input_cost, cache_read_cost, total_cost): (String, String, String) = conn.query_row( + "SELECT input_cost_usd, cache_read_cost_usd, total_cost_usd + FROM proxy_request_logs WHERE request_id = 'grokbuild-total-backfill'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + // grok-4.5 定价 2/6/0.50:input = (700-250)×2/1M,cache_read = 250×0.5/1M + assert_eq!(input_cost, "0.000900"); + assert_eq!(cache_read_cost, "0.000125"); + assert_eq!(total_cost, "0.001625"); + Ok(()) + } + #[test] fn test_backfill_missing_usage_costs_uses_stored_multiplier() -> Result<(), AppError> { let db = Database::memory()?;