llm-message-hash 0.1.0

Stable canonical hash of LLM request/message structures. Recursive key-sorting JSON canonicalization + sha256, with per-provider ignore-lists so semantically-equal Anthropic/OpenAI/Bedrock requests produce the same hash. Useful for cache keys and idempotency.
Documentation
//! Public hash entry points.

use serde_json::Value;
use sha2::{Digest, Sha256};

use crate::canonical::write_canonical;
use crate::opts::HashOpts;

/// sha256 of the canonical JSON representation of `v` with default options
/// (no fields ignored).
pub fn hash_canonical(v: &Value) -> [u8; 32] {
    hash_canonical_with(v, &HashOpts::default())
}

/// sha256 of the canonical JSON representation of `v`, honoring `opts`.
pub fn hash_canonical_with(v: &Value, opts: &HashOpts) -> [u8; 32] {
    let mut hasher = Sha256Writer(Sha256::new());
    write_canonical(&mut hasher, v, opts).expect("Sha256 writer is infallible");
    hasher.0.finalize().into()
}

/// Hex-encoded sha256 of the canonical JSON representation of `v` with
/// default options.
pub fn hash_canonical_hex(v: &Value) -> String {
    hex_lower(&hash_canonical(v))
}

/// Hex-encoded sha256 of the canonical JSON representation of `v`,
/// honoring `opts`.
pub fn hash_canonical_hex_with(v: &Value, opts: &HashOpts) -> String {
    hex_lower(&hash_canonical_with(v, opts))
}

fn hex_lower(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push(char_for_nibble(b >> 4));
        s.push(char_for_nibble(b & 0xf));
    }
    s
}

fn char_for_nibble(n: u8) -> char {
    match n {
        0..=9 => (b'0' + n) as char,
        _ => (b'a' + n - 10) as char,
    }
}

/// Adapter so Sha256 can be used as a std::io::Write target.
struct Sha256Writer(Sha256);

impl std::io::Write for Sha256Writer {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        self.0.update(buf);
        Ok(buf.len())
    }
    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}