mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 19:22:15 +08:00
fix(usage): dedupe Claude Desktop proxy and session logs (#5951)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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<String> {
|
||||
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![
|
||||
|
||||
@@ -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::<Result<Vec<_>, _>>()?;
|
||||
assert_eq!(request_ids, vec!["desktop-proxy"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_desktop_folds_into_claude_for_display() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
Reference in New Issue
Block a user