From 47ce07253c75b933c1d33329612c11794f575188 Mon Sep 17 00:00:00 2001 From: deepak7lal Date: Fri, 11 Sep 2026 00:27:46 +0530 Subject: [PATCH] fix(tools): salvage concatenated JSON in tool call arguments Models sometimes emit several JSON objects concatenated into a single tool-call `arguments` string. `json.loads` reports "Extra data" and `_attempt_json_repair` cannot help because nothing is truncated, so `parse_tool_call_arguments` raised and the chat completions caller converted that into `{}` - silently discarding the tool call. An empty dict is indistinguishable from the model asking for nothing, so the failure was invisible from both ends: the tool server saw no request at all, and the model retried the same malformed shape. `split_concatenated_json_objects` already handles this exact provider behaviour on the Bedrock request path, but was never wired into the chat completions response path. Reuse it before raising, keeping the first object to mirror the "first call keeps the original tool id" semantics in factory.py, and warn with the number discarded. Fixes #40582 Co-Authored-By: Claude Opus 5 --- .../prompt_templates/common_utils.py | 24 +++++++++++ ...ore_utils_prompt_templates_common_utils.py | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 2485896184e..9a9bc74e333 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -2360,6 +2360,30 @@ def parse_tool_call_arguments( ) return repaired + # Some providers emit several JSON objects concatenated into a single + # arguments string, which ``json.loads`` reports as "Extra data" and + # ``_attempt_json_repair`` cannot fix because nothing is truncated. + # This is the same provider behaviour already repaired on the Bedrock + # request path (see ``_convert_to_bedrock_tool_call_invoke``), so the + # helper is reused here rather than dropping the call: returning ``{}`` + # is indistinguishable from the model asking for nothing. + concatenated: Final = split_concatenated_json_objects(arguments) + if concatenated: + verbose_logger.warning( + "Recovered %d concatenated JSON object(s) from tool call arguments for tool '%s' (%s); " + "using the first and discarding %d. Original (%d chars): %.200s%s", + len(concatenated), + tool_name or "", + context or "unknown context", + len(concatenated) - 1, + len(arguments), + arguments, + "..." if len(arguments) > 200 else "", + ) + # Mirrors factory.py, where the first parsed object keeps the + # original tool call id. + return concatenated[0] + error_parts: Final = ["Failed to parse tool call arguments"] if tool_name: diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index b5890d1a5b0..d8781a3813c 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -20,6 +20,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( handle_any_messages_to_chat_completion_str_messages_conversion, hoist_images_from_tool_messages, is_encrypted_reasoning_block, + parse_tool_call_arguments, responses_reasoning_items_from_thinking_blocks, split_concatenated_json_objects, strip_encrypted_reasoning_from_messages, @@ -268,6 +269,46 @@ def test_split_concatenated_json_salvages_prefix_before_truncated_tail(): assert result == [{"a": 1}, {"b": 2}] +def test_parse_tool_call_arguments_salvages_concatenated_objects(): + """ + Regression test for #40582. + + Models sometimes emit several JSON objects concatenated into a single + tool-call ``arguments`` string. ``json.loads`` fails on this with + ``Extra data``, and ``_attempt_json_repair`` cannot help because nothing is + truncated. Previously this raised ``ValueError``, which the chat + completions caller converted into ``{}`` - silently discarding the tool + call. ``split_concatenated_json_objects`` already handled this exact shape + on the Bedrock request path (#20543); the response path must salvage it too. + """ + raw = ( + '{"args": "{\\"flag\\": true}"}' + '{"args": "{\\"box\\": \\"A\\", \\"limit\\": 50}"}' + '{"args": "{\\"since\\": \\"01-Jan-2025\\"}"}' + ) + + result = parse_tool_call_arguments(raw, tool_name="demo", context="chat completions") + + # The first object is kept, mirroring the "first call keeps the original + # tool id" semantics already used in factory.py for the Bedrock path. + assert result == {"args": '{"flag": true}'} + + +def test_parse_tool_call_arguments_concatenated_is_not_dropped_silently(): + """ + The chat completions caller must no longer turn a concatenated-arguments + tool call into an empty dict, which is indistinguishable from the model + asking for nothing. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + _parse_tool_call_arguments, + ) + + result = _parse_tool_call_arguments('{"a": 1}{"b": 2}', tool_name="demo", context="chat completions") + + assert result == {"a": 1} + + # --------------------------------------------------------------------------- # Regression tests for non-OpenAI file content blocks. #