Skip to main content

tokenmiser_cache/
key.rs

1//! L1 exact-match cache key derivation.
2
3use sha2::{Digest, Sha256};
4use tokenmiser_providers::ChatRequest;
5
6/// Bucket temperature into 0.1 steps so 0.0 and 0.05 cache together but stay
7/// distinct from 0.7.
8fn temperature_bucket(t: Option<f32>) -> u8 {
9    let t = t.unwrap_or(1.0).clamp(0.0, 2.0);
10    (t * 10.0).round() as u8
11}
12
13pub fn exact_key(req: &ChatRequest, tenant: &str) -> String {
14    let mut hasher = Sha256::new();
15    hasher.update(req.model.as_bytes());
16    hasher.update(b"\x00");
17
18    for m in &req.messages {
19        hasher.update(m.role.as_bytes());
20        hasher.update(b"\x00");
21        if let Ok(s) = serde_json::to_string(&m.content) {
22            hasher.update(s.as_bytes());
23        }
24        hasher.update(b"\x01");
25    }
26    hasher.update(b"\x00");
27
28    if let Some(t) = req.extra.get("tools") {
29        if let Ok(s) = serde_json::to_string(t) {
30            hasher.update(s.as_bytes());
31        }
32    }
33    hasher.update(b"\x00");
34
35    hasher.update([temperature_bucket(req.temperature)]);
36    hasher.update(b"\x00");
37
38    // `max_tokens` truncates the visible answer and `top_p` changes sampling.
39    // Without them, a response truncated at `max_tokens: 5` would be replayed
40    // to a `max_tokens: 4096` caller.
41    if let Some(mt) = req.max_tokens {
42        hasher.update(mt.to_le_bytes());
43    }
44    hasher.update(b"\x00");
45    if let Some(tp) = req.top_p {
46        hasher.update(tp.to_le_bytes());
47    }
48    hasher.update(b"\x00");
49
50    // Every unmodeled body field participates (`response_format`, `n`,
51    // `stop`, `seed`, `tool_choice`, …) except transport noise that cannot
52    // change the answer. serde_json's map is ordered, so iteration is
53    // deterministic.
54    const KEY_IGNORED_EXTRA: &[&str] = &[
55        "tools",          // hashed above
56        "stream_options", // usage reporting, not answer content
57        "user",           // telemetry attribution
58        "metadata",       // telemetry attribution
59        "store",          // provider-side persistence flag
60        "logprobs",       // adds metadata, not answer content
61        "top_logprobs",
62    ];
63    for (k, v) in req.extra.iter() {
64        if KEY_IGNORED_EXTRA.contains(&k.as_str()) {
65            continue;
66        }
67        hasher.update(k.as_bytes());
68        hasher.update(b"\x02");
69        if let Ok(s) = serde_json::to_string(v) {
70            hasher.update(s.as_bytes());
71        }
72        hasher.update(b"\x01");
73    }
74    hasher.update(b"\x00");
75
76    hasher.update(tenant.as_bytes());
77
78    hex::encode(hasher.finalize())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use tokenmiser_providers::ChatMessage;
85
86    fn req(model: &str, content: &str, temp: Option<f32>) -> ChatRequest {
87        ChatRequest {
88            model: model.into(),
89            messages: vec![ChatMessage {
90                role: "user".into(),
91                content: serde_json::Value::String(content.into()),
92                extra: Default::default(),
93            }],
94            temperature: temp,
95            max_tokens: None,
96            top_p: None,
97            stream: None,
98            extra: Default::default(),
99        }
100    }
101
102    #[test]
103    fn same_input_same_key() {
104        let a = exact_key(&req("gpt-5", "hi", Some(0.0)), "tenant-1");
105        let b = exact_key(&req("gpt-5", "hi", Some(0.0)), "tenant-1");
106        assert_eq!(a, b);
107    }
108
109    #[test]
110    fn different_tenant_different_key() {
111        let a = exact_key(&req("gpt-5", "hi", Some(0.0)), "tenant-1");
112        let b = exact_key(&req("gpt-5", "hi", Some(0.0)), "tenant-2");
113        assert_ne!(a, b);
114    }
115
116    #[test]
117    fn nearby_temperatures_bucket_together() {
118        let a = exact_key(&req("gpt-5", "hi", Some(0.00)), "t");
119        let b = exact_key(&req("gpt-5", "hi", Some(0.04)), "t");
120        assert_eq!(a, b);
121    }
122
123    #[test]
124    fn distant_temperatures_split() {
125        let a = exact_key(&req("gpt-5", "hi", Some(0.0)), "t");
126        let b = exact_key(&req("gpt-5", "hi", Some(0.7)), "t");
127        assert_ne!(a, b);
128    }
129
130    #[test]
131    fn max_tokens_and_top_p_split_the_key() {
132        let mut a = req("gpt-5", "hi", Some(0.0));
133        let mut b = req("gpt-5", "hi", Some(0.0));
134        a.max_tokens = Some(5);
135        b.max_tokens = Some(4096);
136        assert_ne!(exact_key(&a, "t"), exact_key(&b, "t"));
137
138        let mut c = req("gpt-5", "hi", Some(0.0));
139        let mut d = req("gpt-5", "hi", Some(0.0));
140        c.top_p = Some(0.1);
141        d.top_p = Some(1.0);
142        assert_ne!(exact_key(&c, "t"), exact_key(&d, "t"));
143    }
144
145    #[test]
146    fn answer_shaping_extra_params_split_the_key() {
147        let mut a = req("gpt-5", "hi", Some(0.0));
148        let b = req("gpt-5", "hi", Some(0.0));
149        a.extra.insert(
150            "response_format".into(),
151            serde_json::json!({"type": "json_object"}),
152        );
153        assert_ne!(exact_key(&a, "t"), exact_key(&b, "t"));
154
155        for (k, v) in [
156            ("n", serde_json::json!(2)),
157            ("stop", serde_json::json!(["\n"])),
158            ("seed", serde_json::json!(7)),
159        ] {
160            let mut with = req("gpt-5", "hi", Some(0.0));
161            with.extra.insert(k.into(), v);
162            assert_ne!(
163                exact_key(&with, "t"),
164                exact_key(&req("gpt-5", "hi", Some(0.0)), "t"),
165                "`{k}` must participate in the cache key"
166            );
167        }
168    }
169
170    #[test]
171    fn stream_and_transport_noise_do_not_split_the_key() {
172        let plain = req("gpt-5", "hi", Some(0.0));
173        let mut streaming = req("gpt-5", "hi", Some(0.0));
174        streaming.stream = Some(true);
175        streaming.extra.insert(
176            "stream_options".into(),
177            serde_json::json!({"include_usage": true}),
178        );
179        streaming
180            .extra
181            .insert("user".into(), serde_json::json!("abc"));
182        assert_eq!(exact_key(&plain, "t"), exact_key(&streaming, "t"));
183    }
184}