diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 2485896184e..18c5194de88 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -2314,6 +2314,35 @@ def _attempt_json_repair(s: str) -> object | None: return None +def _split_complete_json_objects(raw: str) -> list[dict[str, object]] | None: + """ + Split *raw* into JSON objects, requiring the entire string to be consumed. + + Unlike :func:`split_concatenated_json_objects`, which deliberately salvages + whatever prefix it can before a malformed tail, this returns ``None`` unless + *raw* is exactly a sequence of complete JSON objects. Tool call arguments + are executed, so a truncated or trailing-garbage payload must keep failing + rather than invoke a tool with partial input. + """ + import json + + decoder: Final = json.JSONDecoder() + objects: list[dict[str, object]] = [] + index = 0 + while index < len(raw): + if raw[index].isspace(): + index += 1 + continue + try: + obj, index = decoder.raw_decode(raw, index) + except json.JSONDecodeError: + return None + if not isinstance(obj, dict): + return None + objects.append(obj) + return objects or None + + def parse_tool_call_arguments( arguments: str | None, tool_name: str | None = None, @@ -2360,6 +2389,29 @@ 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 it is + # salvaged here too rather than dropping the call: returning ``{}`` is + # indistinguishable from the model asking for nothing. + concatenated: Final = _split_complete_json_objects(arguments) + if concatenated is not None and len(concatenated) > 1: + # Structural metadata only - the arguments themselves may carry + # PII or credentials and must not reach warning logs. + verbose_logger.warning( + "Recovered %d concatenated JSON objects from tool call arguments " + "for tool '%s' (%s); using the first and discarding %d.", + len(concatenated), + tool_name or "", + context or "unknown context", + len(concatenated) - 1, + ) + # 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..617a4055fe4 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,65 @@ 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} + + +@pytest.mark.parametrize( + "raw", + [ + '{"a": 1}{"b":', # truncated tail + '{"a": 1} garbage', # trailing garbage + '{"a": 1}{"b": 2} x', # complete objects followed by junk + '{"a": 1}[1, 2]', # valid JSON, but not an object + ], +) +def test_parse_tool_call_arguments_rejects_incomplete_concatenation(raw): + """ + Salvage is restricted to input wholly consumed as complete JSON objects. + Tool call arguments are executed, so a truncated or trailing-garbage + payload must keep failing rather than invoke a tool with partial input. + """ + with pytest.raises(ValueError, match="Failed to parse tool call arguments"): + parse_tool_call_arguments(raw, tool_name="demo", context="chat completions") + + # --------------------------------------------------------------------------- # Regression tests for non-OpenAI file content blocks. #