use sha2::{Digest, Sha256};
use crate::cm_types::ChatRequest;
#[derive(Debug, Clone)]
pub struct RequestFingerprint {
pub hash: String,
pub model: String,
pub round_index: usize,
}
impl RequestFingerprint {
pub fn from_request(req: &ChatRequest, round_index: usize) -> Self {
let mut hasher = Sha256::new();
hasher.update(b"model=");
hasher.update(req.model.as_bytes());
hasher.update(b"\nmessages=");
if let Ok(msgs_json) = serde_json::to_string(&req.messages) {
hasher.update(msgs_json.as_bytes());
} else {
hasher.update(format!("count={}", req.messages.len()).as_bytes());
for m in &req.messages {
hasher.update(b"|role=");
hasher.update(m.role.as_bytes());
}
}
if let Some(tools) = &req.tools {
hasher.update(b"\ntools=");
if let Ok(tools_json) = serde_json::to_string(tools) {
hasher.update(tools_json.as_bytes());
} else {
hasher.update(format!("count={}", tools.len()).as_bytes());
for t in tools {
hasher.update(b"|name=");
hasher.update(t.function.name.as_bytes());
}
}
}
if let Some(tc) = &req.tool_choice {
hasher.update(b"\ntool_choice=");
hasher.update(tc.as_bytes());
}
hasher.update(b"\nmax_tokens=");
hasher.update(req.max_tokens.to_string().as_bytes());
hasher.update(b"\ntemperature=");
hasher.update(format!("{:.6}", req.temperature).as_bytes());
if let Some(seed) = req.seed {
hasher.update(b"\nseed=");
hasher.update(seed.to_string().as_bytes());
}
if let Ok(vendor_json) = serde_json::to_string(&req.vendor)
&& vendor_json != "{}"
{
hasher.update(b"\nvendor=");
hasher.update(vendor_json.as_bytes());
}
hasher.update(b"\nround=");
hasher.update(round_index.to_string().as_bytes());
let digest = hasher.finalize();
let hash: String = digest.iter().map(|b| format!("{b:02x}")).collect();
Self {
hash,
model: req.model.clone(),
round_index,
}
}
pub fn short(&self) -> &str {
&self.hash[..12]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cm_types::{ChatRequest, ChatRequestCore, ChatRequestVendorExtensions, Message};
fn sample_request(model: &str) -> ChatRequest {
ChatRequest {
core: ChatRequestCore {
model: model.to_string(),
messages: vec![
Message::system_only("sys".to_string()),
Message::user_only("hello".to_string()),
],
tools: None,
tool_choice: None,
max_tokens: 128,
temperature: 0.7,
seed: Some(42),
stream: None,
},
vendor: ChatRequestVendorExtensions::default(),
}
}
#[test]
fn fingerprint_stable_for_same_request() {
let req = sample_request("deepseek-chat");
let fp1 = RequestFingerprint::from_request(&req, 0);
let fp2 = RequestFingerprint::from_request(&req, 0);
assert_eq!(fp1.hash, fp2.hash, "相同请求指纹必须一致");
assert_eq!(fp1.short().len(), 12);
}
#[test]
fn fingerprint_differs_on_model() {
let fp1 = RequestFingerprint::from_request(&sample_request("model-a"), 0);
let fp2 = RequestFingerprint::from_request(&sample_request("model-b"), 0);
assert_ne!(fp1.hash, fp2.hash, "不同模型指纹必须不同");
}
#[test]
fn fingerprint_differs_on_round_index() {
let req = sample_request("deepseek-chat");
let fp1 = RequestFingerprint::from_request(&req, 0);
let fp2 = RequestFingerprint::from_request(&req, 1);
assert_ne!(fp1.hash, fp2.hash, "不同 round 指纹必须不同");
}
#[test]
fn fingerprint_ignores_stream_field() {
let mut req1 = sample_request("deepseek-chat");
req1.core.stream = Some(true);
let mut req2 = sample_request("deepseek-chat");
req2.core.stream = Some(false);
let fp1 = RequestFingerprint::from_request(&req1, 0);
let fp2 = RequestFingerprint::from_request(&req2, 0);
assert_eq!(fp1.hash, fp2.hash, "stream 字段不应影响指纹(传输层开关)");
}
#[test]
fn fingerprint_differs_on_messages() {
let mut req1 = sample_request("deepseek-chat");
let mut req2 = sample_request("deepseek-chat");
req1.core
.messages
.push(Message::user_only("extra".to_string()));
req2.core
.messages
.push(Message::user_only("different".to_string()));
let fp1 = RequestFingerprint::from_request(&req1, 0);
let fp2 = RequestFingerprint::from_request(&req2, 0);
assert_ne!(fp1.hash, fp2.hash, "不同消息内容指纹必须不同");
}
}