diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index f39df38d069..48b01ae477e 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1989,6 +1989,29 @@ class LiteLLMCompletionResponsesConfig: namespace, restored_tool_name = mapped return restored_tool_name, namespace + @staticmethod + def _unpack_parallel_tool_calls( + tool_id: str, + tool_arguments: str, + status: str = "completed", + ) -> Sequence[ResponseFunctionToolCall] | None: + """Unpack synthetic Azure multi_tool_use.parallel container into individual function calls.""" + try: + parsed_args: Final = json.loads(tool_arguments) + return tuple( + ResponseFunctionToolCall( + name=tool_use["recipient_name"].removeprefix("functions."), + arguments=json.dumps(tool_use["parameters"]), + call_id=f"{tool_id}_{idx}", + id=f"{tool_id}_{idx}", + type="function_call", + status=status, + ) + for idx, tool_use in enumerate(parsed_args["tool_uses"]) + ) + except (json.JSONDecodeError, KeyError, TypeError, AttributeError): + return None + @staticmethod def transform_chat_completion_tools_to_responses_tools( chat_completion_response: ModelResponse, @@ -2003,14 +2026,13 @@ class LiteLLMCompletionResponsesConfig: """ all_chat_completion_tools: Final[list[ChatCompletionMessageToolCall]] = [] for choice in chat_completion_response.choices: - if isinstance(choice, Choices): - if choice.message.tool_calls: - all_chat_completion_tools.extend(choice.message.tool_calls) - for tool_call in choice.message.tool_calls: - TOOL_CALLS_CACHE.set_cache( - key=tool_call.id, - value=tool_call, - ) + if isinstance(choice, Choices) and choice.message.tool_calls: + all_chat_completion_tools.extend(choice.message.tool_calls) + for tool_call in choice.message.tool_calls: + TOOL_CALLS_CACHE.set_cache( + key=tool_call.id, + value=tool_call, + ) request_tools: Final = responses_api_request.get("tools") if responses_api_request is not None else None custom_tool_names: Final = extract_custom_tool_names(request_tools) @@ -2042,6 +2064,16 @@ class LiteLLMCompletionResponsesConfig: restore_name = LiteLLMCompletionResponsesConfig._restore_namespace_tool_name tool_name, namespace = restore_name(tool_name, namespace_tool_names) + if tool_name == "multi_tool_use.parallel": + unpacked = LiteLLMCompletionResponsesConfig._unpack_parallel_tool_calls( + tool_id=tool_id, + tool_arguments=tool_arguments, + status=function_definition.get("status") or "completed", + ) + if unpacked is not None: + responses_tools.extend(unpacked) + continue + provider_specific_fields: dict | None = None if hasattr(tool, "provider_specific_fields") and getattr(tool, "provider_specific_fields", None): provider_specific_fields = getattr(tool, "provider_specific_fields") diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_function_call_output_normalization.py b/tests/test_litellm/responses/litellm_completion_transformation/test_function_call_output_normalization.py index 3af1ef51e3e..35c78428718 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_function_call_output_normalization.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_function_call_output_normalization.py @@ -6,9 +6,21 @@ as tool/function response parts; if the tool output is passed as a list of input we normalize it to text/image blocks or a string. """ +import json +from typing import Final + +from openai.types.responses import ResponseFunctionToolCall + from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, ) +from litellm.types.utils import ( + ChatCompletionMessageToolCall, + Choices, + Function, + Message, + ModelResponse, +) def test_function_call_output_list_input_text_is_converted_to_tool_string_content(): @@ -40,3 +52,114 @@ def test_function_call_output_string_passthrough(): ) assert len(out) == 1 assert out[0]["content"] == '{"ok":true}' + + +def test_multi_tool_use_parallel_expanded_in_responses_tools(): + tool_call: Final = ChatCompletionMessageToolCall( + id="call_azure_123", + type="function", + function=Function( + name="multi_tool_use.parallel", + arguments=json.dumps( + { # mutable-ok: test payload + "tool_uses": [ + { + "recipient_name": "functions.zoekt_search", + "parameters": {"query": "litellm"}, + }, + { + "recipient_name": "functions.file_lookup", + "parameters": {"path": "README.md"}, + }, + ] + } + ), + ), + ) + response: Final = ModelResponse( + id="test_resp", + choices=[ + Choices(index=0, message=Message(content=None, role="assistant", tool_calls=[tool_call])) + ], # mutable-ok: test payload + created=1234567890, + model="azure/gpt-4o", + object="chat.completion", + ) + + result: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_tools_to_responses_tools(response) + + assert len(result) == 2 + assert isinstance(result[0], ResponseFunctionToolCall) + assert result[0].name == "zoekt_search" + assert result[0].id == "call_azure_123_0" + assert result[0].call_id == "call_azure_123_0" + assert json.loads(result[0].arguments) == {"query": "litellm"} # mutable-ok: comparison + + assert isinstance(result[1], ResponseFunctionToolCall) + assert result[1].name == "file_lookup" + assert result[1].id == "call_azure_123_1" + assert result[1].call_id == "call_azure_123_1" + assert json.loads(result[1].arguments) == {"path": "README.md"} # mutable-ok: comparison + + +def test_multi_tool_use_parallel_invalid_json_fallback(): + tool_call: Final = ChatCompletionMessageToolCall( + id="call_azure_123", + type="function", + function=Function( + name="multi_tool_use.parallel", + arguments="invalid-json-content", + ), + ) + response: Final = ModelResponse( + id="test_resp", + choices=[ + Choices(index=0, message=Message(content=None, role="assistant", tool_calls=[tool_call])) + ], # mutable-ok: test payload + created=1234567890, + model="azure/gpt-4o", + object="chat.completion", + ) + + result: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_tools_to_responses_tools(response) + + assert len(result) == 1 + assert isinstance(result[0], ResponseFunctionToolCall) + assert result[0].name == "multi_tool_use.parallel" + assert result[0].id == "call_azure_123" + + +def test_multi_tool_use_parallel_malformed_recipient_fallback(): + tool_call: Final = ChatCompletionMessageToolCall( + id="call_azure_123", + type="function", + function=Function( + name="multi_tool_use.parallel", + arguments=json.dumps( + { # mutable-ok: test payload + "tool_uses": [ + { + "recipient_name": None, + "parameters": {"query": "litellm"}, + } + ] + } + ), + ), + ) + response: Final = ModelResponse( + id="test_resp", + choices=[ + Choices(index=0, message=Message(content=None, role="assistant", tool_calls=[tool_call])) + ], # mutable-ok: test payload + created=1234567890, + model="azure/gpt-4o", + object="chat.completion", + ) + + result: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_tools_to_responses_tools(response) + + assert len(result) == 1 + assert isinstance(result[0], ResponseFunctionToolCall) + assert result[0].name == "multi_tool_use.parallel" + assert result[0].id == "call_azure_123"