From 4ffc6452e0c362351589c2a3ecf6886c1661d8be Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 5 Mar 2026 19:26:53 +0000 Subject: [PATCH] fix: sanitize tool call IDs for OpenAI compatibility OpenAI has a strict 40-character limit for tool call IDs. Long IDs (like Gemini's 56-char thought signatures) are now deterministically truncated to 40 characters when sending history back to OpenAI-compatible providers. --- src/providers/helpers.rs | 49 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/providers/helpers.rs b/src/providers/helpers.rs index f4464a6d..2bedaae4 100644 --- a/src/providers/helpers.rs +++ b/src/providers/helpers.rs @@ -29,7 +29,14 @@ pub async fn messages_to_openai_json(messages: &[UnifiedMessage]) -> Result 40 { + &tool_call_id[..40] + } else { + tool_call_id + }; + msg["tool_call_id"] = serde_json::json!(id); } if let Some(name) = &m.name { msg["name"] = serde_json::json!(name); @@ -60,14 +67,28 @@ pub async fn messages_to_openai_json(messages: &[UnifiedMessage]) -> Result = tool_calls.iter().map(|tc| { + let mut sanitized = tc.clone(); + if sanitized.id.len() > 40 { + sanitized.id = sanitized.id[..40].to_string(); + } + sanitized + }).collect(); + if parts.is_empty() { msg["content"] = serde_json::Value::Null; } else { msg["content"] = serde_json::json!(parts); } - msg["tool_calls"] = serde_json::json!(tool_calls); + msg["tool_calls"] = serde_json::json!(sanitized_calls); } else { msg["content"] = serde_json::json!(parts); } @@ -109,7 +130,13 @@ pub async fn messages_to_openai_json_text_only( "content": text_content }); if let Some(tool_call_id) = &m.tool_call_id { - msg["tool_call_id"] = serde_json::json!(tool_call_id); + // OpenAI and others have a 40-char limit for tool_call_id. + let id = if tool_call_id.len() > 40 { + &tool_call_id[..40] + } else { + tool_call_id + }; + msg["tool_call_id"] = serde_json::json!(id); } if let Some(name) = &m.name { msg["name"] = serde_json::json!(name); @@ -133,14 +160,28 @@ pub async fn messages_to_openai_json_text_only( let mut msg = serde_json::json!({ "role": m.role }); + // Include reasoning_content if present (DeepSeek R1/reasoner requires this in history) + if let Some(reasoning) = &m.reasoning_content { + msg["reasoning_content"] = serde_json::json!(reasoning); + } + // For assistant messages with tool_calls, content can be null if let Some(tool_calls) = &m.tool_calls { + // Sanitize tool call IDs for OpenAI compatibility (max 40 chars) + let sanitized_calls: Vec<_> = tool_calls.iter().map(|tc| { + let mut sanitized = tc.clone(); + if sanitized.id.len() > 40 { + sanitized.id = sanitized.id[..40].to_string(); + } + sanitized + }).collect(); + if parts.is_empty() { msg["content"] = serde_json::Value::Null; } else { msg["content"] = serde_json::json!(parts); } - msg["tool_calls"] = serde_json::json!(tool_calls); + msg["tool_calls"] = serde_json::json!(sanitized_calls); } else { msg["content"] = serde_json::json!(parts); }