From 9cdc0ccfa3f31255262aee2f7f425fbb6e73b5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 23 Jul 2026 01:00:56 -0400 Subject: [PATCH] fix(wire): shared ASCII tool-call id sanitizer, symmetric on both legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../kigi-sampling-types/src/conversation.rs | 98 ++++++++++++++++--- 1 file changed, 82 insertions(+), 16 deletions(-) diff --git a/crates/codegen/kigi-sampling-types/src/conversation.rs b/crates/codegen/kigi-sampling-types/src/conversation.rs index d4f0558..a3d9ed9 100644 --- a/crates/codegen/kigi-sampling-types/src/conversation.rs +++ b/crates/codegen/kigi-sampling-types/src/conversation.rs @@ -2288,12 +2288,15 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec Vec) -> // ============================================================================ /// 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 { use crate::messages::{ 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 = Vec::new(); let mut pending_tool_results: Vec = 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 let content_parts_to_anthropic_blocks = |parts: &[ContentPart]| -> Vec { 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 /// reasoning input item — an empty one 400s with "Invalid /// 'input[N].id': ''" (observed on the Codex backend after a