fix: sanitize tool call IDs for OpenAI compatibility
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled

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.
This commit is contained in:
2026-03-05 19:26:53 +00:00
parent 94162a3dcc
commit 4ffc6452e0

View File

@@ -29,7 +29,14 @@ pub async fn messages_to_openai_json(messages: &[UnifiedMessage]) -> Result<Vec<
"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.
// Gemini signatures (56 chars) must be shortened for compatibility.
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);
@@ -60,14 +67,28 @@ pub async fn messages_to_openai_json(messages: &[UnifiedMessage]) -> Result<Vec<
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);
}
@@ -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);
}