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