Fri, 24 Jul 2026 16:13:44 +0800
This commit is contained in:
+166
@@ -0,0 +1,166 @@
|
||||
use serde_json::Value;
|
||||
|
||||
/// Canonical Byte Representation of the `messages` field.
|
||||
///
|
||||
/// This produces a deterministic byte sequence from the messages JSON,
|
||||
/// independent of the original key ordering or whitespace in the request.
|
||||
/// Semantically identical messages arrays always produce identical bytes.
|
||||
pub fn canonical_messages_bytes(messages: &Value) -> Vec<u8> {
|
||||
let canonical = canonicalize_value(messages);
|
||||
// Compact (no extraneous whitespace), deterministic key order
|
||||
serde_json::to_vec(&canonical).expect("canonical JSON serialization is infallible")
|
||||
}
|
||||
|
||||
/// Canonical cache identity for every request field that can affect prompt
|
||||
/// rendering, followed by `messages` so conversation growth extends the tail.
|
||||
pub fn canonical_prompt_bytes(body: &Value) -> Vec<u8> {
|
||||
let Some(object) = body.as_object() else {
|
||||
return canonical_messages_bytes(&Value::Null);
|
||||
};
|
||||
|
||||
let messages = object.get("messages").unwrap_or(&Value::Null);
|
||||
let mut prompt_options = serde_json::Map::new();
|
||||
for (key, value) in object {
|
||||
if key != "messages" && !is_generation_only_field(key) {
|
||||
prompt_options.insert(key.clone(), canonicalize_value(value));
|
||||
}
|
||||
}
|
||||
|
||||
let mut bytes = serde_json::to_vec(&Value::Object(prompt_options))
|
||||
.expect("canonical JSON serialization is infallible");
|
||||
bytes.push(0);
|
||||
bytes.extend(canonical_messages_bytes(messages));
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Fields known not to alter tokenized prompt construction. Unknown extension
|
||||
/// fields are deliberately included so backend-specific template kwargs and
|
||||
/// future prompt controls cannot silently alias an unrelated cache namespace.
|
||||
fn is_generation_only_field(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"frequency_penalty"
|
||||
| "logit_bias"
|
||||
| "logprobs"
|
||||
| "max_completion_tokens"
|
||||
| "max_tokens"
|
||||
| "metadata"
|
||||
| "n"
|
||||
| "parallel_tool_calls"
|
||||
| "presence_penalty"
|
||||
| "seed"
|
||||
| "service_tier"
|
||||
| "stop"
|
||||
| "store"
|
||||
| "stream"
|
||||
| "stream_options"
|
||||
| "temperature"
|
||||
| "top_logprobs"
|
||||
| "top_p"
|
||||
| "user"
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively sort object keys for deterministic serialization.
|
||||
fn canonicalize_value(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
let mut out = serde_json::Map::with_capacity(map.len());
|
||||
for k in keys {
|
||||
out.insert(k.clone(), canonicalize_value(&map[k]));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_value).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_canonical_stable_key_order() {
|
||||
let a: Value =
|
||||
serde_json::from_str(r#"{"messages":[{"role":"user","content":"hi"}]}"#).unwrap();
|
||||
let b: Value =
|
||||
serde_json::from_str(r#"{"messages":[{"content":"hi","role":"user"}]}"#).unwrap();
|
||||
|
||||
let messages_a = &a["messages"];
|
||||
let messages_b = &b["messages"];
|
||||
|
||||
assert_eq!(
|
||||
canonical_messages_bytes(messages_a),
|
||||
canonical_messages_bytes(messages_b)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_whitespace_agnostic() {
|
||||
let a: Value =
|
||||
serde_json::from_str(r#"{"messages":[{"role":"user","content":"hello world"}]}"#)
|
||||
.unwrap();
|
||||
let b: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hello world"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
canonical_messages_bytes(&a["messages"]),
|
||||
canonical_messages_bytes(&b["messages"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_content_different_bytes() {
|
||||
let a: Value =
|
||||
serde_json::from_str(r#"{"messages":[{"role":"user","content":"hi"}]}"#).unwrap();
|
||||
let b: Value =
|
||||
serde_json::from_str(r#"{"messages":[{"role":"user","content":"hello"}]}"#).unwrap();
|
||||
|
||||
assert_ne!(
|
||||
canonical_messages_bytes(&a["messages"]),
|
||||
canonical_messages_bytes(&b["messages"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_identity_includes_template_inputs() {
|
||||
let a = serde_json::json!({
|
||||
"model": "model-a",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"chat_template_kwargs": {"enable_thinking": true}
|
||||
});
|
||||
let b = serde_json::json!({
|
||||
"model": "model-a",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"chat_template_kwargs": {"enable_thinking": false}
|
||||
});
|
||||
assert_ne!(canonical_prompt_bytes(&a), canonical_prompt_bytes(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_identity_ignores_generation_controls() {
|
||||
let a = serde_json::json!({
|
||||
"model": "model-a",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"temperature": 0.1
|
||||
});
|
||||
let b = serde_json::json!({
|
||||
"model": "model-a",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"temperature": 0.9
|
||||
});
|
||||
assert_eq!(canonical_prompt_bytes(&a), canonical_prompt_bytes(&b));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user