fix(proxy): add stable usage keys and idempotent raw-response logging

Derive request ids from upstream envelope ids (Codex/OpenAI top-level
id, Gemini responseId, Claude message id with non-empty filtering)
scoped as session:{app_type}:{provider_id}:{id} for non-Claude sources;
Claude keeps bare session:{id} to preserve session-log convergence.

The logger now queries and conditionally writes under a single
connection guard: identical semantic replays return without writing or
notifying, session_log rows may be upgraded by proxy, and same-id
different-semantic responses land on a deterministic SHA-256 collision
fallback key instead of being overwritten. Fixes the random-UUID
INSERT OR REPLACE duplication behind #5496.
This commit is contained in:
Jason
2026-07-20 12:16:57 +08:00
parent df3e07edbb
commit c9ac6efd69
5 changed files with 385 additions and 54 deletions
+91 -17
View File
@@ -30,6 +30,13 @@ fn openai_cache_write_tokens(usage: &Value) -> u32 {
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
fn response_id(body: &Value, field: &str) -> Option<String> {
body.get(field)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
/// Token 使用量统计
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage {
@@ -47,12 +54,18 @@ pub struct TokenUsage {
}
impl TokenUsage {
/// 生成与 session 日志共享的 request_id,用于跨源去重。
/// 有 message_id 时返回 `session:{id}`,否则回退到随机 UUID。
pub fn dedup_request_id(&self) -> String {
/// 生成稳定 request_id。Claude 不加作用域,以便继续与 session JSONL 的
/// `session:{message_id}` 主键收敛;其他协议加入 app/provider 作用域,避免
/// 不同上游复用 envelope id 时互相覆盖。
pub fn dedup_request_id(&self, scope: Option<(&str, &str)>) -> String {
self.message_id
.as_ref()
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
.map(|message_id| match scope {
Some((app_type, provider_id)) => {
format!("{SESSION_REQUEST_ID_PREFIX}{app_type}:{provider_id}:{message_id}")
}
None => format!("{SESSION_REQUEST_ID_PREFIX}{message_id}"),
})
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
@@ -88,10 +101,7 @@ impl TokenUsage {
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let message_id = body
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let message_id = response_id(body, "id");
Some(Self {
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
@@ -128,8 +138,8 @@ impl TokenUsage {
}
}
if message_id.is_none() {
if let Some(id) = message.get("id").and_then(|v| v.as_str()) {
message_id = Some(id.to_string());
if let Some(id) = response_id(message, "id") {
message_id = Some(id);
}
}
}
@@ -238,7 +248,7 @@ impl TokenUsage {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
message_id: response_id(body, "id"),
})
}
@@ -277,7 +287,7 @@ impl TokenUsage {
cache_read_tokens: cached_tokens,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
message_id: response_id(body, "id"),
})
}
@@ -312,7 +322,7 @@ impl TokenUsage {
cache_read_tokens: cached_tokens,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
message_id: response_id(body, "id"),
})
}
@@ -404,7 +414,7 @@ impl TokenUsage {
cache_read_tokens: cached_tokens,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
message_id: response_id(body, "id"),
})
}
@@ -416,7 +426,12 @@ impl TokenUsage {
if let Some(usage) = event.get("usage") {
if !usage.is_null() {
log::debug!("[Codex] 找到 usage: {usage:?}");
return Self::from_openai_response(event);
let mut parsed = Self::from_openai_response(event)?;
if parsed.message_id.is_none() {
parsed.message_id =
events.iter().find_map(|chunk| response_id(chunk, "id"));
}
return Some(parsed);
}
}
}
@@ -449,7 +464,7 @@ impl TokenUsage {
.unwrap_or(0) as u32,
cache_creation_tokens: 0,
model,
message_id: None,
message_id: response_id(body, "responseId"),
})
}
@@ -460,6 +475,7 @@ impl TokenUsage {
let mut total_tokens = 0u32;
let mut total_cache_read = 0u32;
let mut model: Option<String> = None;
let mut message_id: Option<String> = None;
for chunk in chunks {
if let Some(usage) = chunk.get("usageMetadata") {
@@ -488,6 +504,9 @@ impl TokenUsage {
model = Some(model_version.to_string());
}
}
if message_id.is_none() {
message_id = response_id(chunk, "responseId");
}
}
// 输出 tokens = 总 tokens - 输入 tokens
@@ -500,7 +519,7 @@ impl TokenUsage {
cache_read_tokens: total_cache_read,
cache_creation_tokens: 0,
model,
message_id: None,
message_id,
})
} else {
None
@@ -513,6 +532,61 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn response_ids_produce_scoped_dedup_keys_and_empty_ids_fall_back() {
let response = json!({
"id": "resp_123",
"model": "gpt-5.6",
"usage": { "input_tokens": 10, "output_tokens": 2 }
});
let usage = TokenUsage::from_codex_response(&response).unwrap();
assert_eq!(usage.message_id.as_deref(), Some("resp_123"));
assert_eq!(
usage.dedup_request_id(Some(("codex", "provider-a"))),
"session:codex:provider-a:resp_123"
);
let empty = json!({
"id": "",
"usage": { "input_tokens": 10, "output_tokens": 2 }
});
let empty_usage = TokenUsage::from_codex_response(&empty).unwrap();
assert!(empty_usage.message_id.is_none());
assert!(!empty_usage
.dedup_request_id(Some(("codex", "provider-a")))
.starts_with("session:"));
}
#[test]
fn stream_parsers_recover_ids_from_envelope_chunks() {
let openai = vec![
json!({"id": "chatcmpl_123", "choices": []}),
json!({
"usage": { "prompt_tokens": 10, "completion_tokens": 2 },
"choices": []
}),
];
assert_eq!(
TokenUsage::from_openai_stream_events(&openai)
.unwrap()
.message_id
.as_deref(),
Some("chatcmpl_123")
);
let gemini = vec![json!({
"responseId": "gemini_123",
"usageMetadata": { "promptTokenCount": 10, "totalTokenCount": 12 }
})];
assert_eq!(
TokenUsage::from_gemini_stream_chunks(&gemini)
.unwrap()
.message_id
.as_deref(),
Some("gemini_123")
);
}
#[test]
fn test_claude_response_parsing() {
let response = json!({