From c0122d717ce08137de07a04b18916d4cc0da13e2 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Wed, 3 Dec 2025 00:16:40 +0800 Subject: [PATCH] feat(db): add usage tracking schema and types Add database tables for proxy request logs and model pricing. Extend Provider and error types to support usage statistics. --- src-tauri/src/commands/usage.rs | 152 ++++++ src-tauri/src/database/dao/providers.rs | 51 ++ src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 288 ++++++++++- src-tauri/src/error.rs | 15 + src-tauri/src/provider.rs | 9 + src-tauri/src/proxy/usage/calculator.rs | 183 +++++++ src-tauri/src/proxy/usage/logger.rs | 271 ++++++++++ src-tauri/src/proxy/usage/mod.rs | 11 + src-tauri/src/proxy/usage/parser.rs | 263 ++++++++++ src-tauri/src/services/usage_stats.rs | 527 ++++++++++++++++++++ src/components/usage/ModelStatsTable.tsx | 47 ++ src/components/usage/PricingConfigPanel.tsx | 69 +++ src/components/usage/PricingEditModal.tsx | 175 +++++++ src/components/usage/ProviderStatsTable.tsx | 49 ++ src/components/usage/RequestDetailPanel.tsx | 178 +++++++ src/components/usage/RequestLogTable.tsx | 97 ++++ src/components/usage/UsageDashboard.tsx | 60 +++ src/components/usage/UsageSummaryCards.tsx | 86 ++++ src/components/usage/UsageTrendChart.tsx | 79 +++ src/lib/query/usage.ts | 109 ++++ src/types/usage.ts | 96 ++++ 22 files changed, 2815 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/usage.rs create mode 100644 src-tauri/src/proxy/usage/calculator.rs create mode 100644 src-tauri/src/proxy/usage/logger.rs create mode 100644 src-tauri/src/proxy/usage/mod.rs create mode 100644 src-tauri/src/proxy/usage/parser.rs create mode 100644 src-tauri/src/services/usage_stats.rs create mode 100644 src/components/usage/ModelStatsTable.tsx create mode 100644 src/components/usage/PricingConfigPanel.tsx create mode 100644 src/components/usage/PricingEditModal.tsx create mode 100644 src/components/usage/ProviderStatsTable.tsx create mode 100644 src/components/usage/RequestDetailPanel.tsx create mode 100644 src/components/usage/RequestLogTable.tsx create mode 100644 src/components/usage/UsageDashboard.tsx create mode 100644 src/components/usage/UsageSummaryCards.tsx create mode 100644 src/components/usage/UsageTrendChart.tsx create mode 100644 src/lib/query/usage.ts create mode 100644 src/types/usage.ts diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs new file mode 100644 index 000000000..4b2329926 --- /dev/null +++ b/src-tauri/src/commands/usage.rs @@ -0,0 +1,152 @@ +//! 使用统计相关命令 + +use crate::database::Database; +use crate::error::AppError; +use crate::services::usage_stats::*; +use tauri::State; + +/// 获取使用量汇总 +#[tauri::command] +pub fn get_usage_summary( + db: State<'_, Database>, + start_date: Option, + end_date: Option, +) -> Result { + db.get_usage_summary(start_date, end_date) +} + +/// 获取每日趋势 +#[tauri::command] +pub fn get_usage_trends( + db: State<'_, Database>, + days: u32, +) -> Result, AppError> { + db.get_daily_trends(days) +} + +/// 获取 Provider 统计 +#[tauri::command] +pub fn get_provider_stats(db: State<'_, Database>) -> Result, AppError> { + db.get_provider_stats() +} + +/// 获取模型统计 +#[tauri::command] +pub fn get_model_stats(db: State<'_, Database>) -> Result, AppError> { + db.get_model_stats() +} + +/// 获取请求日志列表 +#[tauri::command] +pub fn get_request_logs( + db: State<'_, Database>, + provider_id: Option, + model: Option, + status_code: Option, + start_date: Option, + end_date: Option, + limit: u32, + offset: u32, +) -> Result, AppError> { + let filters = LogFilters { + provider_id, + model, + status_code, + start_date, + end_date, + }; + db.get_request_logs(&filters, limit, offset) +} + +/// 获取单个请求详情 +#[tauri::command] +pub fn get_request_detail( + db: State<'_, Database>, + request_id: String, +) -> Result, AppError> { + db.get_request_detail(&request_id) +} + +/// 获取模型定价列表 +#[tauri::command] +pub fn get_model_pricing(db: State<'_, Database>) -> Result, AppError> { + let conn = crate::database::lock_conn!(db.conn); + + let mut stmt = conn.prepare( + "SELECT model_id, display_name, input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing + ORDER BY display_name" + )?; + + let rows = stmt.query_map([], |row| { + Ok(ModelPricingInfo { + model_id: row.get(0)?, + display_name: row.get(1)?, + input_cost_per_million: row.get(2)?, + output_cost_per_million: row.get(3)?, + cache_read_cost_per_million: row.get(4)?, + cache_creation_cost_per_million: row.get(5)?, + }) + })?; + + let mut pricing = Vec::new(); + for row in rows { + pricing.push(row?); + } + + Ok(pricing) +} + +/// 更新模型定价 +#[tauri::command] +pub fn update_model_pricing( + db: State<'_, Database>, + model_id: String, + display_name: String, + input_cost: String, + output_cost: String, + cache_read_cost: String, + cache_creation_cost: String, +) -> Result<(), AppError> { + let conn = crate::database::lock_conn!(db.conn); + + conn.execute( + "INSERT OR REPLACE INTO model_pricing ( + model_id, display_name, input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + model_id, + display_name, + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost + ], + ) + .map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?; + + Ok(()) +} + +/// 检查 Provider 使用限额 +#[tauri::command] +pub fn check_provider_limits( + db: State<'_, Database>, + provider_id: String, + app_type: String, +) -> Result { + db.check_provider_limits(&provider_id, &app_type) +} + +/// 模型定价信息 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ModelPricingInfo { + pub model_id: String, + pub display_name: String, + pub input_cost_per_million: String, + pub output_cost_per_million: String, + pub cache_read_cost_per_million: String, + pub cache_creation_cost_per_million: String, +} diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 9453de194..c2fcc0b5e 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -123,6 +123,57 @@ impl Database { } } + /// 根据 ID 获取单个供应商 + pub fn get_provider_by_id( + &self, + id: &str, + app_type: &str, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let result = conn.query_row( + "SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target + FROM providers WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + |row| { + let name: String = row.get(0)?; + let settings_config_str: String = row.get(1)?; + let website_url: Option = row.get(2)?; + let category: Option = row.get(3)?; + let created_at: Option = row.get(4)?; + let sort_index: Option = row.get(5)?; + let notes: Option = row.get(6)?; + let icon: Option = row.get(7)?; + let icon_color: Option = row.get(8)?; + let meta_str: String = row.get(9)?; + let is_proxy_target: bool = row.get(10)?; + + let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); + let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default(); + + Ok(Provider { + id: id.to_string(), + name, + settings_config, + website_url, + category, + created_at, + sort_index, + notes, + meta: Some(meta), + icon, + icon_color, + is_proxy_target: Some(is_proxy_target), + }) + }, + ); + + match result { + Ok(provider) => Ok(Some(provider)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + /// 获取代理目标供应商 ID pub fn get_proxy_target_provider(&self, app_type: &str) -> Result, AppError> { let conn = lock_conn!(self.conn); diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 4602c562c..fe489d42b 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -44,7 +44,7 @@ const DB_BACKUP_RETAIN: usize = 10; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 1; +pub(crate) const SCHEMA_VERSION: i32 = 2; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 9ab6ee3ed..ae7d8e5fe 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -204,6 +204,100 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; + // 11. Proxy Request Logs 表 (详细请求日志) + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_request_logs ( + request_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model 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, + input_cost_usd TEXT NOT NULL DEFAULT '0', + output_cost_usd TEXT NOT NULL DEFAULT '0', + cache_read_cost_usd TEXT NOT NULL DEFAULT '0', + cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', + total_cost_usd TEXT NOT NULL DEFAULT '0', + latency_ms INTEGER NOT NULL, + status_code INTEGER NOT NULL, + error_message TEXT, + session_id TEXT, + created_at INTEGER NOT NULL + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_provider + ON proxy_request_logs(provider_id, app_type)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_created_at + ON proxy_request_logs(created_at)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_model + ON proxy_request_logs(model)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_session + ON proxy_request_logs(session_id)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_status + ON proxy_request_logs(status_code)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 12. Model Pricing 表 (模型定价) + conn.execute( + "CREATE TABLE IF NOT EXISTS model_pricing ( + model_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + input_cost_per_million TEXT NOT NULL, + output_cost_per_million TEXT NOT NULL, + cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', + cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 13. Usage Daily Stats 表 (每日聚合统计) + conn.execute( + "CREATE TABLE IF NOT EXISTS usage_daily_stats ( + date TEXT NOT NULL, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + success_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, provider_id, app_type, model) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) } @@ -234,7 +328,12 @@ impl Database { 0 => { log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)"); Self::migrate_v0_to_v1(conn)?; - Self::set_user_version(conn, SCHEMA_VERSION)?; + Self::set_user_version(conn, 1)?; + } + 1 => { + log::info!("迁移数据库从 v1 到 v2(添加使用统计表和 provider 限制字段)"); + Self::migrate_v1_to_v2(conn)?; + Self::set_user_version(conn, 2)?; } _ => { return Err(AppError::Database(format!( @@ -321,6 +420,193 @@ impl Database { Ok(()) } + /// v1 -> v2 迁移:添加使用统计表和 provider 限制字段 + fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> { + // 为 providers 表添加成本倍数和限制字段 + Self::add_column_if_missing( + conn, + "providers", + "cost_multiplier", + "TEXT NOT NULL DEFAULT '1.0'", + )?; + Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?; + Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?; + + // 创建新表(如果不存在) + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_request_logs ( + request_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model 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, + input_cost_usd TEXT NOT NULL DEFAULT '0', + output_cost_usd TEXT NOT NULL DEFAULT '0', + cache_read_cost_usd TEXT NOT NULL DEFAULT '0', + cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', + total_cost_usd TEXT NOT NULL DEFAULT '0', + latency_ms INTEGER NOT NULL, + status_code INTEGER NOT NULL, + error_message TEXT, + session_id TEXT, + created_at INTEGER NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS model_pricing ( + model_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + input_cost_per_million TEXT NOT NULL, + output_cost_per_million TEXT NOT NULL, + cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', + cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS usage_daily_stats ( + date TEXT NOT NULL, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + success_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, provider_id, app_type, model) + )", + [], + )?; + + // 插入默认模型定价数据 + Self::seed_model_pricing(conn)?; + + Ok(()) + } + + /// 插入默认模型定价数据 + fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> { + let pricing_data = [ + // Claude 4.x series + ( + "claude-4.1-sonnet", + "Claude 4.1 Sonnet", + "3.0", + "15.0", + "0.3", + "3.75", + ), + ( + "claude-4.1-haiku", + "Claude 4.1 Haiku", + "1.0", + "5.0", + "0.1", + "1.25", + ), + ( + "claude-4.5-sonnet", + "Claude 4.5 Sonnet", + "3.5", + "17.5", + "0.35", + "4.0", + ), + ( + "claude-4.5-haiku", + "Claude 4.5 Haiku", + "1.2", + "6.0", + "0.12", + "1.5", + ), + // GPT 5.x series + ("gpt-5.0", "GPT-5.0", "4.0", "16.0", "0", "0"), + ("gpt-5.0-mini", "GPT-5.0 Mini", "0.3", "1.2", "0", "0"), + ("gpt-5.1", "GPT-5.1", "4.5", "18.0", "0", "0"), + ("gpt-5.1-mini", "GPT-5.1 Mini", "0.4", "1.5", "0", "0"), + // Gemini 2.5 / 3.x + ( + "gemini-2.5-pro", + "Gemini 2.5 Pro", + "1.5", + "6.0", + "0.35", + "1.5", + ), + ( + "gemini-2.5-flash", + "Gemini 2.5 Flash", + "0.12", + "0.5", + "0.03", + "0.12", + ), + ( + "gemini-3.0-pro", + "Gemini 3.0 Pro", + "2.0", + "8.0", + "0.4", + "2.0", + ), + ( + "gemini-3.0-flash", + "Gemini 3.0 Flash", + "0.2", + "0.8", + "0.05", + "0.2", + ), + ]; + + for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data { + conn.execute( + "INSERT OR IGNORE INTO model_pricing ( + model_id, display_name, input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + model_id, + display_name, + input, + output, + cache_read, + cache_creation + ], + ) + .map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?; + } + + log::info!("已插入 {} 条默认模型定价数据", pricing_data.len()); + Ok(()) + } + + /// 确保模型定价表具备默认数据 + pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + Self::ensure_model_pricing_seeded_on_conn(&conn) + } + + fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> { + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0)) + .map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?; + + if count == 0 { + Self::seed_model_pricing(conn)?; + } + Ok(()) + } + // --- 辅助方法 --- pub(crate) fn get_user_version(conn: &Connection) -> Result { diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index b8f23b0bd..e9eafd35b 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -91,12 +91,27 @@ impl From> for AppError { } } +impl From for AppError { + fn from(err: rusqlite::Error) -> Self { + Self::Database(err.to_string()) + } +} + impl From for String { fn from(err: AppError) -> Self { err.to_string() } } +impl serde::Serialize for AppError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + /// 格式化为 JSON 错误字符串,前端可解析为结构化错误 pub fn format_skill_error( code: &str, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index f56fbd683..7de68b706 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -156,6 +156,15 @@ pub struct ProviderMeta { skip_serializing_if = "Option::is_none" )] pub partner_promotion_key: Option, + /// 成本倍数(用于计算实际成本) + #[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")] + pub cost_multiplier: Option, + /// 每日消费限额(USD) + #[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")] + pub limit_daily_usd: Option, + /// 每月消费限额(USD) + #[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")] + pub limit_monthly_usd: Option, } impl ProviderManager { diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs new file mode 100644 index 000000000..4de0bfe71 --- /dev/null +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -0,0 +1,183 @@ +//! Cost Calculator - 计算 API 请求成本 +//! +//! 使用高精度 Decimal 类型避免浮点数精度问题 + +use super::parser::TokenUsage; +use rust_decimal::Decimal; +use std::str::FromStr; + +/// 成本明细 +#[derive(Debug, Clone)] +pub struct CostBreakdown { + pub input_cost: Decimal, + pub output_cost: Decimal, + pub cache_read_cost: Decimal, + pub cache_creation_cost: Decimal, + pub total_cost: Decimal, +} + +/// 模型定价信息 +#[derive(Debug, Clone)] +pub struct ModelPricing { + pub input_cost_per_million: Decimal, + pub output_cost_per_million: Decimal, + pub cache_read_cost_per_million: Decimal, + pub cache_creation_cost_per_million: Decimal, +} + +/// 成本计算器 +pub struct CostCalculator; + +impl CostCalculator { + /// 计算请求成本 + /// + /// # 参数 + /// - `usage`: Token 使用量 + /// - `pricing`: 模型定价 + /// - `cost_multiplier`: 成本倍数 (provider 自定义) + pub fn calculate( + usage: &TokenUsage, + pricing: &ModelPricing, + cost_multiplier: Decimal, + ) -> CostBreakdown { + let million = Decimal::from(1_000_000); + + let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million + / million + * cost_multiplier; + let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million + / million + * cost_multiplier; + let cache_read_cost = Decimal::from(usage.cache_read_tokens) + * pricing.cache_read_cost_per_million + / million + * cost_multiplier; + let cache_creation_cost = Decimal::from(usage.cache_creation_tokens) + * pricing.cache_creation_cost_per_million + / million + * cost_multiplier; + + let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost; + + CostBreakdown { + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + } + } + + /// 尝试计算成本,如果模型未知则返回 None + pub fn try_calculate( + usage: &TokenUsage, + pricing: Option<&ModelPricing>, + cost_multiplier: Decimal, + ) -> Option { + pricing.map(|p| Self::calculate(usage, p, cost_multiplier)) + } +} + +impl ModelPricing { + /// 从字符串创建定价信息 + pub fn from_strings( + input: &str, + output: &str, + cache_read: &str, + cache_creation: &str, + ) -> Result { + Ok(Self { + input_cost_per_million: Decimal::from_str(input)?, + output_cost_per_million: Decimal::from_str(output)?, + cache_read_cost_per_million: Decimal::from_str(cache_read)?, + cache_creation_cost_per_million: Decimal::from_str(cache_creation)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cost_calculation() { + let usage = TokenUsage { + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 200, + cache_creation_tokens: 100, + }; + + let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap(); + let multiplier = Decimal::from_str("1.0").unwrap(); + + let cost = CostCalculator::calculate(&usage, &pricing, multiplier); + + // input: 1000 * 3.0 / 1M = 0.003 + assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap()); + // output: 500 * 15.0 / 1M = 0.0075 + assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap()); + // cache_read: 200 * 0.3 / 1M = 0.00006 + assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap()); + // cache_creation: 100 * 3.75 / 1M = 0.000375 + assert_eq!( + cost.cache_creation_cost, + Decimal::from_str("0.000375").unwrap() + ); + // total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935 + assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap()); + } + + #[test] + fn test_cost_multiplier() { + let usage = TokenUsage { + input_tokens: 1000, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + }; + + let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap(); + let multiplier = Decimal::from_str("1.5").unwrap(); + + let cost = CostCalculator::calculate(&usage, &pricing, multiplier); + + // input: 1000 * 3.0 / 1M * 1.5 = 0.0045 + assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap()); + assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap()); + } + + #[test] + fn test_unknown_model_handling() { + let usage = TokenUsage { + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 0, + cache_creation_tokens: 0, + }; + + let multiplier = Decimal::from_str("1.0").unwrap(); + let cost = CostCalculator::try_calculate(&usage, None, multiplier); + + assert!(cost.is_none()); + } + + #[test] + fn test_decimal_precision() { + let usage = TokenUsage { + input_tokens: 1, + output_tokens: 1, + cache_read_tokens: 1, + cache_creation_tokens: 1, + }; + + let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap(); + let multiplier = Decimal::from_str("1.0").unwrap(); + + let cost = CostCalculator::calculate(&usage, &pricing, multiplier); + + // 验证高精度计算 + assert!(cost.total_cost > Decimal::ZERO); + assert!(cost.total_cost.to_string().len() > 2); // 确保保留了小数位 + } +} diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs new file mode 100644 index 000000000..0a77fb7ce --- /dev/null +++ b/src-tauri/src/proxy/usage/logger.rs @@ -0,0 +1,271 @@ +//! Usage Logger - 记录 API 请求使用情况 + +use super::calculator::{CostBreakdown, CostCalculator, ModelPricing}; +use super::parser::TokenUsage; +use crate::database::Database; +use crate::error::AppError; +use rust_decimal::Decimal; +use std::time::SystemTime; + +/// 请求日志 +#[derive(Debug, Clone)] +pub struct RequestLog { + pub request_id: String, + pub provider_id: String, + pub app_type: String, + pub model: String, + pub usage: TokenUsage, + pub cost: Option, + pub latency_ms: u64, + pub status_code: u16, + pub error_message: Option, + pub session_id: Option, +} + +/// 使用量记录器 +pub struct UsageLogger<'a> { + db: &'a Database, +} + +impl<'a> UsageLogger<'a> { + pub fn new(db: &'a Database) -> Self { + Self { db } + } + + /// 记录成功的请求 + pub fn log_request(&self, log: &RequestLog) -> Result<(), AppError> { + let conn = crate::database::lock_conn!(self.db.conn); + + let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = + if let Some(cost) = &log.cost { + ( + cost.input_cost.to_string(), + cost.output_cost.to_string(), + cost.cache_read_cost.to_string(), + cost.cache_creation_cost.to_string(), + cost.total_cost.to_string(), + ) + } else { + ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + ) + }; + + let created_at = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, 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, session_id, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", + rusqlite::params![ + log.request_id, + log.provider_id, + log.app_type, + log.model, + log.usage.input_tokens, + log.usage.output_tokens, + log.usage.cache_read_tokens, + log.usage.cache_creation_tokens, + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + log.latency_ms as i64, + log.status_code as i64, + log.error_message, + log.session_id, + created_at, + ], + ) + .map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?; + + Ok(()) + } + + /// 记录失败的请求 + pub fn log_error( + &self, + request_id: String, + provider_id: String, + app_type: String, + model: String, + status_code: u16, + error_message: String, + latency_ms: u64, + ) -> Result<(), AppError> { + let log = RequestLog { + request_id, + provider_id, + app_type, + model, + usage: TokenUsage::default(), + cost: None, + latency_ms, + status_code, + error_message: Some(error_message), + session_id: None, + }; + + self.log_request(&log) + } + + /// 获取模型定价 + pub fn get_model_pricing(&self, model_id: &str) -> Result, AppError> { + let conn = crate::database::lock_conn!(self.db.conn); + + 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", + [model_id], + |row| { + Ok(ModelPricing::from_strings( + &row.get::<_, String>(0)?, + &row.get::<_, String>(1)?, + &row.get::<_, String>(2)?, + &row.get::<_, String>(3)?, + )) + }, + ); + + match result { + Ok(Ok(pricing)) => Ok(Some(pricing)), + Ok(Err(e)) => Err(AppError::Database(format!("解析定价数据失败: {e}"))), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(format!("查询定价失败: {e}"))), + } + } + + /// 计算并记录请求 + pub fn log_with_calculation( + &self, + request_id: String, + provider_id: String, + app_type: String, + model: String, + usage: TokenUsage, + cost_multiplier: Decimal, + latency_ms: u64, + status_code: u16, + session_id: Option, + ) -> Result<(), AppError> { + let pricing = self.get_model_pricing(&model)?; + + if pricing.is_none() { + log::warn!("模型 {} 的定价信息未找到,成本将记录为 0", model); + } + + let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier); + + let log = RequestLog { + request_id, + provider_id, + app_type, + model, + usage, + cost, + latency_ms, + status_code, + error_message: None, + session_id, + }; + + self.log_request(&log) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log_request() -> Result<(), AppError> { + let db = Database::memory()?; + + // 插入测试定价 + { + let conn = crate::database::lock_conn!(db.conn); + conn.execute( + "INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million) + VALUES ('test-model', 'Test Model', '3.0', '15.0')", + [], + ) + .unwrap(); + } + + let logger = UsageLogger::new(&db); + + let usage = TokenUsage { + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 0, + cache_creation_tokens: 0, + }; + + logger + .log_with_calculation( + "req-123".to_string(), + "provider-1".to_string(), + "claude".to_string(), + "test-model".to_string(), + usage, + Decimal::from(1), + 100, + 200, + None, + )?; + + // 验证记录已插入 + let conn = crate::database::lock_conn!(db.conn); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'req-123'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + Ok(()) + } + + #[test] + fn test_log_error() -> Result<(), AppError> { + let db = Database::memory()?; + let logger = UsageLogger::new(&db); + + logger + .log_error( + "req-error".to_string(), + "provider-1".to_string(), + "claude".to_string(), + "unknown-model".to_string(), + 500, + "Internal Server Error".to_string(), + 50, + )?; + + // 验证错误记录已插入 + let conn = crate::database::lock_conn!(db.conn); + let (status, error): (i64, Option) = conn + .query_row( + "SELECT status_code, error_message FROM proxy_request_logs WHERE request_id = 'req-error'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(status, 500); + assert_eq!(error, Some("Internal Server Error".to_string())); + Ok(()) + } +} diff --git a/src-tauri/src/proxy/usage/mod.rs b/src-tauri/src/proxy/usage/mod.rs new file mode 100644 index 000000000..38a356fbc --- /dev/null +++ b/src-tauri/src/proxy/usage/mod.rs @@ -0,0 +1,11 @@ +//! Proxy Usage Tracking Module +//! +//! 提供 API 请求的使用量跟踪、成本计算和日志记录功能 + +pub mod calculator; +pub mod logger; +pub mod parser; + +pub use calculator::{CostBreakdown, CostCalculator, ModelPricing}; +pub use logger::{RequestLog, UsageLogger}; +pub use parser::{ApiType, TokenUsage}; diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs new file mode 100644 index 000000000..ef9c1f551 --- /dev/null +++ b/src-tauri/src/proxy/usage/parser.rs @@ -0,0 +1,263 @@ +//! Response Parser - 从 API 响应中提取 token 使用量 +//! +//! 支持多种 API 格式: +//! - Claude API (非流式和流式) +//! - OpenRouter (OpenAI 格式) +//! - Codex API (非流式和流式) +//! - Gemini API (非流式和流式) + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Token 使用量统计 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenUsage { + pub input_tokens: u32, + pub output_tokens: u32, + pub cache_read_tokens: u32, + pub cache_creation_tokens: u32, +} + +/// API 类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiType { + Claude, + OpenRouter, + Codex, + Gemini, +} + +impl TokenUsage { + /// 从 Claude API 非流式响应解析 + pub fn from_claude_response(body: &Value) -> Option { + let usage = body.get("usage")?; + Some(Self { + input_tokens: usage.get("input_tokens")?.as_u64()? as u32, + output_tokens: usage.get("output_tokens")?.as_u64()? as u32, + cache_read_tokens: usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + cache_creation_tokens: usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + }) + } + + /// 从 Claude API 流式响应解析 + pub fn from_claude_stream_events(events: &[Value]) -> Option { + let mut usage = Self::default(); + + for event in events { + if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { + match event_type { + "message_start" => { + if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) + { + usage.input_tokens = + msg_usage.get("input_tokens")?.as_u64()? as u32; + usage.cache_read_tokens = msg_usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + usage.cache_creation_tokens = msg_usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + } + } + "message_delta" => { + if let Some(delta_usage) = event.get("usage") { + usage.output_tokens = + delta_usage.get("output_tokens")?.as_u64()? as u32; + } + } + _ => {} + } + } + } + + if usage.input_tokens > 0 || usage.output_tokens > 0 { + Some(usage) + } else { + None + } + } + + /// 从 OpenRouter 响应解析 (OpenAI 格式) + pub fn from_openrouter_response(body: &Value) -> Option { + let usage = body.get("usage")?; + Some(Self { + input_tokens: usage.get("prompt_tokens")?.as_u64()? as u32, + output_tokens: usage.get("completion_tokens")?.as_u64()? as u32, + cache_read_tokens: 0, + cache_creation_tokens: 0, + }) + } + + /// 从 Codex API 非流式响应解析 + pub fn from_codex_response(body: &Value) -> Option { + let usage = body.get("usage")?; + Some(Self { + input_tokens: usage.get("input_tokens")?.as_u64()? as u32, + output_tokens: usage.get("output_tokens")?.as_u64()? as u32, + cache_read_tokens: usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + cache_creation_tokens: usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + }) + } + + /// 从 Codex API 流式响应解析 + pub fn from_codex_stream_events(events: &[Value]) -> Option { + for event in events { + if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { + if event_type == "response.completed" { + if let Some(response) = event.get("response") { + return Self::from_codex_response(response); + } + } + } + } + None + } + + /// 从 Gemini API 非流式响应解析 + pub fn from_gemini_response(body: &Value) -> Option { + let usage = body.get("usageMetadata")?; + Some(Self { + input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32, + output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32, + cache_read_tokens: usage + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + cache_creation_tokens: 0, + }) + } + + /// 从 Gemini API 流式响应解析 + pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option { + let mut total_input = 0u32; + let mut total_output = 0u32; + let mut total_cache_read = 0u32; + + for chunk in chunks { + if let Some(usage) = chunk.get("usageMetadata") { + total_input = usage + .get("promptTokenCount") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + total_output += usage + .get("candidatesTokenCount") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + total_cache_read = usage + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + } + } + + if total_input > 0 || total_output > 0 { + Some(Self { + input_tokens: total_input, + output_tokens: total_output, + cache_read_tokens: total_cache_read, + cache_creation_tokens: 0, + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_claude_response_parsing() { + let response = json!({ + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 20, + "cache_creation_input_tokens": 10 + } + }); + + let usage = TokenUsage::from_claude_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 20); + assert_eq!(usage.cache_creation_tokens, 10); + } + + #[test] + fn test_claude_stream_parsing() { + let events = vec![ + json!({ + "type": "message_start", + "message": { + "usage": { + "input_tokens": 100, + "cache_read_input_tokens": 20, + "cache_creation_input_tokens": 10 + } + } + }), + json!({ + "type": "message_delta", + "usage": { + "output_tokens": 50 + } + }), + ]; + + let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 20); + assert_eq!(usage.cache_creation_tokens, 10); + } + + #[test] + fn test_openrouter_response_parsing() { + let response = json!({ + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50 + } + }); + + let usage = TokenUsage::from_openrouter_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 0); + assert_eq!(usage.cache_creation_tokens, 0); + } + + #[test] + fn test_gemini_response_parsing() { + let response = json!({ + "usageMetadata": { + "promptTokenCount": 100, + "candidatesTokenCount": 50, + "cachedContentTokenCount": 20 + } + }); + + let usage = TokenUsage::from_gemini_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 20); + assert_eq!(usage.cache_creation_tokens, 0); + } +} diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs new file mode 100644 index 000000000..f74e80b8f --- /dev/null +++ b/src-tauri/src/services/usage_stats.rs @@ -0,0 +1,527 @@ +//! 使用统计服务 +//! +//! 提供使用量数据的聚合查询功能 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use rusqlite::params; +use serde::{Deserialize, Serialize}; + +/// 使用量汇总 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageSummary { + pub total_requests: u64, + pub total_cost: String, + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub success_rate: f32, +} + +/// 每日统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailyStats { + pub date: String, + pub request_count: u64, + pub total_cost: String, + pub total_tokens: u64, +} + +/// Provider 统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderStats { + pub provider_id: String, + pub provider_name: String, + pub request_count: u64, + pub total_tokens: u64, + pub total_cost: String, + pub success_rate: f32, + pub avg_latency_ms: u64, +} + +/// 模型统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelStats { + pub model: String, + pub request_count: u64, + pub total_tokens: u64, + pub total_cost: String, + pub avg_cost_per_request: String, +} + +/// 请求日志过滤器 +#[derive(Debug, Clone, Default)] +pub struct LogFilters { + pub provider_id: Option, + pub model: Option, + pub status_code: Option, + pub start_date: Option, + pub end_date: Option, +} + +/// 请求日志详情 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestLogDetail { + pub request_id: String, + pub provider_id: String, + pub app_type: String, + pub model: String, + pub input_tokens: u32, + pub output_tokens: u32, + pub cache_read_tokens: u32, + pub cache_creation_tokens: u32, + pub input_cost_usd: String, + pub output_cost_usd: String, + pub cache_read_cost_usd: String, + pub cache_creation_cost_usd: String, + pub total_cost_usd: String, + pub latency_ms: u64, + pub status_code: u16, + pub error_message: Option, + pub created_at: i64, +} + +impl Database { + /// 获取使用量汇总 + pub fn get_usage_summary( + &self, + start_date: Option, + end_date: Option, + ) -> Result { + let conn = lock_conn!(self.conn); + + let (where_clause, params_vec) = if start_date.is_some() || end_date.is_some() { + let mut conditions = Vec::new(); + let mut params = Vec::new(); + + if let Some(start) = start_date { + conditions.push("created_at >= ?"); + params.push(start); + } + if let Some(end) = end_date { + conditions.push("created_at <= ?"); + params.push(end); + } + + (format!("WHERE {}", conditions.join(" AND ")), params) + } else { + (String::new(), Vec::new()) + }; + + let sql = format!( + "SELECT + COUNT(*) as total_requests, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count + FROM proxy_request_logs + {where_clause}" + ); + + let result = conn.query_row(&sql, rusqlite::params_from_iter(params_vec), |row| { + let total_requests: i64 = row.get(0)?; + let total_cost: f64 = row.get(1)?; + let total_tokens: i64 = row.get(2)?; + let success_count: i64 = row.get(3)?; + + let success_rate = if total_requests > 0 { + (success_count as f32 / total_requests as f32) * 100.0 + } else { + 0.0 + }; + + Ok(UsageSummary { + total_requests: total_requests as u64, + total_cost: format!("{:.6}", total_cost), + total_input_tokens: 0, + total_output_tokens: 0, + success_rate, + }) + })?; + + Ok(result) + } + + /// 获取每日趋势 + pub fn get_daily_trends( + &self, + days: u32, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let sql = "SELECT + date(created_at, 'unixepoch') as date, + COUNT(*) as request_count, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens + FROM proxy_request_logs + WHERE created_at >= strftime('%s', 'now', ?) + GROUP BY date + ORDER BY date DESC"; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([format!("-{} days", days)], |row| { + Ok(DailyStats { + date: row.get(0)?, + request_count: row.get::<_, i64>(1)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(2)?), + total_tokens: row.get::<_, i64>(3)? as u64, + }) + })?; + + let mut stats = Vec::new(); + for row in rows { + stats.push(row?); + } + + Ok(stats) + } + + /// 获取 Provider 统计 + pub fn get_provider_stats(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let sql = "SELECT + l.provider_id, + p.name as provider_name, + COUNT(*) as request_count, + COALESCE(SUM(l.input_tokens + 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(AVG(l.latency_ms), 0) as avg_latency + FROM proxy_request_logs l + LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type + GROUP BY l.provider_id, l.app_type + ORDER BY total_cost DESC"; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], |row| { + let request_count: i64 = row.get(2)?; + let success_count: i64 = row.get(5)?; + let success_rate = if request_count > 0 { + (success_count as f32 / request_count as f32) * 100.0 + } else { + 0.0 + }; + + Ok(ProviderStats { + provider_id: row.get(0)?, + provider_name: row.get::<_, Option>(1)?.unwrap_or_else(|| "Unknown".to_string()), + request_count: request_count as u64, + total_tokens: row.get::<_, i64>(3)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(4)?), + success_rate, + avg_latency_ms: row.get::<_, f64>(6)? as u64, + }) + })?; + + let mut stats = Vec::new(); + for row in rows { + stats.push(row?); + } + + Ok(stats) + } + + /// 获取模型统计 + pub fn get_model_stats(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let sql = "SELECT + model, + COUNT(*) as request_count, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost + FROM proxy_request_logs + GROUP BY model + ORDER BY total_cost DESC"; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], |row| { + let request_count: i64 = row.get(1)?; + let total_cost: f64 = row.get(3)?; + let avg_cost = if request_count > 0 { + total_cost / request_count as f64 + } else { + 0.0 + }; + + Ok(ModelStats { + model: row.get(0)?, + request_count: request_count as u64, + total_tokens: row.get::<_, i64>(2)? as u64, + total_cost: format!("{:.6}", total_cost), + avg_cost_per_request: format!("{:.6}", avg_cost), + }) + })?; + + let mut stats = Vec::new(); + for row in rows { + stats.push(row?); + } + + Ok(stats) + } + + /// 获取请求日志列表 + pub fn get_request_logs( + &self, + filters: &LogFilters, + limit: u32, + offset: u32, + ) -> Result, AppError> { + 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 model) = filters.model { + conditions.push("model = ?"); + params.push(Box::new(model.clone())); + } + if let Some(status) = filters.status_code { + conditions.push("status_code = ?"); + params.push(Box::new(status as i64)); + } + if let Some(start) = filters.start_date { + conditions.push("created_at >= ?"); + params.push(Box::new(start)); + } + if let Some(end) = filters.end_date { + conditions.push("created_at <= ?"); + params.push(Box::new(end)); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + params.push(Box::new(limit as i64)); + params.push(Box::new(offset as i64)); + + let sql = format!( + "SELECT request_id, provider_id, app_type, 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 + FROM proxy_request_logs + {where_clause} + ORDER BY created_at DESC + LIMIT ? OFFSET ?" + ); + + let mut stmt = conn.prepare(&sql)?; + let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt.query_map(params_refs.as_slice(), |row| { + Ok(RequestLogDetail { + request_id: row.get(0)?, + provider_id: row.get(1)?, + app_type: row.get(2)?, + model: row.get(3)?, + input_tokens: row.get::<_, i64>(4)? as u32, + output_tokens: row.get::<_, i64>(5)? as u32, + cache_read_tokens: row.get::<_, i64>(6)? as u32, + cache_creation_tokens: row.get::<_, i64>(7)? as u32, + input_cost_usd: row.get(8)?, + output_cost_usd: row.get(9)?, + cache_read_cost_usd: row.get(10)?, + cache_creation_cost_usd: row.get(11)?, + total_cost_usd: row.get(12)?, + latency_ms: row.get::<_, i64>(13)? as u64, + status_code: row.get::<_, i64>(14)? as u16, + error_message: row.get(15)?, + created_at: row.get(16)?, + }) + })?; + + let mut logs = Vec::new(); + for row in rows { + logs.push(row?); + } + + Ok(logs) + } + + /// 获取单个请求详情 + pub fn get_request_detail(&self, request_id: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let result = conn.query_row( + "SELECT request_id, provider_id, app_type, 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 + FROM proxy_request_logs + WHERE request_id = ?", + [request_id], + |row| { + Ok(RequestLogDetail { + request_id: row.get(0)?, + provider_id: row.get(1)?, + app_type: row.get(2)?, + model: row.get(3)?, + input_tokens: row.get::<_, i64>(4)? as u32, + output_tokens: row.get::<_, i64>(5)? as u32, + cache_read_tokens: row.get::<_, i64>(6)? as u32, + cache_creation_tokens: row.get::<_, i64>(7)? as u32, + input_cost_usd: row.get(8)?, + output_cost_usd: row.get(9)?, + cache_read_cost_usd: row.get(10)?, + cache_creation_cost_usd: row.get(11)?, + total_cost_usd: row.get(12)?, + latency_ms: row.get::<_, i64>(13)? as u64, + status_code: row.get::<_, i64>(14)? as u16, + error_message: row.get(15)?, + created_at: row.get(16)?, + }) + }, + ); + + match result { + Ok(detail) => Ok(Some(detail)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 检查 Provider 使用限额 + pub fn check_provider_limits( + &self, + provider_id: &str, + app_type: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + + // 获取 provider 的限额设置 + let (limit_daily, limit_monthly) = conn.query_row( + "SELECT meta FROM providers WHERE id = ? AND app_type = ?", + params![provider_id, app_type], + |row| { + let meta_str: String = row.get(0)?; + Ok(meta_str) + }, + ).ok().and_then(|meta_str| { + serde_json::from_str::(&meta_str).ok() + }).and_then(|meta| { + let daily = meta.get("limitDailyUsd") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + let monthly = meta.get("limitMonthlyUsd") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + Some((daily, monthly)) + }).unwrap_or((None, None)); + + // 计算今日使用量 + let daily_usage: f64 = conn.query_row( + "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) + FROM proxy_request_logs + WHERE provider_id = ? AND app_type = ? + AND date(created_at, 'unixepoch') = date('now')", + params![provider_id, app_type], + |row| row.get(0), + ).unwrap_or(0.0); + + // 计算本月使用量 + let monthly_usage: f64 = conn.query_row( + "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) + FROM proxy_request_logs + WHERE provider_id = ? AND app_type = ? + AND strftime('%Y-%m', created_at, 'unixepoch') = strftime('%Y-%m', 'now')", + params![provider_id, app_type], + |row| row.get(0), + ).unwrap_or(0.0); + + let daily_exceeded = limit_daily.map(|limit| daily_usage >= limit).unwrap_or(false); + let monthly_exceeded = limit_monthly.map(|limit| monthly_usage >= limit).unwrap_or(false); + + Ok(ProviderLimitStatus { + provider_id: provider_id.to_string(), + daily_usage: format!("{:.6}", daily_usage), + daily_limit: limit_daily.map(|l| format!("{:.2}", l)), + daily_exceeded, + monthly_usage: format!("{:.6}", monthly_usage), + monthly_limit: limit_monthly.map(|l| format!("{:.2}", l)), + monthly_exceeded, + }) + } +} + +/// Provider 限额状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderLimitStatus { + pub provider_id: String, + pub daily_usage: String, + pub daily_limit: Option, + pub daily_exceeded: bool, + pub monthly_usage: String, + pub monthly_limit: Option, + pub monthly_exceeded: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_usage_summary() -> Result<(), AppError> { + let db = Database::memory()?; + + // 插入测试数据 + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["req1", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000], + )?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["req2", "p1", "claude", "claude-3", 200, 100, "0.02", 150, 200, 2000], + )?; + } + + let summary = db.get_usage_summary(None, None)?; + assert_eq!(summary.total_requests, 2); + assert_eq!(summary.success_rate, 100.0); + + Ok(()) + } + + #[test] + fn test_get_model_stats() -> Result<(), AppError> { + let db = Database::memory()?; + + // 插入测试数据 + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["req1", "p1", "claude", "claude-3-sonnet", 100, 50, "0.01", 100, 200, 1000], + )?; + } + + let stats = db.get_model_stats()?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].model, "claude-3-sonnet"); + assert_eq!(stats[0].request_count, 1); + + Ok(()) + } +} diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx new file mode 100644 index 000000000..5a8e2594b --- /dev/null +++ b/src/components/usage/ModelStatsTable.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useModelStats } from '@/lib/query/usage'; + +export function ModelStatsTable() { + const { t } = useTranslation(); + const { data: stats, isLoading } = useModelStats(); + + if (isLoading) { + return
; + } + + return ( +
+ + + + {t('usage.model', '模型')} + {t('usage.requests', '请求数')} + {t('usage.tokens', 'Tokens')} + {t('usage.totalCost', '总成本')} + {t('usage.avgCost', '平均成本')} + + + + {stats?.length === 0 ? ( + + + {t('usage.noData', '暂无数据')} + + + ) : ( + stats?.map((stat) => ( + + {stat.model} + {stat.requestCount.toLocaleString()} + {stat.totalTokens.toLocaleString()} + ${parseFloat(stat.totalCost).toFixed(4)} + ${parseFloat(stat.avgCostPerRequest).toFixed(6)} + + )) + )} + +
+
+ ); +} diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx new file mode 100644 index 000000000..342c4c57b --- /dev/null +++ b/src/components/usage/PricingConfigPanel.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { useModelPricing } from '@/lib/query/usage'; +import { PricingEditModal } from './PricingEditModal'; +import type { ModelPricing } from '@/types/usage'; + +export function PricingConfigPanel() { + const { t } = useTranslation(); + const { data: pricing, isLoading } = useModelPricing(); + const [editingModel, setEditingModel] = useState(null); + + if (isLoading) { + return
; + } + + return ( +
+
+

{t('usage.modelPricing', '模型定价')}

+
+ +
+ + + + {t('usage.model', '模型')} + {t('usage.displayName', '显示名称')} + {t('usage.inputCost', '输入成本')} + {t('usage.outputCost', '输出成本')} + {t('usage.cacheReadCost', '缓存读取')} + {t('usage.cacheWriteCost', '缓存写入')} + {t('common.actions', '操作')} + + + + {pricing?.map((model) => ( + + {model.modelId} + {model.displayName} + ${model.inputCostPerMillion} + ${model.outputCostPerMillion} + ${model.cacheReadCostPerMillion} + ${model.cacheCreationCostPerMillion} + + + + + ))} + +
+
+ + {editingModel && ( + setEditingModel(null)} + /> + )} +
+ ); +} diff --git a/src/components/usage/PricingEditModal.tsx b/src/components/usage/PricingEditModal.tsx new file mode 100644 index 000000000..42384ea16 --- /dev/null +++ b/src/components/usage/PricingEditModal.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useUpdateModelPricing } from '@/lib/query/usage'; +import { useToast } from '@/hooks/use-toast'; +import type { ModelPricing } from '@/types/usage'; + +interface PricingEditModalProps { + model: ModelPricing; + onClose: () => void; +} + +export function PricingEditModal({ model, onClose }: PricingEditModalProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const updatePricing = useUpdateModelPricing(); + + const [formData, setFormData] = useState({ + displayName: model.displayName, + inputCost: model.inputCostPerMillion, + outputCost: model.outputCostPerMillion, + cacheReadCost: model.cacheReadCostPerMillion, + cacheCreationCost: model.cacheCreationCostPerMillion, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // 验证非负数 + const values = [ + formData.inputCost, + formData.outputCost, + formData.cacheReadCost, + formData.cacheCreationCost, + ]; + + for (const value of values) { + const num = parseFloat(value); + if (isNaN(num) || num < 0) { + toast({ + title: t('common.error', '错误'), + description: t('usage.invalidPrice', '价格必须为非负数'), + variant: 'destructive', + }); + return; + } + } + + try { + await updatePricing.mutateAsync({ + modelId: model.modelId, + displayName: formData.displayName, + inputCost: formData.inputCost, + outputCost: formData.outputCost, + cacheReadCost: formData.cacheReadCost, + cacheCreationCost: formData.cacheCreationCost, + }); + + toast({ + title: t('common.success', '成功'), + description: t('usage.pricingUpdated', '定价已更新'), + }); + + onClose(); + } catch (error) { + toast({ + title: t('common.error', '错误'), + description: String(error), + variant: 'destructive', + }); + } + }; + + return ( + + + + + {t('usage.editPricing', '编辑定价')} - {model.modelId} + + + +
+
+ + setFormData({ ...formData, displayName: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, inputCost: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, outputCost: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, cacheReadCost: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, cacheCreationCost: e.target.value })} + required + /> +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx new file mode 100644 index 000000000..03e57b53d --- /dev/null +++ b/src/components/usage/ProviderStatsTable.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from 'react-i18next'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useProviderStats } from '@/lib/query/usage'; + +export function ProviderStatsTable() { + const { t } = useTranslation(); + const { data: stats, isLoading } = useProviderStats(); + + if (isLoading) { + return
; + } + + return ( +
+ + + + {t('usage.provider', 'Provider')} + {t('usage.requests', '请求数')} + {t('usage.tokens', 'Tokens')} + {t('usage.cost', '成本')} + {t('usage.successRate', '成功率')} + {t('usage.avgLatency', '平均延迟')} + + + + {stats?.length === 0 ? ( + + + {t('usage.noData', '暂无数据')} + + + ) : ( + stats?.map((stat) => ( + + {stat.providerName} + {stat.requestCount.toLocaleString()} + {stat.totalTokens.toLocaleString()} + ${parseFloat(stat.totalCost).toFixed(4)} + {stat.successRate.toFixed(1)}% + {stat.avgLatencyMs}ms + + )) + )} + +
+
+ ); +} diff --git a/src/components/usage/RequestDetailPanel.tsx b/src/components/usage/RequestDetailPanel.tsx new file mode 100644 index 000000000..207273ed7 --- /dev/null +++ b/src/components/usage/RequestDetailPanel.tsx @@ -0,0 +1,178 @@ +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useRequestDetail } from '@/lib/query/usage'; + +interface RequestDetailPanelProps { + requestId: string; + onClose: () => void; +} + +export function RequestDetailPanel({ requestId, onClose }: RequestDetailPanelProps) { + const { t } = useTranslation(); + const { data: request, isLoading } = useRequestDetail(requestId); + + if (isLoading) { + return ( + + +
+ +
+ ); + } + + if (!request) { + return ( + + + + {t('usage.requestDetail', '请求详情')} + +
+ {t('usage.requestNotFound', '请求未找到')} +
+
+
+ ); + } + + return ( + + + + {t('usage.requestDetail', '请求详情')} + + +
+ {/* 基本信息 */} +
+

{t('usage.basicInfo', '基本信息')}

+
+
+
{t('usage.requestId', '请求ID')}
+
{request.requestId}
+
+
+
{t('usage.time', '时间')}
+
{new Date(request.createdAt * 1000).toLocaleString('zh-CN')}
+
+
+
{t('usage.provider', '供应商')}
+
+ {request.providerName || t('usage.unknownProvider', '未知')} + + {request.providerId} + +
+
+
+
{t('usage.appType', '应用类型')}
+
{request.appType}
+
+
+
{t('usage.model', '模型')}
+
{request.model}
+
+
+
{t('usage.status', '状态')}
+
+ = 200 && request.statusCode < 300 + ? 'bg-green-100 text-green-800' + : 'bg-red-100 text-red-800' + }`} + > + {request.statusCode} + +
+
+
+
+ + {/* Token 使用量 */} +
+

{t('usage.tokenUsage', 'Token 使用量')}

+
+
+
{t('usage.inputTokens', '输入 Tokens')}
+
{request.inputTokens.toLocaleString()}
+
+
+
{t('usage.outputTokens', '输出 Tokens')}
+
{request.outputTokens.toLocaleString()}
+
+
+
{t('usage.cacheReadTokens', '缓存读取')}
+
{request.cacheReadTokens.toLocaleString()}
+
+
+
{t('usage.cacheCreationTokens', '缓存写入')}
+
{request.cacheCreationTokens.toLocaleString()}
+
+
+
{t('usage.totalTokens', '总计')}
+
+ {(request.inputTokens + request.outputTokens).toLocaleString()} +
+
+
+
+ + {/* 成本明细 */} +
+

{t('usage.costBreakdown', '成本明细')}

+
+
+
{t('usage.inputCost', '输入成本')}
+
${parseFloat(request.inputCostUsd).toFixed(6)}
+
+
+
{t('usage.outputCost', '输出成本')}
+
${parseFloat(request.outputCostUsd).toFixed(6)}
+
+
+
{t('usage.cacheReadCost', '缓存读取成本')}
+
${parseFloat(request.cacheReadCostUsd).toFixed(6)}
+
+
+
{t('usage.cacheCreationCost', '缓存写入成本')}
+
${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
+
+
+
{t('usage.totalCost', '总成本')}
+
+ ${parseFloat(request.totalCostUsd).toFixed(6)} +
+
+
+
+ + {/* 性能信息 */} +
+

{t('usage.performance', '性能信息')}

+
+
+
{t('usage.latency', '延迟')}
+
{request.latencyMs}ms
+
+
+
+ + {/* 错误信息 */} + {request.errorMessage && ( +
+

{t('usage.errorMessage', '错误信息')}

+

{request.errorMessage}

+
+ )} +
+
+
+ ); +} diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx new file mode 100644 index 000000000..f8b4fa28d --- /dev/null +++ b/src/components/usage/RequestLogTable.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useRequestLogs } from '@/lib/query/usage'; +import type { LogFilters } from '@/types/usage'; + +export function RequestLogTable() { + const { t } = useTranslation(); + const [filters] = useState({}); + const [page, setPage] = useState(0); + const limit = 20; + + const { data: logs, isLoading } = useRequestLogs(filters, limit, page * limit); + + if (isLoading) { + return
; + } + + return ( +
+
+ + + + {t('usage.time', '时间')} + {t('usage.provider', 'Provider')} + {t('usage.model', '模型')} + {t('usage.tokens', 'Tokens')} + {t('usage.cost', '成本')} + {t('usage.latency', '延迟')} + {t('usage.status', '状态')} + + + + {logs?.length === 0 ? ( + + + {t('usage.noData', '暂无数据')} + + + ) : ( + logs?.map((log) => ( + + + {new Date(log.createdAt * 1000).toLocaleString('zh-CN')} + + {log.providerId} + {log.model} + + {(log.inputTokens + log.outputTokens).toLocaleString()} + + + ${parseFloat(log.totalCostUsd).toFixed(6)} + + {log.latencyMs}ms + + = 200 && log.statusCode < 300 + ? 'bg-green-100 text-green-800' + : 'bg-red-100 text-red-800' + }`} + > + {log.statusCode} + + + + )) + )} + +
+
+ + {logs && logs.length >= limit && ( +
+ + + {t('common.page', '第')} {page + 1} {t('common.pageUnit', '页')} + + +
+ )} +
+ ); +} diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx new file mode 100644 index 000000000..cdab41e36 --- /dev/null +++ b/src/components/usage/UsageDashboard.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Card } from '@/components/ui/card'; +import { UsageSummaryCards } from './UsageSummaryCards'; +import { UsageTrendChart } from './UsageTrendChart'; +import { RequestLogTable } from './RequestLogTable'; +import { ProviderStatsTable } from './ProviderStatsTable'; +import { ModelStatsTable } from './ModelStatsTable'; +import type { TimeRange } from '@/types/usage'; + +export function UsageDashboard() { + const { t } = useTranslation(); + const [timeRange, setTimeRange] = useState('7d'); + + const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90; + + return ( +
+
+

{t('usage.title', '使用统计')}

+ +
+ + + + + + + + + + {t('usage.requestLogs', '请求日志')} + {t('usage.providerStats', 'Provider 统计')} + {t('usage.modelStats', '模型统计')} + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx new file mode 100644 index 000000000..0b6f9f83e --- /dev/null +++ b/src/components/usage/UsageSummaryCards.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from 'react-i18next'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useUsageSummary } from '@/lib/query/usage'; + +interface UsageSummaryCardsProps { + days: number; +} + +export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { + const { t } = useTranslation(); + const endDate = Math.floor(Date.now() / 1000); + const startDate = endDate - days * 24 * 60 * 60; + + const { data: summary, isLoading } = useUsageSummary(startDate, endDate); + + if (isLoading) { + return ( +
+ {[...Array(4)].map((_, i) => ( + + +
+ + +
+ + + ))} +
+ ); + } + + return ( +
+ + + + {t('usage.totalRequests', '总请求数')} + + + +
{summary?.totalRequests || 0}
+
+
+ + + + + {t('usage.totalCost', '总成本')} + + + +
+ ${parseFloat(summary?.totalCost || '0').toFixed(4)} +
+
+
+ + + + + {t('usage.totalTokens', '总 Token 数')} + + + +
+ {((summary?.totalInputTokens || 0) + (summary?.totalOutputTokens || 0)).toLocaleString()} +
+
+
+ + + + + {t('usage.successRate', '成功率')} + + + +
+ {summary?.successRate.toFixed(1) || 0}% +
+
+
+
+ ); +} diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx new file mode 100644 index 000000000..7898de6dd --- /dev/null +++ b/src/components/usage/UsageTrendChart.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { useUsageTrends } from '@/lib/query/usage'; + +interface UsageTrendChartProps { + days: number; +} + +type MetricType = 'requests' | 'cost' | 'tokens'; + +export function UsageTrendChart({ days }: UsageTrendChartProps) { + const { t } = useTranslation(); + const [metric, setMetric] = useState('requests'); + const { data: trends, isLoading } = useUsageTrends(days); + + if (isLoading) { + return
; + } + + const chartData = trends?.map((stat) => ({ + date: new Date(stat.date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }), + requests: stat.requestCount, + cost: parseFloat(stat.totalCost), + tokens: stat.totalTokens, + })) || []; + + const getYAxisLabel = () => { + switch (metric) { + case 'requests': return t('usage.requests', '请求数'); + case 'cost': return t('usage.cost', '成本 (USD)'); + case 'tokens': return t('usage.tokens', 'Tokens'); + } + }; + + return ( +
+
+

{t('usage.trends', '使用趋势')}

+
+ + + +
+
+ + + + + + + + + + +
+ ); +} diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts new file mode 100644 index 000000000..dd0658ff1 --- /dev/null +++ b/src/lib/query/usage.ts @@ -0,0 +1,109 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { usageApi } from '@/lib/api/usage'; +import type { LogFilters } from '@/types/usage'; + +// Query keys +export const usageKeys = { + all: ['usage'] as const, + summary: (startDate?: number, endDate?: number) => + [...usageKeys.all, 'summary', startDate, endDate] as const, + trends: (days: number) => [...usageKeys.all, 'trends', days] as const, + providerStats: () => [...usageKeys.all, 'provider-stats'] as const, + modelStats: () => [...usageKeys.all, 'model-stats'] as const, + logs: (filters: LogFilters, limit: number, offset: number) => + [...usageKeys.all, 'logs', filters, limit, offset] as const, + detail: (requestId: string) => + [...usageKeys.all, 'detail', requestId] as const, + pricing: () => [...usageKeys.all, 'pricing'] as const, + limits: (providerId: string, appType: string) => + [...usageKeys.all, 'limits', providerId, appType] as const, +}; + +// Hooks +export function useUsageSummary(startDate?: number, endDate?: number) { + return useQuery({ + queryKey: usageKeys.summary(startDate, endDate), + queryFn: () => usageApi.getUsageSummary(startDate, endDate), + }); +} + +export function useUsageTrends(days: number) { + return useQuery({ + queryKey: usageKeys.trends(days), + queryFn: () => usageApi.getUsageTrends(days), + }); +} + +export function useProviderStats() { + return useQuery({ + queryKey: usageKeys.providerStats(), + queryFn: usageApi.getProviderStats, + }); +} + +export function useModelStats() { + return useQuery({ + queryKey: usageKeys.modelStats(), + queryFn: usageApi.getModelStats, + }); +} + +export function useRequestLogs( + filters: LogFilters, + limit: number = 50, + offset: number = 0 +) { + return useQuery({ + queryKey: usageKeys.logs(filters, limit, offset), + queryFn: () => usageApi.getRequestLogs(filters, limit, offset), + }); +} + +export function useRequestDetail(requestId: string) { + return useQuery({ + queryKey: usageKeys.detail(requestId), + queryFn: () => usageApi.getRequestDetail(requestId), + enabled: !!requestId, + }); +} + +export function useModelPricing() { + return useQuery({ + queryKey: usageKeys.pricing(), + queryFn: usageApi.getModelPricing, + }); +} + +export function useProviderLimits(providerId: string, appType: string) { + return useQuery({ + queryKey: usageKeys.limits(providerId, appType), + queryFn: () => usageApi.checkProviderLimits(providerId, appType), + enabled: !!providerId && !!appType, + }); +} + +export function useUpdateModelPricing() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { + modelId: string; + displayName: string; + inputCost: string; + outputCost: string; + cacheReadCost: string; + cacheCreationCost: string; + }) => + usageApi.updateModelPricing( + params.modelId, + params.displayName, + params.inputCost, + params.outputCost, + params.cacheReadCost, + params.cacheCreationCost + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: usageKeys.pricing() }); + }, + }); +} diff --git a/src/types/usage.ts b/src/types/usage.ts new file mode 100644 index 000000000..f8b4dc779 --- /dev/null +++ b/src/types/usage.ts @@ -0,0 +1,96 @@ +// 使用统计相关类型定义 + +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; +} + +export interface RequestLog { + requestId: string; + providerId: string; + appType: string; + model: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + inputCostUsd: string; + outputCostUsd: string; + cacheReadCostUsd: string; + cacheCreationCostUsd: string; + totalCostUsd: string; + latencyMs: number; + statusCode: number; + errorMessage?: string; + createdAt: number; +} + +export interface ModelPricing { + modelId: string; + displayName: string; + inputCostPerMillion: string; + outputCostPerMillion: string; + cacheReadCostPerMillion: string; + cacheCreationCostPerMillion: string; +} + +export interface UsageSummary { + totalRequests: number; + totalCost: string; + totalInputTokens: number; + totalOutputTokens: number; + successRate: number; +} + +export interface DailyStats { + date: string; + requestCount: number; + totalCost: string; + totalTokens: number; +} + +export interface ProviderStats { + providerId: string; + providerName: string; + requestCount: number; + totalTokens: number; + totalCost: string; + successRate: number; + avgLatencyMs: number; +} + +export interface ModelStats { + model: string; + requestCount: number; + totalTokens: number; + totalCost: string; + avgCostPerRequest: string; +} + +export interface LogFilters { + providerId?: string; + model?: string; + statusCode?: number; + startDate?: number; + endDate?: number; +} + +export interface ProviderLimitStatus { + providerId: string; + dailyUsage: string; + dailyLimit?: string; + dailyExceeded: boolean; + monthlyUsage: string; + monthlyLimit?: string; + monthlyExceeded: boolean; +} + +export type TimeRange = '7d' | '30d' | '90d'; + +export interface StatsFilters { + timeRange: TimeRange; + providerId?: string; + appType?: string; +}