diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 530c5143b..bb2606486 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -2630,7 +2630,7 @@ async fn log_usage( model }; - let dedup_scope = (app_type != "claude").then_some((app_type, provider_id)); + let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id); let request_id = usage.dedup_request_id(dedup_scope); if let Err(e) = logger.log_with_calculation( diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index ed9cb3873..5402a43f7 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -642,7 +642,7 @@ async fn log_usage_internal( model }; - let dedup_scope = (app_type != "claude").then_some((app_type, provider_id)); + let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id); let request_id = usage.dedup_request_id(dedup_scope); log::debug!( diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index f90ce9479..7b6362ff3 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -687,6 +687,57 @@ mod tests { Ok(()) } + #[test] + fn claude_desktop_proxy_replaces_matching_session_log_row() -> Result<(), AppError> { + let db = Database::memory()?; + { + let conn = crate::database::lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, input_tokens, + output_tokens, cache_read_tokens, cache_creation_tokens, + latency_ms, status_code, created_at, data_source + ) VALUES ('session:msg_desktop', '_session', 'claude', + 'claude-sonnet-4-5', 10, 5, 2, 1, 0, 200, 1, 'session_log')", + [], + )?; + } + + let usage = TokenUsage { + input_tokens: 10, + output_tokens: 5, + cache_read_tokens: 2, + cache_creation_tokens: 1, + model: Some("claude-sonnet-4-5".to_string()), + message_id: Some("msg_desktop".to_string()), + }; + let request_id = usage.dedup_request_id(crate::proxy::usage::parser::dedup_scope_for_app( + "claude-desktop", + "desktop-provider", + )); + let mut proxy_log = request_log(&request_id, 10); + proxy_log.provider_id = "desktop-provider".to_string(); + proxy_log.app_type = "claude-desktop".to_string(); + proxy_log.model = "claude-sonnet-4-5".to_string(); + proxy_log.request_model = "claude-sonnet-4-5".to_string(); + proxy_log.pricing_model = "claude-sonnet-4-5".to_string(); + proxy_log.usage = usage; + + UsageLogger::new(&db).log_request(&proxy_log)?; + + let conn = crate::database::lock_conn!(db.conn); + let (count, source, app_type): (i64, String, String) = conn.query_row( + "SELECT COUNT(*), data_source, app_type FROM proxy_request_logs + WHERE request_id = 'session:msg_desktop'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!(count, 1); + assert_eq!(source, "proxy"); + assert_eq!(app_type, "claude-desktop"); + Ok(()) + } + #[test] fn test_log_error() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index 44fc64585..9cbb8232b 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -30,6 +30,16 @@ fn openai_cache_write_tokens(usage: &Value) -> u32 { /// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致 pub const SESSION_REQUEST_ID_PREFIX: &str = "session:"; +/// Claude Code and Claude Desktop share Claude message ids with the session +/// importer, so both use the bare `session:{message_id}` namespace. Other +/// apps retain app/provider scoping to avoid collisions between upstreams. +pub fn dedup_scope_for_app<'a>( + app_type: &'a str, + provider_id: &'a str, +) -> Option<(&'a str, &'a str)> { + (!matches!(app_type, "claude" | "claude-desktop")).then_some((app_type, provider_id)) +} + fn response_id(body: &Value, field: &str) -> Option { body.get(field) .and_then(Value::as_str) @@ -479,6 +489,25 @@ mod tests { .starts_with("session:")); } + #[test] + fn claude_apps_share_the_session_request_id_namespace() { + let usage = TokenUsage { + message_id: Some("msg_123".to_string()), + ..Default::default() + }; + + for app_type in ["claude", "claude-desktop"] { + assert_eq!( + usage.dedup_request_id(dedup_scope_for_app(app_type, "provider-a")), + "session:msg_123" + ); + } + assert_eq!( + usage.dedup_request_id(dedup_scope_for_app("codex", "provider-a")), + "session:codex:provider-a:msg_123" + ); + } + #[test] fn stream_parsers_recover_ids_from_envelope_chunks() { let openai = vec![ diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 117b221a6..326cd2837 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -230,6 +230,12 @@ fn data_source_expr(log_alias: &str) -> String { format!("COALESCE({log_alias}.data_source, 'proxy')") } +fn dedup_app_type_match_sql(left: &str, right: &str) -> String { + format!( + "{left} IN ({right}, CASE WHEN {right} = 'claude' THEN 'claude-desktop' ELSE {right} END)" + ) +} + /// SQL 标量表达式:把 Claude Desktop 网关的 `claude-desktop` app_type 在“展示口径” /// 上折叠进 `claude`,其余 app_type 原样返回。 /// @@ -243,8 +249,8 @@ fn data_source_expr(log_alias: &str) -> String { /// 而不改动任何已存储的行(详情面板仍读原始 `app_type`)。 /// /// 注意:包裹后该列上的索引在此比较中失效,但这些都是已带时间过滤的聚合扫描, -/// app_type 本就不是主访问路径,可接受。仅用于读侧;去重匹配(`has_matching_ -/// proxy_usage_log`)与额度检查(`check_provider_limits`)必须保留原始精确比较。 +/// app_type 本就不是主访问路径,可接受。仅用于读侧;跨源去重使用更窄的 +/// [`dedup_app_type_match_sql`],额度检查(`check_provider_limits`)仍保留原始精确比较。 fn folded_app_type_sql(column: &str) -> String { format!("CASE WHEN {column} = 'claude-desktop' THEN 'claude' ELSE {column} END") } @@ -298,6 +304,8 @@ fn push_provider_model_filters( pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { let data_source = data_source_expr(log_alias); let proxy_data_source = data_source_expr("proxy_dedup"); + let app_type_match = + dedup_app_type_match_sql("proxy_dedup.app_type", &format!("{log_alias}.app_type")); format!( "NOT ( {data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session') @@ -305,7 +313,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { SELECT 1 FROM proxy_request_logs proxy_dedup WHERE {proxy_data_source} = 'proxy' - AND proxy_dedup.app_type = {log_alias}.app_type + AND {app_type_match} AND proxy_dedup.status_code >= 200 AND proxy_dedup.status_code < 300 AND proxy_dedup.input_tokens = {log_alias}.input_tokens @@ -378,12 +386,13 @@ pub(crate) fn has_matching_proxy_usage_log( matches!(key.app_type, "codex" | "gemini" | "opencode") && key.cache_creation_tokens == 0; let l_data_source = data_source_expr("l"); + let app_type_match = dedup_app_type_match_sql("l.app_type", "?1"); let sql = format!( "SELECT EXISTS ( SELECT 1 FROM proxy_request_logs l WHERE {l_data_source} = 'proxy' - AND l.app_type = ?1 + AND {app_type_match} AND l.status_code >= 200 AND l.status_code < 300 AND l.input_tokens = ?3 @@ -2463,6 +2472,75 @@ mod tests { Ok(()) } + #[test] + fn test_matching_proxy_log_matches_claude_desktop_for_claude_session() -> Result<(), AppError> { + let conn = Connection::open_in_memory()?; + create_legacy_nullable_logs_table(&conn)?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, app_type, model, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source + ) VALUES ('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy')", + [], + )?; + + let key = DedupKey { + app_type: "claude", + model: "claude-sonnet-4-5", + input_tokens: 100, + output_tokens: 20, + cache_read_tokens: 10, + cache_creation_tokens: 5, + created_at: 1060, + }; + assert!(has_matching_proxy_usage_log(&conn, &key)?); + + let mut outside_window = key; + outside_window.created_at = 1_601; + assert!(!has_matching_proxy_usage_log(&conn, &outside_window)?); + + let mut different_model = key; + different_model.model = "claude-opus-4-5"; + assert!(!has_matching_proxy_usage_log(&conn, &different_model)?); + + let mut different_input = key; + different_input.input_tokens += 1; + assert!(!has_matching_proxy_usage_log(&conn, &different_input)?); + + let mut different_cache_creation = key; + different_cache_creation.cache_creation_tokens += 1; + assert!(!has_matching_proxy_usage_log( + &conn, + &different_cache_creation + )?); + + Ok(()) + } + + #[test] + fn test_effective_filter_dedups_claude_session_against_desktop_proxy() -> Result<(), AppError> { + let conn = Connection::open_in_memory()?; + create_legacy_nullable_logs_table(&conn)?; + conn.execute_batch( + "INSERT INTO proxy_request_logs ( + request_id, app_type, model, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source + ) VALUES + ('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy'), + ('claude-session', 'claude', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1060, 'session_log');", + )?; + + let filter = effective_usage_log_filter("l"); + let sql = format!("SELECT request_id FROM proxy_request_logs l WHERE {filter}"); + let request_ids = conn + .prepare(&sql)? + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + assert_eq!(request_ids, vec!["desktop-proxy"]); + + Ok(()) + } + #[test] fn test_claude_desktop_folds_into_claude_for_display() -> Result<(), AppError> { let db = Database::memory()?;