prompt_hash/lib.rs
1//! # prompt-hash
2//!
3//! Deterministic cache key for an LLM prompt.
4//!
5//! Inputs:
6//! - `model` — model id (already normalized by `claude-cost`/`openai-cost`
7//! if you care).
8//! - `messages` — `[(role, content)]`.
9//! - `temperature` — quantized to 2 decimal places to avoid floating
10//! point cache misses.
11//!
12//! Output: 64-char hex SHA-256.
13//!
14//! Use as a request-level cache key. For semantic cache keys (matching
15//! prompts with the same meaning but different wording), pair with
16//! [`semantic-cache-key`](https://crates.io/crates/semantic-cache-key).
17//!
18//! ## Example
19//!
20//! ```
21//! use prompt_hash::key;
22//! let k1 = key(
23//! "claude-sonnet-4-5",
24//! &[("user", "what is 2+2?")],
25//! 1.0,
26//! );
27//! let k2 = key(
28//! "claude-sonnet-4-5",
29//! &[("user", "what is 2+2? ")], // trailing ws ignored
30//! 1.0,
31//! );
32//! assert_eq!(k1, k2);
33//! assert_eq!(k1.len(), 64);
34//! ```
35
36#![deny(missing_docs)]
37
38mod sha256;
39
40/// Compute a 64-char hex cache key.
41pub fn key(model: &str, messages: &[(&str, &str)], temperature: f32) -> String {
42 let mut input = String::new();
43 input.push_str("model=");
44 input.push_str(model);
45 input.push('\n');
46 input.push_str("temp=");
47 // Quantize to 2 decimals (1.0 == 1.0000001).
48 input.push_str(&format!("{:.2}", temperature));
49 input.push('\n');
50 for (role, content) in messages {
51 input.push_str("role=");
52 input.push_str(role);
53 input.push('\n');
54 input.push_str("body=");
55 input.push_str(&normalize_body(content));
56 input.push('\n');
57 }
58 sha256::hex(input.as_bytes())
59}
60
61fn normalize_body(s: &str) -> String {
62 // Normalize CRLF -> LF, strip trailing whitespace per line.
63 let s = s.replace("\r\n", "\n").replace('\r', "\n");
64 s.lines()
65 .map(|l| l.trim_end_matches(|c: char| c == ' ' || c == '\t'))
66 .collect::<Vec<_>>()
67 .join("\n")
68}