diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 1401adb0a..677a6be1f 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -3,6 +3,8 @@ use crate::error::AppError; use crate::services::usage_stats::*; use crate::store::AppState; +use rust_decimal::Decimal; +use std::str::FromStr; use tauri::State; /// 获取使用量汇总 @@ -149,23 +151,67 @@ pub fn update_model_pricing( cache_creation_cost: String, ) -> Result<(), AppError> { let db = state.db.clone(); - let conn = crate::database::lock_conn!(db.conn); + let model_id = model_id.trim().to_string(); + let display_name = display_name.trim().to_string(); + if model_id.is_empty() { + return Err(AppError::localized( + "usage.modelIdRequired", + "模型 ID 不能为空", + "Model ID is required", + )); + } + if display_name.is_empty() { + return Err(AppError::localized( + "usage.displayNameRequired", + "显示名称不能为空", + "Display name is required", + )); + } - 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}")))?; + for (label, value) in [ + ("input_cost", &input_cost), + ("output_cost", &output_cost), + ("cache_read_cost", &cache_read_cost), + ("cache_creation_cost", &cache_creation_cost), + ] { + let parsed = Decimal::from_str(value.trim()).map_err(|e| { + AppError::localized( + "usage.invalidPrice", + format!("{label} 价格无效: {value} - {e}"), + format!("{label} price is invalid: {value} - {e}"), + ) + })?; + if parsed < Decimal::ZERO { + return Err(AppError::localized( + "usage.invalidPrice", + format!("{label} 价格必须为非负数: {value}"), + format!("{label} price must be non-negative: {value}"), + )); + } + } + + { + 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.trim(), + output_cost.trim(), + cache_read_cost.trim(), + cache_creation_cost.trim() + ], + ) + .map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?; + } + + if let Err(e) = db.backfill_missing_usage_costs_for_model(&model_id) { + log::warn!("模型定价更新后回填历史用量成本失败 (model_id={model_id}): {e}"); + } Ok(()) } diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 32a42d9ea..c3c31cf7e 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -2,12 +2,56 @@ //! //! 处理代理配置、Provider健康状态和使用统计的数据库操作 +use std::str::FromStr; + use crate::error::AppError; use crate::proxy::types::*; use rust_decimal::Decimal; use super::super::{lock_conn, Database}; +pub(crate) const PRICING_SOURCE_RESPONSE: &str = "response"; +pub(crate) const PRICING_SOURCE_REQUEST: &str = "request"; + +pub(crate) fn validate_cost_multiplier(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::localized( + "error.multiplierEmpty", + "倍率不能为空", + "Multiplier cannot be empty", + )); + } + let parsed = Decimal::from_str(trimmed).map_err(|e| { + AppError::localized( + "error.invalidMultiplier", + format!("无效倍率: {value} - {e}"), + format!("Invalid multiplier: {value} - {e}"), + ) + })?; + if parsed < Decimal::ZERO { + return Err(AppError::localized( + "error.invalidMultiplier", + format!("无效倍率: {value} - 倍率不能为负数"), + format!("Invalid multiplier: {value} - multiplier cannot be negative"), + )); + } + Ok(parsed) +} + +pub(crate) fn validate_pricing_source(value: &str) -> Result<&str, AppError> { + let trimmed = value.trim(); + if trimmed == PRICING_SOURCE_RESPONSE || trimmed == PRICING_SOURCE_REQUEST { + Ok(trimmed) + } else { + Err(AppError::localized( + "error.invalidPricingMode", + format!("无效计费模式: {value}"), + format!("Invalid pricing mode: {value}"), + )) + } +} + impl Database { // ==================== Global Proxy Config ==================== @@ -103,21 +147,8 @@ impl Database { app_type: &str, value: &str, ) -> Result<(), AppError> { + validate_cost_multiplier(value)?; let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(AppError::localized( - "error.multiplierEmpty", - "倍率不能为空", - "Multiplier cannot be empty", - )); - } - trimmed.parse::().map_err(|e| { - AppError::localized( - "error.invalidMultiplier", - format!("无效倍率: {value} - {e}"), - format!("Invalid multiplier: {value} - {e}"), - ) - })?; // 确保行存在 self.ensure_proxy_config_row_exists(app_type)?; @@ -150,7 +181,7 @@ impl Database { Ok(value) => Ok(value), Err(rusqlite::Error::QueryReturnedNoRows) => { self.init_proxy_config_rows().await?; - Ok("response".to_string()) + Ok(PRICING_SOURCE_RESPONSE.to_string()) } Err(e) => Err(AppError::Database(e.to_string())), } @@ -162,14 +193,7 @@ impl Database { app_type: &str, value: &str, ) -> Result<(), AppError> { - let trimmed = value.trim(); - if !matches!(trimmed, "response" | "request") { - return Err(AppError::localized( - "error.invalidPricingMode", - format!("无效计费模式: {value}"), - format!("Invalid pricing mode: {value}"), - )); - } + let trimmed = validate_pricing_source(value)?; // 确保行存在 self.ensure_proxy_config_row_exists(app_type)?; @@ -911,6 +935,18 @@ mod tests { } )); + let err = db + .set_default_cost_multiplier("claude", "-0.5") + .await + .unwrap_err(); + assert!(matches!( + err, + AppError::Localized { + key: "error.invalidMultiplier", + .. + } + )); + Ok(()) } } diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 47c08695f..9a2ac314c 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -33,6 +33,10 @@ mod tests; // DAO 类型导出供外部使用 pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID; +pub(crate) use dao::proxy::{ + validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST, + PRICING_SOURCE_RESPONSE, +}; pub use dao::FailoverQueueItem; use crate::config::get_app_config_dir; diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 85fdf0677..101f2f1de 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -33,6 +33,7 @@ use super::{ ProxyError, }; use crate::app_config::AppType; +use crate::database::PRICING_SOURCE_REQUEST; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use bytes::Bytes; use http_body_util::BodyExt; @@ -880,7 +881,7 @@ async fn log_usage( let (multiplier, pricing_model_source) = logger.resolve_pricing_config(provider_id, app_type).await; - let pricing_model = if pricing_model_source == "request" { + let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST { request_model } else { model diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 94de7d326..c20ff98a4 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -12,6 +12,7 @@ use super::{ usage::parser::TokenUsage, ProxyError, }; +use crate::database::PRICING_SOURCE_REQUEST; use axum::http::{header::HeaderMap, HeaderName}; use axum::response::{IntoResponse, Response}; use bytes::Bytes; @@ -372,6 +373,7 @@ pub struct SseUsageCollector { struct SseUsageCollectorInner { events: Mutex>, first_event_time: Mutex>, + first_event_set: AtomicBool, start_time: std::time::Instant, on_complete: UsageCallbackWithTiming, should_collect: Option, @@ -390,6 +392,7 @@ impl SseUsageCollector { inner: Arc::new(SseUsageCollectorInner { events: Mutex::new(Vec::new()), first_event_time: Mutex::new(None), + first_event_set: AtomicBool::new(false), start_time, on_complete, should_collect, @@ -405,15 +408,21 @@ impl SseUsageCollector { .unwrap_or(true) } + /// 标记首个被收集的 SSE 事件时间,沿用 `first_token_ms` 的既有近似语义。 + async fn mark_first_collected_event_time(&self) { + if self.inner.first_event_set.load(Ordering::Acquire) { + return; + } + let mut first_time = self.inner.first_event_time.lock().await; + if first_time.is_none() { + *first_time = Some(std::time::Instant::now()); + self.inner.first_event_set.store(true, Ordering::Release); + } + } + /// 推送 SSE 事件 pub async fn push(&self, event: Value) { - // 记录首个事件时间 - { - let mut first_time = self.inner.first_event_time.lock().await; - if first_time.is_none() { - *first_time = Some(std::time::Instant::now()); - } - } + self.mark_first_collected_event_time().await; let mut events = self.inner.events.lock().await; events.push(event); } @@ -438,6 +447,36 @@ impl SseUsageCollector { } } +struct SseUsageFinishGuard { + collector: Option, +} + +impl SseUsageFinishGuard { + fn new(collector: SseUsageCollector) -> Self { + Self { + collector: Some(collector), + } + } + + fn disarm(&mut self) { + self.collector = None; + } +} + +impl Drop for SseUsageFinishGuard { + fn drop(&mut self) { + if let Some(collector) = self.collector.take() { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + collector.finish().await; + }); + } else { + log::warn!("SSE 用量收尾保护触发时 Tokio runtime 不可用,跳过异步 finish"); + } + } + } +} + // ============================================================================ // 内部辅助函数 // ============================================================================ @@ -598,7 +637,7 @@ async fn log_usage_internal( let logger = UsageLogger::new(&state.db); let (multiplier, pricing_model_source) = logger.resolve_pricing_config(provider_id, app_type).await; - let pricing_model = if pricing_model_source == "request" { + let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST { request_model } else { model @@ -648,6 +687,7 @@ pub fn create_logged_passthrough_stream( let mut buffer = String::new(); let mut utf8_remainder: Vec = Vec::new(); let mut collector = usage_collector; + let mut finish_guard = collector.clone().map(SseUsageFinishGuard::new); let inspect_sse_events = collector.is_some() || log::log_enabled!(log::Level::Debug); let mut is_first_chunk = true; @@ -753,6 +793,9 @@ pub fn create_logged_passthrough_stream( if let Some(c) = collector.take() { c.finish().await; } + if let Some(guard) = &mut finish_guard { + guard.disarm(); + } } } diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index 0bc5b1217..940f08839 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -2,11 +2,11 @@ use super::calculator::{CostBreakdown, CostCalculator, ModelPricing}; use super::parser::TokenUsage; -use crate::database::Database; +use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE}; use crate::error::AppError; use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model}; use rust_decimal::Decimal; -use std::{str::FromStr, time::SystemTime}; +use std::str::FromStr; /// 请求日志 #[derive(Debug, Clone)] @@ -64,13 +64,7 @@ impl<'a> UsageLogger<'a> { ) }; - let created_at = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or_else(|e| { - log::warn!("SystemTime is before UNIX_EPOCH, falling back to 0: {e}"); - 0 - }); + let created_at = chrono::Utc::now().timestamp(); conn.execute( "INSERT OR REPLACE INTO proxy_request_logs ( @@ -227,18 +221,19 @@ impl<'a> UsageLogger<'a> { Ok(value) => value, Err(e) => { log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}"); - "response".to_string() + PRICING_SOURCE_RESPONSE.to_string() } }; - let default_pricing_source = - if matches!(default_pricing_source_raw.as_str(), "response" | "request") { - default_pricing_source_raw - } else { - log::warn!( + let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE + || default_pricing_source_raw == PRICING_SOURCE_REQUEST + { + default_pricing_source_raw + } else { + log::warn!( "[USG-003] 默认计费模式无效 (app_type={app_type}): {default_pricing_source_raw}" ); - "response".to_string() - }; + PRICING_SOURCE_RESPONSE.to_string() + }; let provider = self .db @@ -271,7 +266,9 @@ impl<'a> UsageLogger<'a> { }; let pricing_model_source = match provider_pricing_source { - Some(value) if matches!(value, "response" | "request") => value.to_string(), + Some(value) if value == PRICING_SOURCE_RESPONSE || value == PRICING_SOURCE_REQUEST => { + value.to_string() + } Some(value) => { log::warn!("[USG-003] 供应商计费模式无效 (provider_id={provider_id}): {value}"); default_pricing_source.clone() diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 64c6120b0..aad1885ab 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -13,6 +13,7 @@ use serde::Deserialize; use serde_json::Value; use crate::app_config::AppType; +use crate::database::{validate_cost_multiplier, validate_pricing_source}; use crate::error::AppError; use crate::provider::{Provider, UsageResult}; use crate::services::mcp::McpService; @@ -261,6 +262,35 @@ mod tests { ); } + #[test] + fn validate_provider_settings_rejects_negative_cost_multiplier() { + let mut provider = Provider::with_id( + "claude".into(), + "Claude".into(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token", + "ANTHROPIC_BASE_URL": "https://claude.example" + } + }), + None, + ); + provider.meta = Some(ProviderMeta { + cost_multiplier: Some("-1".to_string()), + ..ProviderMeta::default() + }); + + let err = ProviderService::validate_provider_settings(&AppType::Claude, &provider) + .expect_err("negative multiplier should be rejected"); + assert!(matches!( + err, + AppError::Localized { + key: "error.invalidMultiplier", + .. + } + )); + } + #[test] fn extract_credentials_returns_expected_values() { let provider = Provider::with_id( @@ -2145,6 +2175,12 @@ impl ProviderService { // Validate and clean UsageScript configuration (common for all app types) if let Some(meta) = &provider.meta { + if let Some(multiplier) = meta.cost_multiplier.as_deref() { + validate_cost_multiplier(multiplier)?; + } + if let Some(source) = meta.pricing_model_source.as_deref() { + validate_pricing_source(source)?; + } if let Some(usage_script) = &meta.usage_script { validate_usage_script(usage_script)?; } diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index 5f9f0f618..254371c02 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -14,7 +14,7 @@ use crate::error::AppError; use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; use crate::services::usage_stats::{ - effective_usage_log_filter, should_skip_session_insert, DedupKey, + effective_usage_log_filter, find_model_pricing, should_skip_session_insert, DedupKey, }; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -142,12 +142,7 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr // 获取文件元数据 let metadata = fs::metadata(file_path) .map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?; - let file_modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); + let file_modified = metadata_modified_nanos(&metadata); // 检查同步状态 let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?; @@ -321,6 +316,19 @@ pub(crate) fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64 Ok(result.unwrap_or((0, 0))) } +/// 返回文件 mtime 的纳秒时间戳。 +/// +/// `session_log_sync.last_modified` 旧数据是秒级时间戳;新写入纳秒值不需要 +/// schema 迁移,旧值会自然触发一次增量重扫,并继续依赖行 offset 避免重复导入。 +pub(crate) fn metadata_modified_nanos(metadata: &fs::Metadata) -> i64 { + metadata + .modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_nanos().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} + /// 更新 session_log_sync 表中某条目的同步进度。 /// /// Shared by all session_usage_* parsers. @@ -459,77 +467,7 @@ fn find_model_pricing_for_session( conn: &rusqlite::Connection, model_id: &str, ) -> Option { - // 精确匹配 - if let Ok(Some(pricing)) = try_find_pricing(conn, model_id) { - return Some(pricing); - } - - // 模糊匹配:去掉日期后缀 - // 例如 "claude-opus-4-6-20260206" -> "claude-opus-4-6" - let parts: Vec<&str> = model_id.rsplitn(2, '-').collect(); - if parts.len() == 2 { - if let Some(suffix) = parts.first() { - if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) { - if let Ok(Some(pricing)) = try_find_pricing(conn, parts[1]) { - return Some(pricing); - } - } - } - } - - // 尝试 LIKE 匹配 - let pattern = format!("{model_id}%"); - 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 LIKE ?1 LIMIT 1", - rusqlite::params![pattern], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ); - - match result { - Ok((input, output, cache_read, cache_creation)) => { - ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok() - } - Err(_) => None, - } -} - -fn try_find_pricing( - conn: &rusqlite::Connection, - model_id: &str, -) -> Result, AppError> { - 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", - rusqlite::params![model_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ); - - match result { - Ok((input, output, cache_read, cache_creation)) => { - ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation) - .map(Some) - .map_err(|e| AppError::Database(format!("解析定价失败: {e}"))) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(AppError::Database(format!("查询定价失败: {e}"))), - } + find_model_pricing(conn, model_id) } /// 查询数据来源分布统计 diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index 4490cc781..ff00ede2c 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -18,8 +18,10 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; -use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult}; -use crate::services::usage_stats::{should_skip_session_insert, DedupKey}; +use crate::services::session_usage::{ + get_sync_state, metadata_modified_nanos, update_sync_state, SessionSyncResult, +}; +use crate::services::usage_stats::{find_model_pricing, should_skip_session_insert, DedupKey}; use rust_decimal::Decimal; use std::fs; use std::io::{BufRead, BufReader}; @@ -232,12 +234,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), // 获取文件元数据 let metadata = fs::metadata(file_path) .map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?; - let file_modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); + let file_modified = metadata_modified_nanos(&metadata); // 检查同步状态 let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?; @@ -540,51 +537,7 @@ fn insert_codex_session_entry( /// 查找 Codex 模型定价(带归一化) fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { - let normalized = normalize_codex_model(model_id); - - // 1. 精确匹配(归一化后的名称) - if let Some(pricing) = try_find_pricing(conn, &normalized) { - return Some(pricing); - } - - // 2. LIKE 模糊匹配(兜底) - let pattern = format!("{normalized}%"); - 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 LIKE ?1 LIMIT 1", - rusqlite::params![pattern], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .ok() - .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) -} - -/// 精确匹配定价查询 -fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { - 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", - rusqlite::params![model_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .ok() - .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) + find_model_pricing(conn, &normalize_codex_model(model_id)) } #[cfg(test)] diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs index 5e7b0434e..97abdb23c 100644 --- a/src-tauri/src/services/session_usage_gemini.rs +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -18,8 +18,10 @@ use crate::error::AppError; use crate::gemini_config::get_gemini_dir; use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; -use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult}; -use crate::services::usage_stats::{should_skip_session_insert, DedupKey}; +use crate::services::session_usage::{ + get_sync_state, metadata_modified_nanos, update_sync_state, SessionSyncResult, +}; +use crate::services::usage_stats::{find_model_pricing, should_skip_session_insert, DedupKey}; use rust_decimal::Decimal; use std::fs; use std::path::{Path, PathBuf}; @@ -126,12 +128,7 @@ fn sync_single_gemini_file(db: &Database, file_path: &Path) -> Result<(u32, u32) // 获取文件元数据 let metadata = fs::metadata(file_path) .map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?; - let file_modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); + let file_modified = metadata_modified_nanos(&metadata); // 检查同步状态 let (last_modified, _last_offset) = get_sync_state(db, &file_path_str)?; @@ -358,49 +355,7 @@ fn insert_gemini_session_entry( /// 查找 Gemini 模型定价 fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { - // 精确匹配 - if let Some(pricing) = try_find_pricing(conn, model_id) { - return Some(pricing); - } - - // LIKE 模糊匹配(兜底) - let pattern = format!("{model_id}%"); - 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 LIKE ?1 LIMIT 1", - rusqlite::params![pattern], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .ok() - .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) -} - -/// 精确匹配定价查询 -fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { - 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", - rusqlite::params![model_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .ok() - .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) + find_model_pricing(conn, model_id) } #[cfg(test)] diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index de4b8bbc9..24dc7edba 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -4,6 +4,7 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; +use crate::proxy::usage::calculator::ModelPricing; use crate::services::sql_helpers::fresh_input_sql; use chrono::{Local, NaiveDate, TimeZone, Timelike}; use rusqlite::{params, Connection, OptionalExtension}; @@ -1455,27 +1456,49 @@ impl Database { /// Recalculate stored zero-cost usage rows once pricing becomes available. pub(crate) fn backfill_missing_usage_costs(&self) -> Result { let conn = lock_conn!(self.conn); - Self::backfill_missing_usage_costs_on_conn(&conn) + Self::backfill_missing_usage_costs_on_conn(&conn, None) } - fn backfill_missing_usage_costs_on_conn(conn: &Connection) -> Result { - let mut logs = { - let mut stmt = conn.prepare( - "SELECT request_id, provider_id, NULL AS provider_name, app_type, model, request_model, + /// 仅回填指定 model_id 相关的零成本行;用于单条定价更新后的精准回填。 + pub(crate) fn backfill_missing_usage_costs_for_model( + &self, + model_id: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + Self::backfill_missing_usage_costs_on_conn(&conn, Some(model_id)) + } + + fn backfill_missing_usage_costs_on_conn( + conn: &Connection, + only_model_id: Option<&str>, + ) -> Result { + const BASE_SQL: &str = + "SELECT request_id, provider_id, NULL AS provider_name, app_type, model, request_model, cost_multiplier, 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, is_streaming, latency_ms, first_token_ms, duration_ms, status_code, error_message, created_at, data_source - FROM proxy_request_logs - WHERE CAST(total_cost_usd AS REAL) <= 0 - AND (input_tokens > 0 OR output_tokens > 0 - OR cache_read_tokens > 0 OR cache_creation_tokens > 0)", - )?; + FROM proxy_request_logs + WHERE CAST(total_cost_usd AS REAL) <= 0 + AND (input_tokens > 0 OR output_tokens > 0 + OR cache_read_tokens > 0 OR cache_creation_tokens > 0)"; - let rows = stmt.query_map([], row_to_request_log_detail)?; - rows.collect::, _>>()? + let mut logs = { + match only_model_id { + Some(model) => { + let sql = format!("{BASE_SQL} AND (model = ?1 OR request_model = ?1)"); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([model], row_to_request_log_detail)?; + rows.collect::, _>>()? + } + None => { + let mut stmt = conn.prepare(BASE_SQL)?; + let rows = stmt.query_map([], row_to_request_log_detail)?; + rows.collect::, _>>()? + } + } }; if logs.is_empty() { @@ -1638,6 +1661,15 @@ impl Database { } } +pub(crate) fn find_model_pricing(conn: &Connection, model_id: &str) -> Option { + find_model_pricing_row(conn, model_id) + .ok() + .flatten() + .and_then(|(input, output, cache_read, cache_creation)| { + ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok() + }) +} + pub(crate) fn find_model_pricing_row( conn: &Connection, model_id: &str, @@ -1741,6 +1773,9 @@ fn model_pricing_candidates(model_id: &str) -> Vec { if let Some(stripped) = strip_bedrock_model_version_suffix(&candidate) { queue.push(stripped); } + if let Some(stripped) = strip_model_date_suffix(&candidate) { + queue.push(stripped); + } if let Some(stripped) = strip_reasoning_effort_suffix(&candidate) { queue.push(stripped); } @@ -1854,6 +1889,27 @@ fn strip_bedrock_model_version_suffix(model_id: &str) -> Option { .then(|| base.to_string()) } +fn strip_model_date_suffix(model_id: &str) -> Option { + let bytes = model_id.as_bytes(); + if bytes.len() > 11 { + let start = bytes.len() - 11; + let suffix = &bytes[start..]; + let is_iso_date = suffix[0] == b'-' + && suffix[1..5].iter().all(|b| b.is_ascii_digit()) + && suffix[5] == b'-' + && suffix[6..8].iter().all(|b| b.is_ascii_digit()) + && suffix[8] == b'-' + && suffix[9..11].iter().all(|b| b.is_ascii_digit()); + if is_iso_date { + return Some(model_id[..start].to_string()); + } + } + + let (base, suffix) = model_id.rsplit_once('-')?; + (!base.is_empty() && suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit())) + .then(|| base.to_string()) +} + fn strip_reasoning_effort_suffix(model_id: &str) -> Option { for suffix in ["-minimal", "-low", "-medium", "-high", "-xhigh"] { if let Some(stripped) = model_id.strip_suffix(suffix) { @@ -1866,7 +1922,33 @@ fn strip_reasoning_effort_suffix(model_id: &str) -> Option { } fn should_try_pricing_prefix_match(model_id: &str) -> bool { - model_id.starts_with("claude-") && model_id.matches('-').count() >= 3 + let dash_count = model_id.matches('-').count(); + + if model_id.starts_with("claude-") { + return dash_count >= 3; + } + + if ["o1", "o3", "o4", "o5"] + .iter() + .any(|prefix| model_id.starts_with(prefix)) + { + return dash_count >= 1; + } + + const PREFIX_MATCH_FAMILIES: &[&str] = &[ + "gpt-", + "gemini-", + "deepseek-", + "qwen-", + "glm-", + "kimi-", + "minimax-", + ]; + + PREFIX_MATCH_FAMILIES + .iter() + .any(|prefix| model_id.starts_with(prefix)) + && dash_count >= 2 } #[cfg(test)] @@ -3030,6 +3112,40 @@ mod tests { Ok(()) } + #[test] + fn test_strip_model_date_suffix_is_utf8_safe() { + assert_eq!( + strip_model_date_suffix("模型-2026-05-14").as_deref(), + Some("模型") + ); + assert_eq!(strip_model_date_suffix("abc🚀12345678"), None); + } + + #[test] + fn test_prefix_pricing_does_not_match_short_base_model_to_variant() -> Result<(), AppError> { + let db = Database::memory()?; + let conn = lock_conn!(db.conn); + + conn.execute("DELETE FROM model_pricing WHERE model_id LIKE 'gpt-5%'", [])?; + for (model_id, display_name) in [("gpt-5-mini", "GPT-5 Mini"), ("gpt-5-pro", "GPT-5 Pro")] { + conn.execute( + "INSERT 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, '1', '2', '0', '0')", + params![model_id, display_name], + )?; + } + + let result = find_model_pricing_row(&conn, "gpt-5")?; + assert!( + result.is_none(), + "缺少 gpt-5 基础定价时,不应前缀误匹配到 gpt-5-mini/gpt-5-pro" + ); + + Ok(()) + } + #[test] fn test_model_pricing_matching() -> Result<(), AppError> { let db = Database::memory()?; @@ -3081,6 +3197,16 @@ mod tests { result.is_some(), "大小写混合的 GPT-5.5 模型应能归一化匹配到 gpt-5.5-high" ); + let result = find_model_pricing_row(&conn, "OpenAI/GPT-5.5-2026-05-14")?; + assert!( + result.is_some(), + "OpenAI 日期后缀模型应能回退到 gpt-5.5 基础定价" + ); + let result = find_model_pricing_row(&conn, "google/gemini-3-pro-preview-20260514")?; + assert!( + result.is_some(), + "Gemini 日期后缀模型应能回退到 gemini-3-pro-preview 基础定价" + ); // Claude Desktop route 短 ID:应通过前缀匹配到带日期的定价 let result = find_model_pricing_row(&conn, "claude-haiku-4-5")?; diff --git a/src/components/providers/forms/ProviderAdvancedConfig.tsx b/src/components/providers/forms/ProviderAdvancedConfig.tsx index 0a7c54d7b..8801ae873 100644 --- a/src/components/providers/forms/ProviderAdvancedConfig.tsx +++ b/src/components/providers/forms/ProviderAdvancedConfig.tsx @@ -297,6 +297,7 @@ export function ProviderAdvancedConfig({ id="cost-multiplier" type="number" step="0.01" + min="0" inputMode="decimal" value={pricingConfig.costMultiplier || ""} onChange={(e) => diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index cc19abd3b..da721337f 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -50,6 +50,7 @@ import { hasApiKeyField, } from "@/utils/providerConfigUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils"; +import { isNonNegativeDecimalString } from "@/types/usage"; import { getCodexCustomTemplate } from "@/config/codexTemplates"; import CodexConfigEditor from "./CodexConfigEditor"; import { CommonConfigEditor } from "./CommonConfigEditor"; @@ -814,6 +815,20 @@ function ProviderFormFull({ ); } + const costMultiplier = pricingConfig.costMultiplier?.trim(); + if ( + pricingConfig.enabled && + costMultiplier && + !isNonNegativeDecimalString(costMultiplier) + ) { + toast.error( + t("settings.globalProxy.defaultCostMultiplierInvalid", { + defaultValue: "成本倍率必须为非负数", + }), + ); + return; + } + // opencode / openclaw / hermes: providerKey 相关 // A 类(空)归到 issues;B 类(正则不合法 / 重复 / 状态加载中)仍硬拒绝 const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx index 29169a38f..5e69b4278 100644 --- a/src/components/usage/PricingConfigPanel.tsx +++ b/src/components/usage/PricingConfigPanel.tsx @@ -28,7 +28,7 @@ import { } from "@/components/ui/select"; import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage"; import { PricingEditModal } from "./PricingEditModal"; -import type { ModelPricing } from "@/types/usage"; +import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage"; import { Plus, Pencil, Trash2, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { proxyApi } from "@/lib/api/proxy"; @@ -143,7 +143,7 @@ export function PricingConfigPanel() { ); return; } - if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) { + if (!isNonNegativeDecimalString(trimmed)) { toast.error( `${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierInvalid")}`, ); @@ -281,6 +281,7 @@ export function PricingConfigPanel() { diff --git a/src/components/usage/PricingEditModal.tsx b/src/components/usage/PricingEditModal.tsx index 55cb582c3..d27738560 100644 --- a/src/components/usage/PricingEditModal.tsx +++ b/src/components/usage/PricingEditModal.tsx @@ -7,7 +7,7 @@ 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 type { ModelPricing } from "@/types/usage"; +import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage"; interface PricingEditModalProps { open: boolean; @@ -52,8 +52,7 @@ export function PricingEditModal({ ]; for (const value of values) { - const num = parseFloat(value); - if (isNaN(num) || num < 0) { + if (!isNonNegativeDecimalString(value)) { toast.error(t("usage.invalidPrice", "价格必须为非负数")); return; } diff --git a/src/components/usage/RequestDetailPanel.tsx b/src/components/usage/RequestDetailPanel.tsx index 9f21f7049..6a2c7619f 100644 --- a/src/components/usage/RequestDetailPanel.tsx +++ b/src/components/usage/RequestDetailPanel.tsx @@ -6,7 +6,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useRequestDetail } from "@/lib/query/usage"; -import { getFreshInputTokens } from "@/types/usage"; +import { getFreshInputTokens, isUnpricedUsage } from "@/types/usage"; interface RequestDetailPanelProps { requestId: string; @@ -53,6 +53,7 @@ export function RequestDetailPanel({ const freshInput = getFreshInputTokens(request); const isCacheInclusive = request.inputTokens !== freshInput; + const unpriced = isUnpricedUsage(request); return ( @@ -255,8 +256,14 @@ export function RequestDetailPanel({ )} -
- ${parseFloat(request.totalCostUsd).toFixed(6)} +
+ {unpriced + ? t("usage.unpriced", "未定价") + : `$${parseFloat(request.totalCostUsd).toFixed(6)}`}
diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 7d824f94d..2ea0204eb 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -21,6 +21,7 @@ import { useRequestLogs } from "@/lib/query/usage"; import { KNOWN_APP_TYPES, getFreshInputTokens, + isUnpricedUsage, type LogFilters, type UsageRangeSelection, } from "@/types/usage"; @@ -293,115 +294,127 @@ export function RequestLogTable({ ) : ( - logs.map((log) => ( - - - {new Date(log.createdAt * 1000).toLocaleString(locale, { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - })} - - - {log.providerName || t("usage.unknownProvider")} - - -
- {log.requestModel && - log.requestModel !== log.model ? ( - - {log.requestModel} - - {" → "} - {log.model} + logs.map((log) => { + const unpriced = isUnpricedUsage(log); + return ( + + + {new Date(log.createdAt * 1000).toLocaleString( + locale, + { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }, + )} + + + {log.providerName || t("usage.unknownProvider")} + + +
+ {log.requestModel && + log.requestModel !== log.model ? ( + + {log.requestModel} + + {" → "} + {log.model} + - - ) : ( - log.model - )} -
-
- - {(() => { - const freshInput = getFreshInputTokens(log); - const isCacheInclusive = - log.inputTokens !== freshInput; - return ( -
- {fmtInt(freshInput, locale)} -
- ); - })()} - {(log.cacheReadTokens > 0 || - log.cacheCreationTokens > 0) && ( -
- {[ - log.cacheReadTokens > 0 && - `R${fmtInt(log.cacheReadTokens, locale)}`, - log.cacheCreationTokens > 0 && - `W${fmtInt(log.cacheCreationTokens, locale)}`, - ] - .filter(Boolean) - .join("·")} + ) : ( + log.model + )}
- )} -
- - {fmtInt(log.outputTokens, locale)} - - -
- {fmtUsd(log.totalCostUsd, 4)} -
- {parseFiniteNumber(log.costMultiplier) != null && - parseFiniteNumber(log.costMultiplier) !== 1 && ( -
- × - {parseFiniteNumber(log.costMultiplier)?.toFixed( - 2, - )} + + + {(() => { + const freshInput = getFreshInputTokens(log); + const isCacheInclusive = + log.inputTokens !== freshInput; + return ( +
+ {fmtInt(freshInput, locale)} +
+ ); + })()} + {(log.cacheReadTokens > 0 || + log.cacheCreationTokens > 0) && ( +
+ {[ + log.cacheReadTokens > 0 && + `R${fmtInt(log.cacheReadTokens, locale)}`, + log.cacheCreationTokens > 0 && + `W${fmtInt(log.cacheCreationTokens, locale)}`, + ] + .filter(Boolean) + .join("·")}
)} -
- - {(log.latencyMs / 1000).toFixed(1)}s - {log.firstTokenMs != null && ( - - /{(log.firstTokenMs / 1000).toFixed(1)}s + + + {fmtInt(log.outputTokens, locale)} + + +
+ {unpriced + ? t("usage.unpriced", "未定价") + : fmtUsd(log.totalCostUsd, 4)} +
+ {parseFiniteNumber(log.costMultiplier) != null && + parseFiniteNumber(log.costMultiplier) !== 1 && ( +
+ × + {parseFiniteNumber(log.costMultiplier)?.toFixed( + 2, + )} +
+ )} +
+ + {(log.latencyMs / 1000).toFixed(1)}s + {log.firstTokenMs != null && ( + + /{(log.firstTokenMs / 1000).toFixed(1)}s + + )} + + + = 200 && log.statusCode < 300 + ? "text-green-600" + : "text-red-600" + } + > + {log.statusCode} - )} - - - = 200 && log.statusCode < 300 - ? "text-green-600" - : "text-red-600" - } - > - {log.statusCode} - - - - {log.dataSource || "proxy"} - - - )) + + + {log.dataSource || "proxy"} + + + ); + }) )} diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index bed48ed1e..3458fb114 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -302,7 +302,7 @@ export function useUpdateModelPricing() { params.cacheCreationCost, ), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: usageKeys.pricing() }); + queryClient.invalidateQueries({ queryKey: usageKeys.all }); }, }); } @@ -313,7 +313,7 @@ export function useDeleteModelPricing() { return useMutation({ mutationFn: (modelId: string) => usageApi.deleteModelPricing(modelId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: usageKeys.pricing() }); + queryClient.invalidateQueries({ queryKey: usageKeys.all }); }, }); } diff --git a/src/types/usage.ts b/src/types/usage.ts index 993c094b3..316d3481d 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -195,6 +195,50 @@ export function getFreshInputTokens(log: CacheNormalizableLog): number { return log.inputTokens; } +export const NON_NEGATIVE_DECIMAL_REGEX = /^\d+(?:\.\d+)?$/; + +export function isNonNegativeDecimalString(value: string): boolean { + const trimmed = value.trim(); + if (!NON_NEGATIVE_DECIMAL_REGEX.test(trimmed)) return false; + return Number.isFinite(Number(trimmed)); +} + +type UsageCostLog = Pick< + RequestLog, + | "inputTokens" + | "outputTokens" + | "cacheReadTokens" + | "cacheCreationTokens" + | "totalCostUsd" + | "statusCode" +> & + Partial>; + +export function hasUsageTokens(log: UsageCostLog): boolean { + return ( + log.inputTokens > 0 || + log.outputTokens > 0 || + log.cacheReadTokens > 0 || + log.cacheCreationTokens > 0 + ); +} + +export function isUnpricedUsage(log: UsageCostLog): boolean { + const totalCost = Number.parseFloat(log.totalCostUsd); + const multiplier = + log.costMultiplier == null + ? undefined + : Number.parseFloat(log.costMultiplier); + return ( + log.statusCode >= 200 && + log.statusCode < 300 && + hasUsageTokens(log) && + Number.isFinite(totalCost) && + (!Number.isFinite(multiplier) || multiplier !== 0) && + totalCost === 0 + ); +} + export interface StatsFilters { timeRange: UsageRangePreset; providerId?: string;