From 22c16b690a1575f6b9eae7fbe7e6b0575afb17a9 Mon Sep 17 00:00:00 2001 From: Daniel Strobusch <1847260+dastrobu@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:05:41 +0100 Subject: [PATCH] language_models: Handle empty tool call arguments consistently (#48958) Normalize handling of empty tool call arguments across all LLM providers. Many providers return empty strings for tool calls with no arguments, which would previously fail JSON parsing. - Created shared parse_tool_arguments() helper in provider/util.rs that treats empty strings as empty JSON objects ({}) - Refactored 10 occurrences across 9 provider files to use the helper - Ensures consistent behavior across all providers (anthropic, bedrock, copilot_chat, deepseek, lmstudio, mistral, open_ai, open_router) Closes: #48955 Release Notes: - Fixed tool calls with no arguments failing when using certain LLM providers --- crates/language_models/src/provider.rs | 1 + .../language_models/src/provider/anthropic.rs | 9 +- .../language_models/src/provider/bedrock.rs | 10 +- .../src/provider/copilot_chat.rs | 19 ++-- .../language_models/src/provider/deepseek.rs | 5 +- .../language_models/src/provider/lmstudio.rs | 4 +- .../language_models/src/provider/mistral.rs | 5 +- .../language_models/src/provider/open_ai.rs | 94 ++++++++++++++----- .../src/provider/open_router.rs | 5 +- crates/language_models/src/provider/util.rs | 13 +++ 10 files changed, 106 insertions(+), 59 deletions(-) create mode 100644 crates/language_models/src/provider/util.rs diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index d780195c66ec0d19c2b7d53e62b5e3629baa8a43..6e63a5f5745afce2a21f19002706c628360d7792 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -10,5 +10,6 @@ pub mod ollama; pub mod open_ai; pub mod open_ai_compatible; pub mod open_router; +mod util; pub mod vercel; pub mod x_ai; diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 47dec06232bb12e33ac144bb55201d825310b0fe..61060a093aff9f69b19c9696d85debb82bc068ca 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -24,6 +24,8 @@ use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + pub use settings::AnthropicAvailableModel as AvailableModel; const PROVIDER_ID: LanguageModelProviderId = language_model::ANTHROPIC_PROVIDER_ID; @@ -829,12 +831,7 @@ impl AnthropicEventMapper { Event::ContentBlockStop { index } => { if let Some(tool_use) = self.tool_uses_by_index.remove(&index) { let input_json = tool_use.input_json.trim(); - let input_value = if input_json.is_empty() { - Ok(serde_json::Value::Object(serde_json::Map::default())) - } else { - serde_json::Value::from_str(input_json) - }; - let event_result = match input_value { + let event_result = match parse_tool_arguments(input_json) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_use.id.into(), diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index b10c2789c86024b5eab3d4238754f4e755fe42b0..b45b34e0b75f148b15439704e6cdeef75314aa6a 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -1,5 +1,4 @@ use std::pin::Pin; -use std::str::FromStr; use std::sync::Arc; use anyhow::{Context as _, Result, anyhow}; @@ -48,6 +47,7 @@ use ui_input::InputField; use util::ResultExt; use crate::AllLanguageModelSettings; +use crate::provider::util::parse_tool_arguments; actions!(bedrock, [Tab, TabPrev]); @@ -1099,12 +1099,8 @@ pub fn map_to_language_model_completion_events( .tool_uses_by_index .remove(&cb_stop.content_block_index) .map(|tool_use| { - let input = if tool_use.input_json.is_empty() { - Value::Null - } else { - serde_json::Value::from_str(&tool_use.input_json) - .unwrap_or(Value::Null) - }; + let input = parse_tool_arguments(&tool_use.input_json) + .unwrap_or_else(|_| Value::Object(Default::default())); Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 1e5f707259e2bc786bc8d7002f3aca8fbe1c565d..e6b9973299d15e78955efd79282b75de48e924f0 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -30,6 +30,8 @@ use settings::SettingsStore; use ui::prelude::*; use util::debug_panic; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("GitHub Copilot Chat"); @@ -493,17 +495,9 @@ pub fn map_to_language_model_completion_events( } events.extend(state.tool_calls_by_index.drain().map( - |(_, tool_call)| { - // The model can output an empty string - // to indicate the absence of arguments. - // When that happens, create an empty - // object instead. - let arguments = if tool_call.arguments.is_empty() { - Ok(serde_json::Value::Object(Default::default())) - } else { - serde_json::Value::from_str(&tool_call.arguments) - }; - match arguments { + |(_, tool_call)| match parse_tool_arguments( + &tool_call.arguments, + ) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.into(), @@ -522,7 +516,6 @@ pub fn map_to_language_model_completion_events( json_parse_error: error.to_string(), }, ), - } }, )); @@ -607,7 +600,7 @@ impl CopilotResponsesEventMapper { .. } => { let mut events = Vec::new(); - match serde_json::from_str::(&arguments) { + match parse_tool_arguments(&arguments) { Ok(input) => events.push(Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: call_id.into(), diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index ea623d2cf24f26ce32e8d1fd309ac747e469096e..2a9f7322b1fb5d3d1e6713c5a084b83dc2b01ce2 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -16,13 +16,14 @@ use language_model::{ pub use settings::DeepseekAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::pin::Pin; -use std::str::FromStr; use std::sync::{Arc, LazyLock}; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("DeepSeek"); @@ -486,7 +487,7 @@ impl DeepSeekEventMapper { } Some("tool_calls") => { events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.clone().into(), diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 041dfedf86e4195d98689d4f06031b32fb162e51..9af8559c722d1fe726f7f871c9863cd85a3d2678 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -18,12 +18,12 @@ use lmstudio::{ModelType, get_models}; pub use settings::LmStudioAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::pin::Pin; -use std::str::FromStr; use std::{collections::BTreeMap, sync::Arc}; use ui::{ButtonLike, Indicator, List, ListBulletItem, prelude::*}; use util::ResultExt; use crate::AllLanguageModelSettings; +use crate::provider::util::parse_tool_arguments; const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download"; const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models"; @@ -558,7 +558,7 @@ impl LmStudioEventMapper { } Some("tool_calls") => { events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.into(), diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index c8f6f71b0c9c73f07cd24712420ebf7543e06e02..542779803f9314cd6bf71b7beb48ddc429664159 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -16,13 +16,14 @@ pub use settings::MistralAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::collections::HashMap; use std::pin::Pin; -use std::str::FromStr; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Mistral"); @@ -659,7 +660,7 @@ impl MistralEventMapper { continue; } - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => results.push(Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.into(), diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 87c43fc547bdf1c6c0455214b7281c4df29d0f9c..d66861a8955819153134811d464929cfa8423d2c 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -28,13 +28,14 @@ use open_ai::{ }; use settings::{OpenAiAvailableModel as AvailableModel, Settings, SettingsStore}; use std::pin::Pin; -use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = language_model::OPEN_AI_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::OPEN_AI_PROVIDER_NAME; @@ -831,7 +832,7 @@ impl OpenAiEventMapper { } Some("tool_calls") => { events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.clone().into(), @@ -963,7 +964,7 @@ impl OpenAiResponseEventMapper { } let raw_input = entry.arguments.clone(); self.pending_stop_reason = Some(StopReason::ToolUse); - match serde_json::from_str::(&entry.arguments) { + match parse_tool_arguments(&entry.arguments) { Ok(input) => { vec![Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { @@ -1087,29 +1088,27 @@ impl OpenAiResponseEventMapper { }; let name: Arc = Arc::from(function_call.name.clone().unwrap_or_default()); let arguments = &function_call.arguments; - if !arguments.is_empty() { - self.pending_stop_reason = Some(StopReason::ToolUse); - match serde_json::from_str::(arguments) { - Ok(input) => { - events.push(Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: LanguageModelToolUseId::from(call_id.clone()), - name: name.clone(), - is_input_complete: true, - input, - raw_input: arguments.clone(), - thought_signature: None, - }, - ))); - } - Err(error) => { - events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { + self.pending_stop_reason = Some(StopReason::ToolUse); + match parse_tool_arguments(arguments) { + Ok(input) => { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { id: LanguageModelToolUseId::from(call_id.clone()), - tool_name: name.clone(), - raw_input: Arc::::from(arguments.clone()), - json_parse_error: error.to_string(), - })); - } + name: name.clone(), + is_input_complete: true, + input, + raw_input: arguments.clone(), + thought_signature: None, + }, + ))); + } + Err(error) => { + events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { + id: LanguageModelToolUseId::from(call_id.clone()), + tool_name: name.clone(), + raw_input: Arc::::from(arguments.clone()), + json_parse_error: error.to_string(), + })); } } } @@ -1928,4 +1927,49 @@ mod tests { LanguageModelCompletionEvent::Stop(StopReason::MaxTokens) )); } + + #[test] + fn responses_stream_handles_empty_tool_arguments() { + // Test that tools with no arguments (empty string) are handled correctly + let events = vec![ + ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: response_item_function_call("item_fn", Some("")), + }, + ResponsesStreamEvent::FunctionCallArgumentsDone { + item_id: "item_fn".into(), + output_index: 0, + arguments: "".into(), + sequence_number: None, + }, + ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }, + ]; + + let mapped = map_response_events(events); + assert_eq!(mapped.len(), 2); + + // Should produce a ToolUse event with an empty object + assert!(matches!( + &mapped[0], + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id, + name, + raw_input, + input, + .. + }) if id.to_string() == "call_123" + && name.as_ref() == "get_weather" + && raw_input == "" + && input.is_object() + && input.as_object().unwrap().is_empty() + )); + + assert!(matches!( + mapped[1], + LanguageModelCompletionEvent::Stop(StopReason::ToolUse) + )); + } } diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 01d90b590ab5d23a30f2ca5739ddf2d1886f77f8..1311471c534e8fef7f1739567b5a01133e02b1d0 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -16,12 +16,13 @@ use open_router::{ }; use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore}; use std::pin::Pin; -use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter"); @@ -657,7 +658,7 @@ impl OpenRouterEventMapper { } Some("tool_calls") => { events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.clone().into(), diff --git a/crates/language_models/src/provider/util.rs b/crates/language_models/src/provider/util.rs new file mode 100644 index 0000000000000000000000000000000000000000..6b1cf7afbb7e3a068dabbc6787c322649d50393d --- /dev/null +++ b/crates/language_models/src/provider/util.rs @@ -0,0 +1,13 @@ +use std::str::FromStr; + +/// Parses tool call arguments JSON, treating empty strings as empty objects. +/// +/// Many LLM providers return empty strings for tool calls with no arguments. +/// This helper normalizes that behavior by converting empty strings to `{}`. +pub fn parse_tool_arguments(arguments: &str) -> Result { + if arguments.is_empty() { + Ok(serde_json::Value::Object(Default::default())) + } else { + serde_json::Value::from_str(arguments) + } +}