From 47ce07253c75b933c1d33329612c11794f575188 Mon Sep 17 00:00:00 2001 From: deepak7lal Date: Fri, 11 Sep 2026 00:27:46 +0530 Subject: [PATCH 1/3] 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. # From fe0607c16c1adfe24c6606006d44d87c0831fe1a Mon Sep 17 00:00:00 2001 From: deepak7lal Date: Fri, 11 Sep 2026 00:49:58 +0530 Subject: [PATCH 2/3] fix(tools): restrict concatenated-JSON salvage to complete objects Address review feedback on #40603. `split_concatenated_json_objects` deliberately keeps whatever prefix it can parse before a malformed tail, so salvaging on its result meant `{"a": 1}{"b":` or `{"a": 1} garbage` would invoke a tool with partial arguments where parsing previously failed. Tool calls execute, so those must keep failing. Salvage now requires the whole string to be consumed as two or more complete JSON objects. The recovery warning also no longer logs a raw prefix of the arguments, which can carry PII or credentials; it records structural metadata only. Co-Authored-By: Claude Opus 5 --- .../prompt_templates/common_utils.py | 48 +++++++++++++++---- ...ore_utils_prompt_templates_common_utils.py | 18 +++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 9a9bc74e333..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, @@ -2364,21 +2393,20 @@ def parse_tool_call_arguments( # 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: + # 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 object(s) from tool call arguments for tool '%s' (%s); " - "using the first and discarding %d. Original (%d chars): %.200s%s", + "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, - len(arguments), - arguments, - "..." if len(arguments) > 200 else "", ) # Mirrors factory.py, where the first parsed object keeps the # original tool call id. 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 d8781a3813c..cf641113928 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 @@ -309,6 +309,24 @@ def test_parse_tool_call_arguments_concatenated_is_not_dropped_silently(): 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 + ], +) +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. # From c6c61c8dddda9c4309f8d67a573c3351c892cf52 Mon Sep 17 00:00:00 2001 From: deepak7lal Date: Fri, 11 Sep 2026 01:06:17 +0530 Subject: [PATCH 3/3] test(tools): cover non-object JSON in concatenated arguments Codecov flagged the `not isinstance(obj, dict)` guard in `_split_complete_json_objects` as the one uncovered line in the patch. `{"a": 1}[1, 2]` decodes cleanly but yields a list, which must not be salvaged as tool arguments. Co-Authored-By: Claude Opus 5 --- .../test_litellm_core_utils_prompt_templates_common_utils.py | 1 + 1 file changed, 1 insertion(+) 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 cf641113928..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 @@ -315,6 +315,7 @@ def test_parse_tool_call_arguments_concatenated_is_not_dropped_silently(): '{"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):