diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index a985fcaa6..b935c2ecc 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -105,11 +105,22 @@ impl Database { ) -> Result<(), AppError> { let trimmed = value.trim(); if trimmed.is_empty() { - return Err(AppError::InvalidInput("倍率不能为空".to_string())); + return Err(AppError::localized( + "error.multiplierEmpty", + "倍率不能为空", + "Multiplier cannot be empty", + )); } - trimmed - .parse::() - .map_err(|e| AppError::InvalidInput(format!("无效倍率: {value} - {e}")))?; + 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)?; let conn = lock_conn!(self.conn); conn.execute( @@ -153,11 +164,16 @@ impl Database { ) -> Result<(), AppError> { let trimmed = value.trim(); if !matches!(trimmed, "response" | "request") { - return Err(AppError::InvalidInput(format!( - "无效计费模式: {value}" - ))); + return Err(AppError::localized( + "error.invalidPricingMode", + format!("无效计费模式: {value}"), + format!("Invalid pricing mode: {value}"), + )); } + // 确保行存在 + self.ensure_proxy_config_row_exists(app_type)?; + let conn = lock_conn!(self.conn); conn.execute( "UPDATE proxy_config SET @@ -273,17 +289,90 @@ impl Database { Ok(()) } + /// 确保指定 app_type 的 proxy_config 行存在(同步版本,用于 set_* 函数) + /// + /// 使用与 schema.rs seed 相同的 per-app 默认值 + fn ensure_proxy_config_row_exists(&self, app_type: &str) -> Result<(), AppError> { + let conn = self + .conn + .lock() + .map_err(|e| AppError::Lock(e.to_string()))?; + + // 根据 app_type 使用不同的默认值(与 schema.rs seed 保持一致) + let (retries, fb_timeout, idle_timeout, cb_fail, cb_succ, cb_timeout, cb_rate, cb_min) = + match app_type { + "claude" => (6, 90, 180, 8, 3, 90, 0.7, 15), + "codex" => (3, 60, 120, 4, 2, 60, 0.6, 10), + "gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10), + _ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值 + }; + + conn.execute( + "INSERT OR IGNORE INTO proxy_config ( + app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests + ) VALUES (?1, ?2, ?3, ?4, 600, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + app_type, + retries, + fb_timeout, + idle_timeout, + cb_fail, + cb_succ, + cb_timeout, + cb_rate, + cb_min + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + /// 初始化 proxy_config 表的三行数据 + /// + /// 使用与 schema.rs seed 相同的 per-app 默认值 async fn init_proxy_config_rows(&self) -> Result<(), AppError> { let conn = lock_conn!(self.conn); - for app_type in &["claude", "codex", "gemini"] { - conn.execute( - "INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)", - [app_type], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - } + // 使用与 schema.rs seed 相同的 per-app 默认值 + // claude: 更激进的重试和超时配置 + conn.execute( + "INSERT OR IGNORE INTO proxy_config ( + app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests + ) VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // codex: 默认配置 + conn.execute( + "INSERT OR IGNORE INTO proxy_config ( + app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests + ) VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // gemini: 稍高的重试次数 + conn.execute( + "INSERT OR IGNORE INTO proxy_config ( + app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests + ) VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 2389ed172..759f8f2c4 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -465,7 +465,9 @@ impl Database { app_type: row.get(3)?, model: row.get(4)?, request_model: row.get(5)?, - cost_multiplier: row.get::<_, Option>(6)?.unwrap_or_else(|| "1".to_string()), + cost_multiplier: row + .get::<_, Option>(6)? + .unwrap_or_else(|| "1".to_string()), input_tokens: row.get::<_, i64>(7)? as u32, output_tokens: row.get::<_, i64>(8)? as u32, cache_read_tokens: row.get::<_, i64>(9)? as u32, @@ -700,21 +702,26 @@ impl Database { )?; let million = rust_decimal::Decimal::from(1_000_000u64); - let input_cost = rust_decimal::Decimal::from(log.input_tokens as u64) * pricing.input - / million - * multiplier; - let output_cost = rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output - / million - * multiplier; + + // 与 CostCalculator::calculate 保持一致的计算逻辑: + // 1. input_cost 需要扣除 cache_read_tokens(避免缓存部分被重复计费) + // 2. 各项成本是基础成本(不含倍率) + // 3. 倍率只作用于最终总价 + let billable_input_tokens = + (log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64); + let input_cost = + rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million; + let output_cost = + rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output / million; let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64) * pricing.cache_read - / million - * multiplier; + / million; let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64) * pricing.cache_creation - / million - * multiplier; - let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost; + / million; + // 总成本 = 基础成本之和 × 倍率 + let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost; + let total_cost = base_total * multiplier; log.input_cost_usd = format!("{input_cost:.6}"); log.output_cost_usd = format!("{output_cost:.6}"); diff --git a/src-tauri/tests/proxy_commands.rs b/src-tauri/tests/proxy_commands.rs index d727e7ae7..daa29f093 100644 --- a/src-tauri/tests/proxy_commands.rs +++ b/src-tauri/tests/proxy_commands.rs @@ -7,6 +7,8 @@ use cc_switch_lib::{ mod support; use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex}; +// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为 +#[allow(clippy::await_holding_lock)] #[tokio::test] async fn default_cost_multiplier_commands_round_trip() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -31,12 +33,17 @@ async fn default_cost_multiplier_commands_round_trip() { let err = set_default_cost_multiplier_test_hook(&state, "claude", "not-a-number") .await .expect_err("invalid multiplier should error"); + // 错误已改为 Localized 类型(支持 i18n) match err { - AppError::InvalidInput(_) => {} - other => panic!("expected invalid input error, got {other:?}"), + AppError::Localized { key, .. } => { + assert_eq!(key, "error.invalidMultiplier"); + } + other => panic!("expected localized error, got {other:?}"), } } +// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为 +#[allow(clippy::await_holding_lock)] #[tokio::test] async fn pricing_model_source_commands_round_trip() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -61,8 +68,11 @@ async fn pricing_model_source_commands_round_trip() { let err = set_pricing_model_source_test_hook(&state, "claude", "invalid") .await .expect_err("invalid pricing model source should error"); + // 错误已改为 Localized 类型(支持 i18n) match err { - AppError::InvalidInput(_) => {} - other => panic!("expected invalid input error, got {other:?}"), + AppError::Localized { key, .. } => { + assert_eq!(key, "error.invalidPricingMode"); + } + other => panic!("expected localized error, got {other:?}"), } } diff --git a/src/components/usage/RequestDetailPanel.tsx b/src/components/usage/RequestDetailPanel.tsx index ea62f7780..4b9b191c3 100644 --- a/src/components/usage/RequestDetailPanel.tsx +++ b/src/components/usage/RequestDetailPanel.tsx @@ -184,6 +184,9 @@ export function RequestDetailPanel({
{t("usage.inputCost", "输入成本")} + + ({t("usage.baseCost", "基础")}) +
${parseFloat(request.inputCostUsd).toFixed(6)} @@ -192,6 +195,9 @@ export function RequestDetailPanel({
{t("usage.outputCost", "输出成本")} + + ({t("usage.baseCost", "基础")}) +
${parseFloat(request.outputCostUsd).toFixed(6)} @@ -200,6 +206,9 @@ export function RequestDetailPanel({
{t("usage.cacheReadCost", "缓存读取成本")} + + ({t("usage.baseCost", "基础")}) +
${parseFloat(request.cacheReadCostUsd).toFixed(6)} @@ -208,14 +217,35 @@ export function RequestDetailPanel({
{t("usage.cacheCreationCost", "缓存写入成本")} + + ({t("usage.baseCost", "基础")}) +
${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
-
+ {/* 显示成本倍率(如果不等于1) */} + {request.costMultiplier && + parseFloat(request.costMultiplier) !== 1 && ( +
+
+ {t("usage.costMultiplier", "成本倍率")} +
+
×{request.costMultiplier}
+
+ )} +
{t("usage.totalCost", "总成本")} + {request.costMultiplier && + parseFloat(request.costMultiplier) !== 1 && ( + + ({t("usage.withMultiplier", "含倍率")}) + + )}
${parseFloat(request.totalCostUsd).toFixed(6)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 212b3648c..9936f98e1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -653,7 +653,19 @@ "input": "Input", "output": "Output", "cacheWrite": "Creation", - "cacheRead": "Hit" + "cacheRead": "Hit", + "baseCost": "Base", + "costMultiplier": "Cost Multiplier", + "withMultiplier": "with multiplier", + "requestDetail": "Request Detail", + "requestNotFound": "Request not found", + "basicInfo": "Basic Info", + "tokenUsage": "Token Usage", + "cacheCreationCost": "Cache Creation Cost", + "costBreakdown": "Cost Breakdown", + "performance": "Performance", + "latency": "Latency", + "errorMessage": "Error Message" }, "usageScript": { "title": "Configure Usage Query", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6acbeb03a..ee568d40a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -653,7 +653,19 @@ "input": "Input", "output": "Output", "cacheWrite": "作成", - "cacheRead": "ヒット" + "cacheRead": "ヒット", + "baseCost": "基本", + "costMultiplier": "コスト倍率", + "withMultiplier": "倍率込み", + "requestDetail": "リクエスト詳細", + "requestNotFound": "リクエストが見つかりません", + "basicInfo": "基本情報", + "tokenUsage": "Token 使用量", + "cacheCreationCost": "キャッシュ作成コスト", + "costBreakdown": "コスト明細", + "performance": "パフォーマンス", + "latency": "レイテンシー", + "errorMessage": "エラーメッセージ" }, "usageScript": { "title": "利用状況を設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 21a1cb9f9..402bdac3a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -653,7 +653,19 @@ "input": "Input", "output": "Output", "cacheWrite": "创建", - "cacheRead": "命中" + "cacheRead": "命中", + "baseCost": "基础", + "costMultiplier": "成本倍率", + "withMultiplier": "含倍率", + "requestDetail": "请求详情", + "requestNotFound": "请求未找到", + "basicInfo": "基本信息", + "tokenUsage": "Token 使用量", + "cacheCreationCost": "缓存写入成本", + "costBreakdown": "成本明细", + "performance": "性能信息", + "latency": "延迟", + "errorMessage": "错误信息" }, "usageScript": { "title": "配置用量查询",