fix(wire): shared ASCII tool-call id sanitizer, symmetric on both legs

Cross-provider audit R4+M3: the Responses leg passed tool-call ids
verbatim (call_id on both function_call and function_call_output) while
the Messages leg sanitized — and its closure used Unicode
is_alphanumeric, letting CJK ids through to Anthropic's ASCII-only
contract, with no empty-id fallback. Both providers enforce
[A-Za-z0-9_-]+ (the codex 400's own words). One module-scope
sanitize_tool_call_id now serves both builders, ASCII-only, empty → "_",
applied identically on call+result so pairing survives.

Part 4 of the cross-provider replay audit.

Verified: 6081 tests green across the four crates, clippy clean.
This commit is contained in:
2026-07-23 01:00:56 -04:00
parent 6f9f550308
commit 9cdc0ccfa3
@@ -2288,12 +2288,15 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
})); }));
} }
// Add each tool call as a FunctionCall item // Add each tool call as a FunctionCall item. The call_id is
// normalized to the Responses charset (foreign backends mint
// arbitrary ids); the ToolResult arm applies the SAME map so
// pairing survives.
for tc in &a.tool_calls { for tc in &a.tool_calls {
let arguments = sanitize_tool_arguments(&tc.id, &tc.name, tc.arguments.clone()); let arguments = sanitize_tool_arguments(&tc.id, &tc.name, tc.arguments.clone());
items.push(rs::InputItem::Item(rs::Item::FunctionCall( items.push(rs::InputItem::Item(rs::Item::FunctionCall(
rs::FunctionToolCall { rs::FunctionToolCall {
call_id: tc.id.as_ref().to_owned(), call_id: sanitize_tool_call_id(&tc.id),
name: tc.name.clone(), name: tc.name.clone(),
arguments: arguments.as_ref().to_owned(), arguments: arguments.as_ref().to_owned(),
id: None, id: None,
@@ -2327,7 +2330,9 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
}; };
vec![rs::InputItem::Item(rs::Item::FunctionCallOutput( vec![rs::InputItem::Item(rs::Item::FunctionCallOutput(
rs::FunctionCallOutputItemParam { rs::FunctionCallOutputItemParam {
call_id: t.tool_call_id.clone(), // Same normalization as the FunctionCall arm — pairing
// survives because both sides map identically.
call_id: sanitize_tool_call_id(&t.tool_call_id),
output, output,
id: None, id: None,
status: None, status: None,
@@ -2997,6 +3002,30 @@ pub fn dedup_duplicate_tool_results(conversation: &mut Vec<ConversationItem>) ->
// ============================================================================ // ============================================================================
/// Convert a ConversationRequest to Anthropic MessagesRequest. /// Convert a ConversationRequest to Anthropic MessagesRequest.
/// Normalize a tool-call id to the `[A-Za-z0-9_-]+` charset both Anthropic
/// Messages and the OpenAI Responses API enforce ("Expected an ID that
/// contains letters, numbers, underscores, or dashes"). Foreign backends
/// mint arbitrary ids (chat-completions providers, UUID synthesis), so the
/// wire builders apply this SYMMETRICALLY on the call and its result —
/// pairing survives because both sides map through the same function.
/// ASCII-only (the old closure used Unicode `is_alphanumeric`, letting
/// e.g. CJK ids through to Anthropic's ASCII contract); an empty id maps
/// to `"_"` so the mandatory field is never empty on the wire.
fn sanitize_tool_call_id(id: &str) -> String {
if id.is_empty() {
return "_".to_string();
}
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::MessagesRequest { pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::MessagesRequest {
use crate::messages::{ use crate::messages::{
CacheControl, ContentBlock, ImageSource, Message, MessageContent, MessageRole, CacheControl, ContentBlock, ImageSource, Message, MessageContent, MessageRole,
@@ -3009,19 +3038,6 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
let mut pending_assistant: Vec<ContentBlock> = Vec::new(); let mut pending_assistant: Vec<ContentBlock> = Vec::new();
let mut pending_tool_results: Vec<ContentBlock> = Vec::new(); let mut pending_tool_results: Vec<ContentBlock> = Vec::new();
// Helper to sanitize tool call IDs (replace [^a-zA-Z0-9_-] with _)
let sanitize_tool_call_id = |id: &str| -> String {
id.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
};
// Helper to convert ContentPart to Anthropic ContentBlock // Helper to convert ContentPart to Anthropic ContentBlock
let content_parts_to_anthropic_blocks = |parts: &[ContentPart]| -> Vec<ContentBlock> { let content_parts_to_anthropic_blocks = |parts: &[ContentPart]| -> Vec<ContentBlock> {
parts parts
@@ -4635,6 +4651,56 @@ mod tests {
} }
} }
/// Both Anthropic Messages and the Responses API enforce
/// `[A-Za-z0-9_-]+` tool-call ids; foreign backends mint arbitrary
/// ones. The shared sanitizer must be ASCII-only (the old closure's
/// Unicode `is_alphanumeric` let CJK ids through), never emit an empty
/// id, and map call + result IDENTICALLY so pairing survives.
#[test]
fn tool_call_ids_sanitized_symmetrically_on_responses_leg() {
let weird_id = "调用#1 β";
let req = ConversationRequest::from_items(vec![
ConversationItem::user("q"),
ConversationItem::Assistant(AssistantItem {
content: "".into(),
tool_calls: vec![ToolCall {
id: std::sync::Arc::from(weird_id),
name: "read_file".to_string(),
arguments: std::sync::Arc::from("{}"),
}],
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
}),
ConversationItem::tool_result(weird_id, "contents"),
]);
let json = serde_json::to_value(rs::CreateResponse::from(&req)).unwrap();
let input = json["input"].as_array().unwrap();
let call_id = input
.iter()
.find(|i| i["type"] == "function_call")
.map(|i| i["call_id"].as_str().unwrap().to_string())
.expect("function_call present");
let output_id = input
.iter()
.find(|i| i["type"] == "function_call_output")
.map(|i| i["call_id"].as_str().unwrap().to_string())
.expect("function_call_output present");
assert_eq!(call_id, output_id, "pairing must survive sanitization");
assert!(
call_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"sanitized id must satisfy the wire charset: {call_id:?}"
);
assert!(!call_id.is_empty());
// The sanitizer itself: ASCII passthrough, unicode replaced, empty
// never emitted.
assert_eq!(sanitize_tool_call_id("toolu_01AB-cd"), "toolu_01AB-cd");
assert_eq!(sanitize_tool_call_id("统A1"), "_A1");
assert_eq!(sanitize_tool_call_id(""), "_");
}
/// The Responses API requires a server-issued id on every replayed /// The Responses API requires a server-issued id on every replayed
/// reasoning input item — an empty one 400s with "Invalid /// reasoning input item — an empty one 400s with "Invalid
/// 'input[N].id': ''" (observed on the Codex backend after a /// 'input[N].id': ''" (observed on the Codex backend after a