fix(usage): prevent double-counting between proxy and session-log sources

Proxy writes and session-log sync wrote to proxy_request_logs with
mismatched request_ids: only Claude on a native Anthropic backend used the
shared `session:{message_id}` key. Codex/Gemini and Claude-through-OpenAI
providers always produced distinct ids, so primary-key dedup never fired
and every real request was recorded twice.

Adds a 7-dim fingerprint dedup (app_type, 4 token counts, 2xx status,
model with case-insensitive match, ±10min window) wired into three layers:

- Write path: should_skip_session_insert() blocks duplicate session rows
  before INSERT, unifying the previously-divergent Claude/Codex/Gemini
  paths through a single DedupKey-based helper.
- Read path: effective_usage_log_filter() excludes already-covered session
  rows from every aggregation query.
- Rollup path: same filter applied so usage_daily_rollups never absorbs
  duplicates.

Also adds a covering index (idx_request_logs_dedup_lookup) so the EXISTS
subquery stays index-only, and a transform.rs regression test that pins
openai_to_anthropic id preservation - the missing piece that lets
Claude+OpenAI-compatible providers reuse the session: id scheme.
This commit is contained in:
Jason
2026-04-29 09:35:42 +08:00
parent bcf8434c1f
commit 2ee7cb4101
7 changed files with 917 additions and 92 deletions
+69 -1
View File
@@ -19,6 +19,7 @@ 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::SessionSyncResult;
use crate::services::usage_stats::{should_skip_session_insert, DedupKey};
use rust_decimal::Decimal;
use std::fs;
use std::path::{Path, PathBuf};
@@ -237,7 +238,6 @@ fn insert_gemini_session_entry(
) -> Result<bool, AppError> {
let conn = lock_conn!(db.conn);
// 解析时间戳
let created_at = timestamp
.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
@@ -254,6 +254,19 @@ fn insert_gemini_session_entry(
// 合并 thoughts 到 output(思考 token 按输出计费)
let output_tokens = tokens.output + tokens.thoughts;
let dedup_key = DedupKey {
app_type: "gemini",
model,
input_tokens: tokens.input,
output_tokens,
cache_read_tokens: tokens.cached,
cache_creation_tokens: 0,
created_at,
};
if should_skip_session_insert(&conn, request_id, &dedup_key)? {
return Ok(false);
}
// 计算费用
let usage = TokenUsage {
input_tokens: tokens.input,
@@ -433,6 +446,61 @@ mod tests {
assert!(files.is_empty());
}
#[test]
fn test_insert_gemini_session_skips_matching_proxy_log() -> 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, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
total_cost_usd, latency_ms, status_code, created_at, data_source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
"gemini-proxy",
"google",
"gemini",
"gemini-2.5-pro",
"gemini-2.5-pro",
10,
7,
1,
0,
"0.01",
100,
200,
1000,
"proxy"
],
)?;
}
let tokens = GeminiTokens {
input: 10,
output: 2,
cached: 1,
thoughts: 5,
};
let inserted = insert_gemini_session_entry(
&db,
"gemini-session-dup",
&tokens,
"gemini-2.5-pro",
Some("session-1"),
Some("1970-01-01T00:16:45Z"),
)?;
assert!(!inserted);
let conn = lock_conn!(db.conn);
let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
row.get(0)
})?;
assert_eq!(count, 1);
Ok(())
}
#[test]
fn test_parse_gemini_tokens() {
let json: serde_json::Value = serde_json::json!({