Skip to main content

prompt_cache_key/
lib.rs

1//! # prompt-cache-key
2//!
3//! Stable Anthropic prompt-cache scope hashes.
4//!
5//! Anthropic's prompt cache hits when the prefix (system + tools, up to a
6//! `cache_control` breakpoint) is byte-identical to a previously seen
7//! request. Coordinating that across workers needs a deterministic scope
8//! key everyone can compute locally.
9//!
10//! [`compute_cache_key`] walks `(model, system, tools)` into a canonical
11//! byte stream and returns a SHA-256 hex digest prefixed with the model:
12//!
13//! ```
14//! use prompt_cache_key::{compute_cache_key, System};
15//!
16//! let key = compute_cache_key("claude-opus-4-7", System::Text("You are helpful."), None);
17//! assert!(key.starts_with("anthropic-cache:claude-opus-4-7:sha256:"));
18//! ```
19//!
20//! Anything AFTER the last `cache_control` breakpoint in `system` is
21//! excluded from the key because it isn't part of the cached scope.
22//! [`find_breakpoints`] returns the indices of those markers if you need
23//! to inspect them yourself.
24//!
25//! ```
26//! use prompt_cache_key::find_breakpoints;
27//! use serde_json::json;
28//!
29//! let blocks = json!([
30//!     {"type": "text", "text": "a"},
31//!     {"type": "text", "text": "b", "cache_control": {"type": "ephemeral"}},
32//!     {"type": "text", "text": "c"},
33//! ]);
34//! assert_eq!(find_breakpoints(&blocks), vec![1]);
35//! ```
36//!
37//! Companion to [`llm-message-hash`](https://crates.io/crates/llm-message-hash),
38//! which hashes the full request for idempotency rather than just the
39//! cache scope.
40
41use serde_json::{Map, Value};
42use sha2::{Digest, Sha256};
43
44pub const KEY_PREFIX: &str = "anthropic-cache";
45
46/// System prompt input form. Anthropic accepts either a plain string or a
47/// list of content blocks; this enum mirrors that surface.
48pub enum System<'a> {
49    /// Treated as a single `{"type": "text", "text": ...}` block.
50    Text(&'a str),
51    /// Pre-built content blocks. Must be a JSON array of objects.
52    Blocks(&'a Value),
53    /// Same as passing `None` to the Python API.
54    None,
55}
56
57/// Return zero-based indices of blocks carrying a non-null `cache_control`.
58///
59/// Accepts any [`Value`]; non-arrays and non-object entries are ignored.
60pub fn find_breakpoints(blocks: &Value) -> Vec<usize> {
61    let Some(arr) = blocks.as_array() else {
62        return Vec::new();
63    };
64    arr.iter()
65        .enumerate()
66        .filter_map(|(i, b)| match b.as_object() {
67            Some(o) => match o.get("cache_control") {
68                Some(v) if !v.is_null() => Some(i),
69                _ => None,
70            },
71            None => None,
72        })
73        .collect()
74}
75
76/// Return the prefix of `blocks` up to and including the LAST
77/// `cache_control` marker. If no marker is present, the full list is
78/// returned. Non-arrays produce an empty list.
79pub fn scope_blocks(blocks: &Value) -> Vec<Value> {
80    let Some(arr) = blocks.as_array() else {
81        return Vec::new();
82    };
83    if arr.is_empty() {
84        return Vec::new();
85    }
86    let bps = find_breakpoints(blocks);
87    let end = match bps.last() {
88        Some(&last) => last + 1,
89        None => arr.len(),
90    };
91    arr[..end].to_vec()
92}
93
94/// Stable scope key for `(model, system, tools)`.
95///
96/// Returns `"{KEY_PREFIX}:{model}:sha256:{hex}"`. `tools` must be a JSON
97/// array (or `None`); each element is canonicalized.
98///
99/// Everything after the last `cache_control` in `system` is dropped to
100/// match Anthropic's cached prefix. Tools always participate because
101/// Anthropic includes them in the cached prefix.
102pub fn compute_cache_key(model: &str, system: System<'_>, tools: Option<&Value>) -> String {
103    let scoped = scope_blocks(&normalize_system(system));
104    let tools_vec: Vec<Value> = match tools.and_then(|v| v.as_array()) {
105        Some(arr) => arr.to_vec(),
106        None => Vec::new(),
107    };
108
109    let mut body = Map::new();
110    body.insert("model".to_string(), Value::String(model.to_string()));
111    body.insert("system".to_string(), Value::Array(scoped));
112    body.insert("tools".to_string(), Value::Array(tools_vec));
113    let body = Value::Object(body);
114
115    let blob = canonical_json(&body);
116    let mut hasher = Sha256::new();
117    hasher.update(blob.as_bytes());
118    let digest = hex_lower(&hasher.finalize());
119    format!("{KEY_PREFIX}:{model}:sha256:{digest}")
120}
121
122/// Serialize `value` as JSON with recursively sorted object keys and the
123/// compact separator `","` / `":"` (no spaces). This matches Python's
124/// `json.dumps(..., sort_keys=True, separators=(",", ":"), ensure_ascii=False)`.
125pub fn canonical_json(value: &Value) -> String {
126    let mut out = String::new();
127    write_canonical(value, &mut out);
128    out
129}
130
131fn normalize_system(system: System<'_>) -> Value {
132    match system {
133        System::None => Value::Array(Vec::new()),
134        System::Text(s) => {
135            let mut block = Map::new();
136            block.insert("type".to_string(), Value::String("text".to_string()));
137            block.insert("text".to_string(), Value::String(s.to_string()));
138            Value::Array(vec![Value::Object(block)])
139        }
140        System::Blocks(v) => match v.as_array() {
141            Some(arr) => Value::Array(arr.clone()),
142            None => Value::Array(Vec::new()),
143        },
144    }
145}
146
147fn write_canonical(value: &Value, out: &mut String) {
148    match value {
149        Value::Null => out.push_str("null"),
150        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
151        Value::Number(n) => out.push_str(&n.to_string()),
152        Value::String(s) => write_json_string(s, out),
153        Value::Array(arr) => {
154            out.push('[');
155            for (i, item) in arr.iter().enumerate() {
156                if i > 0 {
157                    out.push(',');
158                }
159                write_canonical(item, out);
160            }
161            out.push(']');
162        }
163        Value::Object(map) => {
164            let mut keys: Vec<&String> = map.keys().collect();
165            keys.sort();
166            out.push('{');
167            for (i, k) in keys.iter().enumerate() {
168                if i > 0 {
169                    out.push(',');
170                }
171                write_json_string(k, out);
172                out.push(':');
173                write_canonical(&map[*k], out);
174            }
175            out.push('}');
176        }
177    }
178}
179
180fn write_json_string(s: &str, out: &mut String) {
181    // Mirrors Python json.dumps with ensure_ascii=False: escapes only
182    // control chars, quote, and backslash; passes non-ASCII through.
183    out.push('"');
184    for c in s.chars() {
185        match c {
186            '"' => out.push_str("\\\""),
187            '\\' => out.push_str("\\\\"),
188            '\x08' => out.push_str("\\b"),
189            '\x0c' => out.push_str("\\f"),
190            '\n' => out.push_str("\\n"),
191            '\r' => out.push_str("\\r"),
192            '\t' => out.push_str("\\t"),
193            c if (c as u32) < 0x20 => {
194                let code = c as u32;
195                out.push_str(&format!("\\u{code:04x}"));
196            }
197            c => out.push(c),
198        }
199    }
200    out.push('"');
201}
202
203fn hex_lower(bytes: &[u8]) -> String {
204    const HEX: &[u8; 16] = b"0123456789abcdef";
205    let mut s = String::with_capacity(bytes.len() * 2);
206    for &b in bytes {
207        s.push(HEX[(b >> 4) as usize] as char);
208        s.push(HEX[(b & 0x0f) as usize] as char);
209    }
210    s
211}