diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 349496931..e872feb5d 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -41,10 +41,10 @@ pub fn get_model_stats(state: State<'_, AppState>) -> Result, Ap pub fn get_request_logs( state: State<'_, AppState>, filters: LogFilters, - limit: u32, - offset: u32, -) -> Result, AppError> { - state.db.get_request_logs(&filters, limit, offset) + page: u32, + page_size: u32, +) -> Result { + state.db.get_request_logs(&filters, page, page_size) } /// 获取单个请求详情 diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index efa57d254..ae13066e0 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -18,6 +18,6 @@ pub use skill::{Skill, SkillRepo, SkillService}; pub use speedtest::{EndpointLatency, SpeedtestService}; #[allow(unused_imports)] pub use usage_stats::{ - DailyStats, LogFilters, ModelStats, ProviderLimitStatus, ProviderStats, RequestLogDetail, - UsageSummary, + DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats, + RequestLogDetail, UsageSummary, }; diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index e55e6eb79..7f480579c 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -5,7 +5,6 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; use chrono::{Duration, Utc}; -use regex::Regex; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -67,13 +66,24 @@ pub struct ModelStats { #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LogFilters { - pub provider_id: Option, + pub app_type: Option, + pub provider_name: Option, pub model: Option, pub status_code: Option, pub start_date: Option, pub end_date: Option, } +/// 分页请求日志响应 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaginatedLogs { + pub data: Vec, + pub total: u32, + pub page: u32, + pub page_size: u32, +} + /// 请求日志详情 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -93,7 +103,10 @@ pub struct RequestLogDetail { pub cache_read_cost_usd: String, pub cache_creation_cost_usd: String, pub total_cost_usd: String, + pub is_streaming: bool, pub latency_ms: u64, + pub first_token_ms: Option, + pub duration_ms: Option, pub status_code: u16, pub error_message: Option, pub created_at: i64, @@ -381,36 +394,40 @@ impl Database { Ok(stats) } - /// 获取请求日志列表 + /// 获取请求日志列表(分页) pub fn get_request_logs( &self, filters: &LogFilters, - limit: u32, - offset: u32, - ) -> Result, AppError> { + page: u32, + page_size: u32, + ) -> Result { let conn = lock_conn!(self.conn); let mut conditions = Vec::new(); let mut params: Vec> = Vec::new(); - if let Some(ref provider_id) = filters.provider_id { - conditions.push("provider_id = ?"); - params.push(Box::new(provider_id.clone())); + if let Some(ref app_type) = filters.app_type { + conditions.push("l.app_type = ?"); + params.push(Box::new(app_type.clone())); + } + if let Some(ref provider_name) = filters.provider_name { + conditions.push("p.name LIKE ?"); + params.push(Box::new(format!("%{provider_name}%"))); } if let Some(ref model) = filters.model { - conditions.push("model = ?"); - params.push(Box::new(model.clone())); + conditions.push("l.model LIKE ?"); + params.push(Box::new(format!("%{model}%"))); } if let Some(status) = filters.status_code { - conditions.push("status_code = ?"); + conditions.push("l.status_code = ?"); params.push(Box::new(status as i64)); } if let Some(start) = filters.start_date { - conditions.push("created_at >= ?"); + conditions.push("l.created_at >= ?"); params.push(Box::new(start)); } if let Some(end) = filters.end_date { - conditions.push("created_at <= ?"); + conditions.push("l.created_at <= ?"); params.push(Box::new(end)); } @@ -420,14 +437,28 @@ impl Database { format!("WHERE {}", conditions.join(" AND ")) }; - params.push(Box::new(limit as i64)); + // 获取总数 + let count_sql = format!( + "SELECT COUNT(*) FROM proxy_request_logs l + LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type + {where_clause}" + ); + let count_params: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let total: u32 = conn.query_row(&count_sql, count_params.as_slice(), |row| { + row.get::<_, i64>(0).map(|v| v as u32) + })?; + + // 获取数据 + let offset = page * page_size; + params.push(Box::new(page_size as i64)); params.push(Box::new(offset as i64)); let sql = format!( "SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model, l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens, l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd, - l.latency_ms, l.status_code, l.error_message, l.created_at + l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms, + l.status_code, l.error_message, l.created_at FROM proxy_request_logs l LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type {where_clause} @@ -453,10 +484,13 @@ impl Database { cache_read_cost_usd: row.get(11)?, cache_creation_cost_usd: row.get(12)?, total_cost_usd: row.get(13)?, - latency_ms: row.get::<_, i64>(14)? as u64, - status_code: row.get::<_, i64>(15)? as u16, - error_message: row.get(16)?, - created_at: row.get(17)?, + is_streaming: row.get::<_, i64>(14)? != 0, + latency_ms: row.get::<_, i64>(15)? as u64, + first_token_ms: row.get::<_, Option>(16)?.map(|v| v as u64), + duration_ms: row.get::<_, Option>(17)?.map(|v| v as u64), + status_code: row.get::<_, i64>(18)? as u16, + error_message: row.get(19)?, + created_at: row.get(20)?, }) })?; @@ -475,7 +509,12 @@ impl Database { logs.push(log); } - Ok(logs) + Ok(PaginatedLogs { + data: logs, + total, + page, + page_size, + }) } /// 获取单个请求详情 @@ -489,7 +528,8 @@ impl Database { "SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, - latency_ms, status_code, error_message, created_at + is_streaming, latency_ms, first_token_ms, duration_ms, + status_code, error_message, created_at FROM proxy_request_logs l LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type WHERE l.request_id = ?", @@ -510,10 +550,13 @@ impl Database { cache_read_cost_usd: row.get(11)?, cache_creation_cost_usd: row.get(12)?, total_cost_usd: row.get(13)?, - latency_ms: row.get::<_, i64>(14)? as u64, - status_code: row.get::<_, i64>(15)? as u16, - error_message: row.get(16)?, - created_at: row.get(17)?, + is_streaming: row.get::<_, i64>(14)? != 0, + latency_ms: row.get::<_, i64>(15)? as u64, + first_token_ms: row.get::<_, Option>(16)?.map(|v| v as u64), + duration_ms: row.get::<_, Option>(17)?.map(|v| v as u64), + status_code: row.get::<_, i64>(18)? as u16, + error_message: row.get(19)?, + created_at: row.get(20)?, }) }, ); @@ -609,6 +652,56 @@ impl Database { monthly_exceeded, }) } + + /// 更新每日统计聚合 + /// + /// 在请求完成后调用,更新 usage_daily_stats 表 + #[allow(clippy::too_many_arguments)] + pub fn update_daily_stats( + &self, + provider_id: &str, + app_type: &str, + model: &str, + input_tokens: u32, + output_tokens: u32, + total_cost: &str, + is_success: bool, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + let date = Utc::now().format("%Y-%m-%d").to_string(); + + // 使用 UPSERT 更新或插入统计 + conn.execute( + "INSERT INTO usage_daily_stats ( + date, provider_id, app_type, model, + request_count, total_input_tokens, total_output_tokens, + total_cost_usd, success_count, error_count + ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(date, provider_id, app_type, model) DO UPDATE SET + request_count = request_count + 1, + total_input_tokens = total_input_tokens + ?5, + total_output_tokens = total_output_tokens + ?6, + total_cost_usd = CAST( + CAST(total_cost_usd AS REAL) + CAST(?7 AS REAL) AS TEXT + ), + success_count = success_count + ?8, + error_count = error_count + ?9", + params![ + date, + provider_id, + app_type, + model, + input_tokens, + output_tokens, + total_cost, + if is_success { 1 } else { 0 }, + if is_success { 0 } else { 1 }, + ], + ) + .map_err(|e| AppError::Database(format!("更新每日统计失败: {e}")))?; + + Ok(()) + } } /// Provider 限额状态 @@ -774,6 +867,7 @@ pub(crate) fn find_model_pricing_row( conn: &Connection, model_id: &str, ) -> Result, AppError> { + // 1. 精确匹配 let exact = conn .query_row( "SELECT input_cost_per_million, output_cost_per_million, @@ -797,59 +891,39 @@ pub(crate) fn find_model_pricing_row( return Ok(exact); } - let mut stmt = conn.prepare( - "SELECT model_id, input_cost_per_million, output_cost_per_million, - cache_read_cost_per_million, cache_creation_cost_per_million - FROM model_pricing - WHERE model_id LIKE '%*%' OR model_id LIKE '%?%' - ORDER BY LENGTH(model_id) DESC", - )?; + // 2. 逐步删除后缀匹配(claude-sonnet-4-5-20250929 → claude-sonnet-4-5 → claude-sonnet-4 → claude-sonnet) + let mut current = model_id.to_string(); + while let Some(pos) = current.rfind('-') { + current = current[..pos].to_string(); - let rows = stmt - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - )) - })? - .filter_map(|res| res.ok()); + let result = conn + .query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing + WHERE model_id = ?1", + [¤t], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .optional() + .map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?; - for (pattern, input, output, cache_read, cache_creation) in rows { - if matches_model_pattern(&pattern, model_id) { - return Ok(Some((input, output, cache_read, cache_creation))); + if result.is_some() { + log::info!("模型 {model_id} 通过删除后缀匹配到: {current}"); + return Ok(result); } } Ok(None) } -fn matches_model_pattern(pattern: &str, model_id: &str) -> bool { - if pattern == model_id { - return true; - } - - if !pattern.contains('*') && !pattern.contains('?') { - return false; - } - - let mut regex_pattern = String::from("^"); - for ch in pattern.chars() { - match ch { - '*' => regex_pattern.push_str(".*"), - '?' => regex_pattern.push('.'), - _ => regex_pattern.push_str(®ex::escape(&ch.to_string())), - } - } - regex_pattern.push('$'); - - Regex::new(®ex_pattern) - .map(|re| re.is_match(model_id)) - .unwrap_or(false) -} - #[cfg(test)] mod tests { use super::*; @@ -921,4 +995,55 @@ mod tests { Ok(()) } + + #[test] + fn test_model_pricing_matching() -> Result<(), AppError> { + let db = Database::memory()?; + let conn = lock_conn!(db.conn); + + // 测试精确匹配 + let result = find_model_pricing_row(&conn, "claude-4.1-sonnet")?; + assert!(result.is_some(), "应该能精确匹配 claude-4.1-sonnet"); + + // 测试逐步删除后缀匹配 - 日期后缀 + let result = find_model_pricing_row(&conn, "claude-4.1-sonnet-20241022")?; + assert!( + result.is_some(), + "应该能通过删除后缀匹配 claude-4.1-sonnet-20241022" + ); + + // 测试逐步删除后缀匹配 - 多个后缀 + let result = find_model_pricing_row(&conn, "claude-4.5-haiku-20240229-preview")?; + assert!( + result.is_some(), + "应该能通过删除后缀匹配 claude-4.5-haiku-20240229-preview" + ); + + // 测试 GPT 模型 + let result = find_model_pricing_row(&conn, "gpt-5.0-2024-11-20")?; + assert!( + result.is_some(), + "应该能通过删除后缀匹配 gpt-5.0-2024-11-20" + ); + + // 测试 Gemini 模型 + let result = find_model_pricing_row(&conn, "gemini-2.5-flash-exp")?; + assert!( + result.is_some(), + "应该能通过删除后缀匹配 gemini-2.5-flash-exp" + ); + + // 测试 claude-sonnet-4-5 命名格式 + let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?; + assert!( + result.is_some(), + "应该能通过删除后缀匹配 claude-sonnet-4-5-20250929" + ); + + // 测试不存在的模型 + let result = find_model_pricing_row(&conn, "unknown-model-123")?; + assert!(result.is_none(), "不应该匹配不存在的模型"); + + Ok(()) + } }