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 7b2860b9f97..180f05d5fa6 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 @@ -427,7 +427,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/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", + ]