mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 18:33:04 +08:00
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:
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user