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. #