llm_message_hash/lib.rs
1//! # llm-message-hash
2//!
3//! Stable canonical hash of LLM request/message structures.
4//!
5//! Two semantically-identical Anthropic requests can produce different
6//! `sha256(serde_json::to_string(&req))` results because:
7//!
8//! - JSON key order isn't guaranteed.
9//! - `cache_control` fields don't change request semantics but change the bytes.
10//! - Optional metadata (`metadata.user_id`, etc.) varies between callers.
11//!
12//! This crate fixes that. It walks the value tree, sorts keys recursively,
13//! drops a configurable set of fields, and sha256s the canonical bytes.
14//!
15//! ## Quick example
16//!
17//! ```
18//! use serde_json::json;
19//! use llm_message_hash::{hash_canonical_hex, HashOpts};
20//!
21//! let a = json!({
22//! "model": "claude-sonnet-4-5",
23//! "messages": [{"role": "user", "content": "hi"}],
24//! });
25//! let b = json!({
26//! "messages": [{"content": "hi", "role": "user"}],
27//! "model": "claude-sonnet-4-5",
28//! });
29//!
30//! assert_eq!(hash_canonical_hex(&a), hash_canonical_hex(&b));
31//! ```
32//!
33//! ## With per-provider ignore lists
34//!
35//! ```
36//! use serde_json::json;
37//! use llm_message_hash::{hash_canonical_hex_with, HashOpts};
38//!
39//! let req_a = json!({
40//! "model": "claude-sonnet-4-5",
41//! "messages": [{
42//! "role": "user",
43//! "content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}],
44//! }],
45//! });
46//! let req_b = json!({
47//! "model": "claude-sonnet-4-5",
48//! "messages": [{
49//! "role": "user",
50//! "content": [{"type": "text", "text": "hi"}],
51//! }],
52//! });
53//!
54//! // `HashOpts::anthropic()` drops cache_control + metadata.user_id
55//! let h1 = hash_canonical_hex_with(&req_a, &HashOpts::anthropic());
56//! let h2 = hash_canonical_hex_with(&req_b, &HashOpts::anthropic());
57//! assert_eq!(h1, h2);
58//! ```
59
60#![deny(missing_docs)]
61
62mod canonical;
63mod hasher;
64mod opts;
65
66pub use hasher::{
67 hash_canonical, hash_canonical_hex, hash_canonical_hex_with, hash_canonical_with,
68};
69pub use opts::HashOpts;