From 60c2d9f590b4e72b25a9a6d60c6b4070f03a9637 Mon Sep 17 00:00:00 2001 From: sclfcz Date: Fri, 25 Sep 2026 23:07:18 +0800 Subject: [PATCH 1/6] fix(llm_response_utils): expand every hallucinated multi_tool_use.parallel call _handle_invalid_parallel_tool_calls replaces one entry with len(replacement) entries, so the offsets of everything after it shift by len(replacement) - 1. Advancing by the full length skipped one entry per expansion: with two multi_tool_use.parallel calls in one message the second stayed in message.tool_calls (so agents tried to call a non-existent tool) and the call after it was overwritten. Adds a regression test that expands two such calls in one message. --- .../convert_dict_to_response.py | 6 +- .../test_convert_dict_to_chat_completion.py | 76 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 9ea730a873f..ff624a3de8b 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -406,7 +406,11 @@ def _handle_invalid_parallel_tool_calls( shift = 0 for i, replacement in replacements.items(): tool_calls[:] = tool_calls[: i + shift] + replacement + tool_calls[i + shift + 1 :] - shift += len(replacement) + # One entry is replaced by ``len(replacement)`` entries, so the + # offsets of everything after it move by the difference - not by + # the full length, which would skip one entry per expansion and + # leave the next ``multi_tool_use.parallel`` call in place. + shift += len(replacement) - 1 return tool_calls except json.JSONDecodeError: diff --git a/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py b/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py index 31c554985a7..1a5fbe95b5a 100644 --- a/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py +++ b/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py @@ -2486,3 +2486,79 @@ class TestConvertToModelResponseObjectCompletion: }, model_response_object=None, ) + + +def test_convert_to_model_response_object_expands_every_parallel_tool_call(): + """ + Every hallucinated `multi_tool_use.parallel` entry in one message must be + expanded. Replacing one entry with its expansions moves the offsets of the + entries that follow it by `len(expansions) - 1`; advancing by the full + length skipped one entry per expansion, so with two such calls the second + one stayed in place and the call after it was overwritten. + """ + + def parallel(call_id, recipient_name, parameters): + return { + "id": call_id, + "type": "function", + "function": { + "name": "multi_tool_use.parallel", + "arguments": json.dumps( + { + "tool_uses": [ + { + "recipient_name": recipient_name, + "parameters": parameters, + } + ] + } + ), + }, + } + + def plain(call_id, name): + return { + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": "{}"}, + } + + response_object = { + "id": "chatcmpl-parallel", + "object": "chat.completion", + "created": 1728933352, + "model": "gpt-4o-2024-08-06", + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + plain("0", "get_weather"), + parallel("m1", "functions.get_time", {"tz": "UTC"}), + plain("2", "get_news"), + parallel("m2", "functions.get_quote", {"sym": "AAPL"}), + plain("4", "get_forecast"), + ], + }, + } + ], + } + + result = convert_to_model_response_object( + response_object=response_object, + model_response_object=ModelResponse(), + response_type="completion", + ) + + names = [tc.function.name for tc in result.choices[0].message.tool_calls] + assert names == [ + "get_weather", + "get_time", + "get_news", + "get_quote", + "get_forecast", + ] + assert "multi_tool_use.parallel" not in names From e2f83aadcf1298cb255b48dd76cf8d9a9680abc5 Mon Sep 17 00:00:00 2001 From: sclfcz Date: Fri, 25 Sep 2026 23:45:49 +0800 Subject: [PATCH 2/6] test(llm_response_utils): expand one parallel call into two tool uses Addresses review feedback: with one tool use per parallel call the replacement never changed the list length, so the test did not pin the offset arithmetic. Now the first splice changes the length and the second has to land at the shifted offset. --- .../test_convert_dict_to_chat_completion.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py b/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py index 1a5fbe95b5a..ff0bd628ab3 100644 --- a/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py +++ b/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py @@ -2497,7 +2497,7 @@ def test_convert_to_model_response_object_expands_every_parallel_tool_call(): one stayed in place and the call after it was overwritten. """ - def parallel(call_id, recipient_name, parameters): + def parallel(call_id, *calls): return { "id": call_id, "type": "function", @@ -2506,10 +2506,8 @@ def test_convert_to_model_response_object_expands_every_parallel_tool_call(): "arguments": json.dumps( { "tool_uses": [ - { - "recipient_name": recipient_name, - "parameters": parameters, - } + {"recipient_name": name, "parameters": params} + for name, params in calls ] } ), @@ -2537,9 +2535,16 @@ def test_convert_to_model_response_object_expands_every_parallel_tool_call(): "content": None, "tool_calls": [ plain("0", "get_weather"), - parallel("m1", "functions.get_time", {"tz": "UTC"}), + # Two expansions here and one below, so the first splice + # changes the list length and the second splice has to land + # at the shifted offset. + parallel( + "m1", + ("functions.get_time", {"tz": "UTC"}), + ("functions.get_date", {"tz": "UTC"}), + ), plain("2", "get_news"), - parallel("m2", "functions.get_quote", {"sym": "AAPL"}), + parallel("m2", ("functions.get_quote", {"sym": "AAPL"})), plain("4", "get_forecast"), ], }, @@ -2557,6 +2562,7 @@ def test_convert_to_model_response_object_expands_every_parallel_tool_call(): assert names == [ "get_weather", "get_time", + "get_date", "get_news", "get_quote", "get_forecast", From 523c30dcf3c1e2b49b295a4dd330cd41c00d65fe Mon Sep 17 00:00:00 2001 From: sclfcz Date: Sat, 26 Sep 2026 09:07:25 +0800 Subject: [PATCH 3/6] test(llm_response_utils): cover the malformed payload guard where unit coverage runs codecov/patch reported 0% of the diff hit: the new except-clause lines are in llm_response_utils, but this test lived under tests/llm_translation, which the unit coverage job does not run. Move it next to the other llm_response_utils tests and add a positive case so the guard is proven not to swallow valid payloads. --- ...test_handle_invalid_parallel_tool_calls.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_litellm/litellm_core_utils/llm_response_utils/test_handle_invalid_parallel_tool_calls.py diff --git a/tests/test_litellm/litellm_core_utils/llm_response_utils/test_handle_invalid_parallel_tool_calls.py b/tests/test_litellm/litellm_core_utils/llm_response_utils/test_handle_invalid_parallel_tool_calls.py new file mode 100644 index 00000000000..09ef7bca2d2 --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/llm_response_utils/test_handle_invalid_parallel_tool_calls.py @@ -0,0 +1,59 @@ +"""The hallucinated multi_tool_use.parallel expansion must not fail the response.""" + +import json + +import pytest + +from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( + _handle_invalid_parallel_tool_calls, +) +from litellm.types.utils import ChatCompletionMessageToolCall, Function + + +def _parallel_instance(arguments: str): + return [ + ChatCompletionMessageToolCall( + id="call_1", + type="function", + function=Function(name="multi_tool_use.parallel", arguments=arguments), + ) + ] + + +@pytest.mark.parametrize( + "arguments", + [ + '{"tool_uses": [{"recipient_name": "functions.get_weather"}]}', + '{"tool_uses": "nope"}', + '{"tool_uses": [42]}', + "{}", + ], +) +def test_malformed_tool_uses_returns_original_calls(arguments): + """A hallucinated payload we cannot expand must come back untouched.""" + tool_calls = _parallel_instance(arguments) + + result = _handle_invalid_parallel_tool_calls(tool_calls) + + assert len(result) == 1 + assert result[0].function.name == "multi_tool_use.parallel" + assert result[0].id == "call_1" + assert result[0].function.arguments == arguments + + +def test_valid_parallel_payload_still_expands(): + """The guard must not swallow well-formed payloads.""" + tool_calls = _parallel_instance( + json.dumps( + { + "tool_uses": [ + {"recipient_name": "functions.get_weather", "parameters": {"city": "NYC"}}, + {"recipient_name": "functions.get_time", "parameters": {"tz": "EST"}}, + ] + } + ) + ) + + result = _handle_invalid_parallel_tool_calls(tool_calls) + + assert [c.function.name for c in result] == ["get_weather", "get_time"] From 752e755da5b0e5b2404435b91f3f9af6d929e81b Mon Sep 17 00:00:00 2001 From: sclfcz Date: Sat, 26 Sep 2026 09:08:03 +0800 Subject: [PATCH 4/6] fix(llm_response_utils): actually widen the except clause The earlier commit added the rationale comment but the clause still read json.JSONDecodeError alone, so the four malformed payloads kept escaping. --- .../llm_response_utils/convert_dict_to_response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index ff624a3de8b..5395ba973d3 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -413,7 +413,7 @@ def _handle_invalid_parallel_tool_calls( shift += len(replacement) - 1 return tool_calls - except json.JSONDecodeError: + except (json.JSONDecodeError, KeyError, TypeError, AttributeError): # if there is a JSONDecodeError, return the original tool_calls return tool_calls From a99918e84598def918863f96632c8289cbbc84ec Mon Sep 17 00:00:00 2001 From: sclfcz Date: Sat, 26 Sep 2026 09:18:11 +0800 Subject: [PATCH 5/6] test(llm_response_utils): cover the parallel expansion where unit coverage runs codecov/patch reported 0% of diff hit: the regression test lived under tests/llm_translation, which the unit coverage job does not run. Add it next to the other llm_response_utils unit tests, keeping the length-changing case (two tool uses in the first parallel call) that pins the shift arithmetic. --- .../test_parallel_tool_call_shift.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_litellm/litellm_core_utils/llm_response_utils/test_parallel_tool_call_shift.py diff --git a/tests/test_litellm/litellm_core_utils/llm_response_utils/test_parallel_tool_call_shift.py b/tests/test_litellm/litellm_core_utils/llm_response_utils/test_parallel_tool_call_shift.py new file mode 100644 index 00000000000..52a6ae2cc07 --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/llm_response_utils/test_parallel_tool_call_shift.py @@ -0,0 +1,88 @@ +"""Every hallucinated multi_tool_use.parallel call in one message must expand. + +Replacing one entry with len(expansions) entries moves everything after it by +len(expansions) - 1; advancing by the full length skipped one entry per +expansion, so with two such calls the second stayed in place (and the call +after it was overwritten). +""" + +import json + +from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( + convert_to_model_response_object, +) +from litellm.types.utils import ModelResponse + + +def _parallel(call_id: str, *calls): + return { + "id": call_id, + "type": "function", + "function": { + "name": "multi_tool_use.parallel", + "arguments": json.dumps( + { + "tool_uses": [ + {"recipient_name": name, "parameters": params} for name, params in calls + ] + } + ), + }, + } + + +def _plain(call_id: str, name: str): + return { + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": "{}"}, + } + + +def test_every_parallel_tool_call_expands(): + response_object = { + "id": "chatcmpl-parallel", + "object": "chat.completion", + "created": 1728933352, + "model": "gpt-4o-2024-08-06", + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + _plain("0", "get_weather"), + # Two expansions here and one below, so the first splice + # changes the list length and the second has to land at + # the shifted offset. + _parallel( + "m1", + ("functions.get_time", {"tz": "UTC"}), + ("functions.get_date", {"tz": "UTC"}), + ), + _plain("2", "get_news"), + _parallel("m2", ("functions.get_quote", {"sym": "AAPL"})), + _plain("4", "get_forecast"), + ], + }, + } + ], + } + + result = convert_to_model_response_object( + response_object=response_object, + model_response_object=ModelResponse(), + response_type="completion", + ) + + names = [tc.function.name for tc in result.choices[0].message.tool_calls] + assert names == [ + "get_weather", + "get_time", + "get_date", + "get_news", + "get_quote", + "get_forecast", + ] From c02f11dd7edb783a220ed6c49922b3b8de6dc9af Mon Sep 17 00:00:00 2001 From: sclfcz Date: Sat, 26 Sep 2026 09:19:57 +0800 Subject: [PATCH 6/6] chore: keep this PR to the shift fix only Two commits that belong to the malformed-payload PR (#43173) had leaked onto this branch: the widened except clause and its test file. Restore the clause to main's version and drop those tests here, and keep the regression test only in tests/test_litellm/litellm_core_utils/llm_response_utils/ where the unit coverage job runs. --- .../convert_dict_to_response.py | 2 +- .../test_convert_dict_to_chat_completion.py | 82 ------------------- ...test_handle_invalid_parallel_tool_calls.py | 59 ------------- 3 files changed, 1 insertion(+), 142 deletions(-) delete mode 100644 tests/test_litellm/litellm_core_utils/llm_response_utils/test_handle_invalid_parallel_tool_calls.py diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 5395ba973d3..ff624a3de8b 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -413,7 +413,7 @@ def _handle_invalid_parallel_tool_calls( shift += len(replacement) - 1 return tool_calls - except (json.JSONDecodeError, KeyError, TypeError, AttributeError): + except json.JSONDecodeError: # if there is a JSONDecodeError, return the original tool_calls return tool_calls diff --git a/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py b/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py index ff0bd628ab3..31c554985a7 100644 --- a/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py +++ b/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py @@ -2486,85 +2486,3 @@ class TestConvertToModelResponseObjectCompletion: }, model_response_object=None, ) - - -def test_convert_to_model_response_object_expands_every_parallel_tool_call(): - """ - Every hallucinated `multi_tool_use.parallel` entry in one message must be - expanded. Replacing one entry with its expansions moves the offsets of the - entries that follow it by `len(expansions) - 1`; advancing by the full - length skipped one entry per expansion, so with two such calls the second - one stayed in place and the call after it was overwritten. - """ - - def parallel(call_id, *calls): - return { - "id": call_id, - "type": "function", - "function": { - "name": "multi_tool_use.parallel", - "arguments": json.dumps( - { - "tool_uses": [ - {"recipient_name": name, "parameters": params} - for name, params in calls - ] - } - ), - }, - } - - def plain(call_id, name): - return { - "id": call_id, - "type": "function", - "function": {"name": name, "arguments": "{}"}, - } - - response_object = { - "id": "chatcmpl-parallel", - "object": "chat.completion", - "created": 1728933352, - "model": "gpt-4o-2024-08-06", - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "content": None, - "tool_calls": [ - plain("0", "get_weather"), - # Two expansions here and one below, so the first splice - # changes the list length and the second splice has to land - # at the shifted offset. - parallel( - "m1", - ("functions.get_time", {"tz": "UTC"}), - ("functions.get_date", {"tz": "UTC"}), - ), - plain("2", "get_news"), - parallel("m2", ("functions.get_quote", {"sym": "AAPL"})), - plain("4", "get_forecast"), - ], - }, - } - ], - } - - result = convert_to_model_response_object( - response_object=response_object, - model_response_object=ModelResponse(), - response_type="completion", - ) - - names = [tc.function.name for tc in result.choices[0].message.tool_calls] - assert names == [ - "get_weather", - "get_time", - "get_date", - "get_news", - "get_quote", - "get_forecast", - ] - assert "multi_tool_use.parallel" not in names diff --git a/tests/test_litellm/litellm_core_utils/llm_response_utils/test_handle_invalid_parallel_tool_calls.py b/tests/test_litellm/litellm_core_utils/llm_response_utils/test_handle_invalid_parallel_tool_calls.py deleted file mode 100644 index 09ef7bca2d2..00000000000 --- a/tests/test_litellm/litellm_core_utils/llm_response_utils/test_handle_invalid_parallel_tool_calls.py +++ /dev/null @@ -1,59 +0,0 @@ -"""The hallucinated multi_tool_use.parallel expansion must not fail the response.""" - -import json - -import pytest - -from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( - _handle_invalid_parallel_tool_calls, -) -from litellm.types.utils import ChatCompletionMessageToolCall, Function - - -def _parallel_instance(arguments: str): - return [ - ChatCompletionMessageToolCall( - id="call_1", - type="function", - function=Function(name="multi_tool_use.parallel", arguments=arguments), - ) - ] - - -@pytest.mark.parametrize( - "arguments", - [ - '{"tool_uses": [{"recipient_name": "functions.get_weather"}]}', - '{"tool_uses": "nope"}', - '{"tool_uses": [42]}', - "{}", - ], -) -def test_malformed_tool_uses_returns_original_calls(arguments): - """A hallucinated payload we cannot expand must come back untouched.""" - tool_calls = _parallel_instance(arguments) - - result = _handle_invalid_parallel_tool_calls(tool_calls) - - assert len(result) == 1 - assert result[0].function.name == "multi_tool_use.parallel" - assert result[0].id == "call_1" - assert result[0].function.arguments == arguments - - -def test_valid_parallel_payload_still_expands(): - """The guard must not swallow well-formed payloads.""" - tool_calls = _parallel_instance( - json.dumps( - { - "tool_uses": [ - {"recipient_name": "functions.get_weather", "parameters": {"city": "NYC"}}, - {"recipient_name": "functions.get_time", "parameters": {"tz": "EST"}}, - ] - } - ) - ) - - result = _handle_invalid_parallel_tool_calls(tool_calls) - - assert [c.function.name for c in result] == ["get_weather", "get_time"]