use klieo_core::llm::{ChatRequest, ResponseFormat};
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
pub fn request_fingerprint(req: &ChatRequest) -> String {
let mut canonical = String::new();
push_json(&mut canonical, "messages", &req.messages);
push_json(&mut canonical, "tools", &req.tools);
push_json(&mut canonical, "temperature", &req.temperature);
push_json(&mut canonical, "max_tokens", &req.max_tokens);
push_json(&mut canonical, "stop", &req.stop);
canonical.push_str("response_format=");
canonical.push_str(&response_format_tag(&req.response_format));
let mut hash = FNV_OFFSET_BASIS;
for byte in canonical.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("{hash:016x}")
}
fn push_json<T: serde::Serialize>(buf: &mut String, name: &str, value: &T) {
buf.push_str(name);
buf.push('=');
buf.push_str(&serde_json::to_string(value).expect("request field is JSON-serializable"));
buf.push('\n');
}
fn response_format_tag(format: &ResponseFormat) -> String {
match format {
ResponseFormat::Text => "text".into(),
ResponseFormat::Json { schema } => format!("json:{schema}"),
ResponseFormat::StructuredOutput { schema } => format!("structured:{schema}"),
_ => "other".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use klieo_core::llm::{ChatRequest, Message, ResponseFormat, Role};
fn user_request(content: &str) -> ChatRequest {
ChatRequest {
messages: vec![Message {
role: Role::User,
content: content.into(),
tool_calls: vec![],
tool_call_id: None,
}],
tools: vec![],
temperature: None,
max_tokens: None,
response_format: ResponseFormat::Text,
stop: vec![],
timeout: None,
}
}
#[test]
fn same_request_same_fingerprint() {
assert_eq!(
request_fingerprint(&user_request("hello")),
request_fingerprint(&user_request("hello"))
);
}
#[test]
fn differing_message_changes_fingerprint() {
assert_ne!(
request_fingerprint(&user_request("hello")),
request_fingerprint(&user_request("goodbye"))
);
}
#[test]
fn timeout_does_not_affect_fingerprint() {
let mut with_timeout = user_request("hello");
with_timeout.timeout = Some(std::time::Duration::from_secs(30));
assert_eq!(
request_fingerprint(&user_request("hello")),
request_fingerprint(&with_timeout),
"timeout is operational, not part of replay identity"
);
}
#[test]
fn sampling_params_change_fingerprint() {
let mut hot = user_request("hello");
hot.temperature = Some(0.9);
assert_ne!(
request_fingerprint(&user_request("hello")),
request_fingerprint(&hot)
);
}
#[test]
fn fingerprint_is_16_lowercase_hex_chars() {
let fp = request_fingerprint(&user_request("hello"));
assert_eq!(fp.len(), 16, "FNV-1a 64 renders as 16 hex chars");
assert!(
fp.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"fingerprint must be lowercase hex: {fp}"
);
}
#[test]
fn empty_message_request_is_well_formed_and_distinct() {
let empty = ChatRequest {
messages: vec![],
tools: vec![],
temperature: None,
max_tokens: None,
response_format: ResponseFormat::Text,
stop: vec![],
timeout: None,
};
assert_eq!(request_fingerprint(&empty).len(), 16);
assert_ne!(
request_fingerprint(&empty),
request_fingerprint(&user_request("hello")),
"an empty request must not collide with a non-empty one"
);
}
#[test]
fn response_format_changes_fingerprint() {
let text = user_request("hello");
let mut json = user_request("hello");
json.response_format = ResponseFormat::Json {
schema: serde_json::json!({"type": "object"}),
};
let mut structured = user_request("hello");
structured.response_format = ResponseFormat::StructuredOutput {
schema: serde_json::json!({"type": "object"}),
};
let (ft, fj, fs) = (
request_fingerprint(&text),
request_fingerprint(&json),
request_fingerprint(&structured),
);
assert_ne!(ft, fj, "Text vs Json must differ");
assert_ne!(ft, fs, "Text vs StructuredOutput must differ");
assert_ne!(fj, fs, "Json vs StructuredOutput must differ");
}
}