From bbf087756c51aa1d22cbc08148b6506b06ca7b30 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 16 Apr 2026 18:21:02 +0530 Subject: [PATCH] language_models: Fix Mistral tool use erroring out (#54058) Closes #52717 Mistral's streaming API sometimes sends `"id": "null"` (the literal string, not JSON null) in continuation chunks for tool calls. Our stream handler treated this as a valid ID, overwriting the real tool call ID from the first chunk. When the corrupted ID was sent back in the next request, Mistral's API rejected it with "Tool call id has to be defined in serving mode." Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed issue where Mistral models erroring out with "Tool call id has to be defined" when using tools. --- .../language_models/src/provider/mistral.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 5fef40b2b1badbc77133ebe67fbe0f1fe5521259..fdb0fb7b3a7f510c8e55deefcea8e3b7f4d1eb86 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -652,6 +652,7 @@ impl MistralEventMapper { if let Some(tool_id) = tool_call.id.clone() && !tool_id.is_empty() + && tool_id != "null" { entry.id = tool_id; } @@ -905,6 +906,69 @@ mod tests { use super::*; use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent}; + fn tool_call_chunk( + id: Option<&str>, + name: Option<&str>, + arguments: Option<&str>, + finish_reason: Option<&str>, + ) -> mistral::StreamResponse { + mistral::StreamResponse { + id: "resp".into(), + object: "chat.completion.chunk".into(), + created: 0, + model: "test".into(), + choices: vec![mistral::StreamChoice { + index: 0, + delta: mistral::StreamDelta { + role: None, + content: None, + tool_calls: if finish_reason.is_some() { + None + } else { + Some(vec![mistral::ToolCallChunk { + index: 0, + id: id.map(Into::into), + function: Some(mistral::FunctionChunk { + name: name.map(Into::into), + arguments: arguments.map(Into::into), + }), + }]) + }, + }, + finish_reason: finish_reason.map(Into::into), + }], + usage: None, + } + } + + #[test] + fn test_streaming_tool_call_ignores_null_id() { + // Mistral's streaming API sometimes sends `"id": "null"` in continuation chunks. + let mut mapper = MistralEventMapper::new(); + + mapper.map_event(tool_call_chunk( + Some("real_id_123"), + Some("read_file"), + Some("{\"path\":"), + None, + )); + mapper.map_event(tool_call_chunk( + Some("null"), + None, + Some("\"a.txt\"}"), + None, + )); + let events = mapper.map_event(tool_call_chunk(None, None, None, Some("tool_calls"))); + + let Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) = &events[0] else { + panic!("Expected first event to be ToolUse, got: {:?}", events[0]); + }; + + assert_eq!(tool_use.id.to_string(), "real_id_123"); + assert_eq!(tool_use.name.as_ref(), "read_file"); + assert_eq!(tool_use.input, serde_json::json!({"path": "a.txt"})); + } + #[test] fn test_into_mistral_basic_conversion() { let request = LanguageModelRequest {