diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 75e04371be3..a70d9d350cf 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -5366,9 +5366,20 @@ def _parse_tool_call_arguments( return {} normalized_raw: Final = "{}" if raw == REDACTED_BY_LITELLM else raw from litellm.litellm_core_utils.prompt_templates.common_utils import ( + MAX_RECOVERED_ARGUMENT_OBJECTS, parse_tool_call_arguments, ) + try: + direct: Final = json.loads(normalized_raw) + except json.JSONDecodeError: + pass # malformed JSON: fall through to repair / concatenated recovery + else: + # Valid JSON keeps the historical object-only contract: a non-object + # root (e.g. a JSON array of objects) degrades to {} rather than + # becoming an uncapped expansion vector. + return direct if isinstance(direct, dict) else {} + try: parsed: Final = parse_tool_call_arguments( normalized_raw, @@ -5381,7 +5392,15 @@ def _parse_tool_call_arguments( return {} if isinstance(parsed, dict): return parsed - if isinstance(parsed, list) and parsed and all(isinstance(item, dict) for item in parsed): + # Only malformed-input recovery (repair or concatenated split) can yield a + # sequence here; hold it to the same per-call bound as concatenated + # recovery so expansion is never an amplification vector. + if ( + isinstance(parsed, list) + and parsed + and all(isinstance(item, dict) for item in parsed) + and len(parsed) <= MAX_RECOVERED_ARGUMENT_OBJECTS + ): return parsed return {} diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 1762c78f47e..54d85ea4aa1 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3524,6 +3524,70 @@ def test_get_tool_calls_from_response_caps_concatenated_expansion(): assert tool_calls == [{"id": "call_1", "name": "search", "arguments": {}}] +def test_get_tool_calls_from_response_valid_json_array_not_expanded(): + """ + A valid JSON array of objects is not concatenated recovery: it keeps the + historical object-only semantics (degrades to {}) and never expands, so + it cannot bypass the per-call expansion cap. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + get_tool_calls_from_response, + ) + + response: Final = { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "search", + "arguments": json.dumps( + [{"query": f"q{index}"} for index in range(9)] + ), + }, + } + ] + } + } + ] + } + + tool_calls: Final = get_tool_calls_from_response(response) + + assert tool_calls == [{"id": "call_1", "name": "search", "arguments": {}}] + + +def test_get_tool_calls_from_response_valid_json_array_of_two_not_expanded(): + """Even a small valid JSON array degrades to {} (object-only contract).""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + get_tool_calls_from_response, + ) + + response: Final = { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "search", + "arguments": json.dumps([{"query": "a"}, {"query": "b"}]), + }, + } + ] + } + } + ] + } + + tool_calls: Final = get_tool_calls_from_response(response) + + assert tool_calls == [{"id": "call_1", "name": "search", "arguments": {}}] + + def test_get_tool_calls_from_response_non_object_arguments_returns_empty(): from litellm.litellm_core_utils.prompt_templates.factory import ( get_tool_calls_from_response,