use std::collections::HashMap;
use amberfork_record::Body;
use serde_json::{Map, Value};
const TOKEN_PREFIX: &str = "__amberfork_tool_call_";
pub(crate) fn canonicalize_body(body: &Body) -> Body {
match body {
Body::Json(value) => Body::Json(canonicalize_tool_call_ids(value)),
Body::Text(text) => Body::Text(text.clone()),
}
}
pub(crate) fn canonicalize_tool_call_ids(value: &Value) -> Value {
let mut tokens = IdTokens::default();
rewrite(value, &mut tokens)
}
fn is_reference_key(key: &str) -> bool {
matches!(key, "tool_call_id" | "tool_use_id" | "call_id")
}
fn declares_tool_call_id(map: &Map<String, Value>) -> bool {
matches!(
map.get("type").and_then(Value::as_str),
Some("tool_use" | "function")
)
}
fn rewrite(value: &Value, tokens: &mut IdTokens) -> Value {
match value {
Value::Object(map) => {
let declares = declares_tool_call_id(map);
let mut out = Map::new();
for (key, child) in map {
let rewritten = match child {
Value::String(id) if is_reference_key(key) || (key == "id" && declares) => {
Value::String(tokens.token_for(id))
}
_ => rewrite(child, tokens),
};
out.insert(key.clone(), rewritten);
}
Value::Object(out)
}
Value::Array(items) => {
Value::Array(items.iter().map(|item| rewrite(item, tokens)).collect())
}
scalar => scalar.clone(),
}
}
#[derive(Default)]
struct IdTokens {
assigned: HashMap<String, usize>,
}
impl IdTokens {
fn token_for(&mut self, id: &str) -> String {
let next = self.assigned.len();
let index = *self.assigned.entry(id.to_owned()).or_insert(next);
format!("{TOKEN_PREFIX}{index}__")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_body_without_tool_ids_is_unchanged() {
let body =
json!({"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "hi"}]});
assert_eq!(canonicalize_tool_call_ids(&body), body);
}
#[test]
fn fresh_openai_tool_call_ids_canonicalize_to_the_same_value() {
let recorded = json!({
"messages": [
{"role": "assistant", "tool_calls": [
{"id": "call_ABC", "type": "function",
"function": {"name": "search", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "call_ABC", "content": "result"}
]
});
let reissued = json!({
"messages": [
{"role": "assistant", "tool_calls": [
{"id": "call_XYZ", "type": "function",
"function": {"name": "search", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "call_XYZ", "content": "result"}
]
});
assert_eq!(
canonicalize_tool_call_ids(&recorded),
canonicalize_tool_call_ids(&reissued)
);
}
#[test]
fn anthropic_linkage_normalizes_but_a_real_argument_difference_still_differs() {
let recorded = json!({
"messages": [
{"role": "assistant", "content": [
{"type": "tool_use", "id": "toolu_A", "name": "lookup", "input": {"id": "record-1"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_A", "content": "ok"}
]}
]
});
let same_call_same_arg = json!({
"messages": [
{"role": "assistant", "content": [
{"type": "tool_use", "id": "toolu_Z", "name": "lookup", "input": {"id": "record-1"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_Z", "content": "ok"}
]}
]
});
let same_call_different_arg = json!({
"messages": [
{"role": "assistant", "content": [
{"type": "tool_use", "id": "toolu_Z", "name": "lookup", "input": {"id": "record-2"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_Z", "content": "ok"}
]}
]
});
assert_eq!(
canonicalize_tool_call_ids(&recorded),
canonicalize_tool_call_ids(&same_call_same_arg),
"only the fresh tool-call IDs differed — should match"
);
assert_ne!(
canonicalize_tool_call_ids(&recorded),
canonicalize_tool_call_ids(&same_call_different_arg),
"a real change to a non-tool-call `id` must not be normalized away"
);
}
}