diff --git a/python/semantic_kernel/connectors/ai/open_ai/prompt_execution_settings/open_ai_prompt_execution_settings.py b/python/semantic_kernel/connectors/ai/open_ai/prompt_execution_settings/open_ai_prompt_execution_settings.py index d65f086eba73..7f563aa3266d 100644 --- a/python/semantic_kernel/connectors/ai/open_ai/prompt_execution_settings/open_ai_prompt_execution_settings.py +++ b/python/semantic_kernel/connectors/ai/open_ai/prompt_execution_settings/open_ai_prompt_execution_settings.py @@ -68,6 +68,7 @@ class OpenAIChatPromptExecutionSettings(OpenAIPromptExecutionSettings): functions: list[dict[str, Any]] | None = None messages: list[dict[str, Any]] | None = None function_call_behavior: FunctionCallBehavior | None = Field(None, exclude=True) + parallel_tool_calls: bool = True tools: list[dict[str, Any]] | None = Field( None, max_length=64, diff --git a/python/semantic_kernel/connectors/ai/open_ai/services/open_ai_handler.py b/python/semantic_kernel/connectors/ai/open_ai/services/open_ai_handler.py index 0af2cbd44c75..cc97ecfd7624 100644 --- a/python/semantic_kernel/connectors/ai/open_ai/services/open_ai_handler.py +++ b/python/semantic_kernel/connectors/ai/open_ai/services/open_ai_handler.py @@ -44,6 +44,8 @@ async def _send_request( if self.ai_model_type == OpenAIModelTypes.CHAT: assert isinstance(request_settings, OpenAIChatPromptExecutionSettings) # nosec self._handle_structured_output(request_settings, settings) + if request_settings.tools is None: + settings.pop("parallel_tool_calls", None) response = await self.client.chat.completions.create(**settings) else: response = await self.client.completions.create(**settings) diff --git a/python/tests/unit/connectors/ai/open_ai/services/test_azure_chat_completion.py b/python/tests/unit/connectors/ai/open_ai/services/test_azure_chat_completion.py index b4891c68f5cc..eaef9ff64931 100644 --- a/python/tests/unit/connectors/ai/open_ai/services/test_azure_chat_completion.py +++ b/python/tests/unit/connectors/ai/open_ai/services/test_azure_chat_completion.py @@ -2,6 +2,7 @@ import json import os +from copy import deepcopy from unittest.mock import AsyncMock, MagicMock, patch import openai @@ -17,6 +18,7 @@ from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.function_call_behavior import FunctionCallBehavior +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion from semantic_kernel.connectors.ai.open_ai.exceptions.content_filter_ai_exception import ( ContentFilterAIException, @@ -32,6 +34,8 @@ from semantic_kernel.contents.text_content import TextContent from semantic_kernel.exceptions import ServiceInitializationError, ServiceInvalidExecutionSettingsError from semantic_kernel.exceptions.service_exceptions import ServiceResponseException +from semantic_kernel.functions.kernel_arguments import KernelArguments +from semantic_kernel.functions.kernel_function_decorator import kernel_function from semantic_kernel.kernel import Kernel # region Service Setup @@ -595,6 +599,162 @@ async def test_cmc_tool_calling( ) +@pytest.mark.asyncio +@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) +async def test_cmc_tool_calling_parallel_tool_calls( + mock_create, + kernel: Kernel, + azure_openai_unit_test_env, + chat_history: ChatHistory, + mock_chat_completion_response: ChatCompletion, +) -> None: + mock_chat_completion_response.choices = [ + Choice( + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + tool_calls=[ + { + "id": "test id", + "function": {"name": "test-tool", "arguments": '{"key": "value"}'}, + "type": "function", + } + ], + ), + finish_reason="stop", + ) + ] + mock_create.return_value = mock_chat_completion_response + prompt = "hello world" + chat_history.add_user_message(prompt) + + class MockPlugin: + @kernel_function(name="test_tool") + def test_tool(self, key: str): + return "test" + + kernel.add_plugin(MockPlugin(), plugin_name="test_tool") + + orig_chat_history = deepcopy(chat_history) + complete_prompt_execution_settings = AzureChatPromptExecutionSettings( + service_id="test_service_id", function_choice_behavior=FunctionChoiceBehavior.Auto() + ) + + with patch( + "semantic_kernel.kernel.Kernel.invoke_function_call", + new_callable=AsyncMock, + ) as mock_process_function_call: + azure_chat_completion = AzureChatCompletion() + await azure_chat_completion.get_chat_message_contents( + chat_history=chat_history, + settings=complete_prompt_execution_settings, + kernel=kernel, + arguments=KernelArguments(), + ) + mock_create.assert_awaited_once_with( + model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], + stream=False, + messages=azure_chat_completion._prepare_chat_history_for_request(orig_chat_history), + parallel_tool_calls=True, + tools=[ + { + "type": "function", + "function": { + "name": "test_tool-test_tool", + "description": "", + "parameters": { + "type": "object", + "properties": {"key": {"type": "string"}}, + "required": ["key"], + }, + }, + } + ], + tool_choice="auto", + ) + mock_process_function_call.assert_awaited() + + +@pytest.mark.asyncio +@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) +async def test_cmc_tool_calling_parallel_tool_calls_disabled( + mock_create, + kernel: Kernel, + azure_openai_unit_test_env, + chat_history: ChatHistory, + mock_chat_completion_response: ChatCompletion, +) -> None: + mock_chat_completion_response.choices = [ + Choice( + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + tool_calls=[ + { + "id": "test id", + "function": {"name": "test-tool", "arguments": '{"key": "value"}'}, + "type": "function", + } + ], + ), + finish_reason="stop", + ) + ] + mock_create.return_value = mock_chat_completion_response + prompt = "hello world" + chat_history.add_user_message(prompt) + + class MockPlugin: + @kernel_function(name="test_tool") + def test_tool(self, key: str): + return "test" + + kernel.add_plugin(MockPlugin(), plugin_name="test_tool") + + orig_chat_history = deepcopy(chat_history) + complete_prompt_execution_settings = AzureChatPromptExecutionSettings( + service_id="test_service_id", + function_choice_behavior=FunctionChoiceBehavior.Auto(), + parallel_tool_calls=False, + ) + + with patch( + "semantic_kernel.kernel.Kernel.invoke_function_call", + new_callable=AsyncMock, + ) as mock_process_function_call: + azure_chat_completion = AzureChatCompletion() + await azure_chat_completion.get_chat_message_contents( + chat_history=chat_history, + settings=complete_prompt_execution_settings, + kernel=kernel, + arguments=KernelArguments(), + ) + mock_create.assert_awaited_once_with( + model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], + stream=False, + messages=azure_chat_completion._prepare_chat_history_for_request(orig_chat_history), + parallel_tool_calls=False, + tools=[ + { + "type": "function", + "function": { + "name": "test_tool-test_tool", + "description": "", + "parameters": { + "type": "object", + "properties": {"key": {"type": "string"}}, + "required": ["key"], + }, + }, + } + ], + tool_choice="auto", + ) + mock_process_function_call.assert_awaited() + + CONTENT_FILTERED_ERROR_MESSAGE = ( "The response was filtered due to the prompt triggering Azure OpenAI's content management policy. Please " "modify your prompt and retry. To learn more about our content filtering policies please read our " diff --git a/python/tests/unit/connectors/ai/open_ai/services/test_open_ai_chat_completion_base.py b/python/tests/unit/connectors/ai/open_ai/services/test_open_ai_chat_completion_base.py index 401349542f02..54e02bea0c20 100644 --- a/python/tests/unit/connectors/ai/open_ai/services/test_open_ai_chat_completion_base.py +++ b/python/tests/unit/connectors/ai/open_ai/services/test_open_ai_chat_completion_base.py @@ -224,6 +224,14 @@ async def test_cmc_function_choice_behavior( complete_prompt_execution_settings = OpenAIChatPromptExecutionSettings( service_id="test_service_id", function_choice_behavior=FunctionChoiceBehavior.Auto() ) + + class MockPlugin: + @kernel_function(name="test_tool") + def test_tool(self, key: str): + return "test" + + kernel.add_plugin(MockPlugin(), plugin_name="test_tool") + with patch( "semantic_kernel.kernel.Kernel.invoke_function_call", new_callable=AsyncMock, @@ -239,6 +247,99 @@ async def test_cmc_function_choice_behavior( model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], stream=False, messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), + parallel_tool_calls=True, + tools=[ + { + "type": "function", + "function": { + "name": "test_tool-test_tool", + "description": "", + "parameters": { + "type": "object", + "properties": {"key": {"type": "string"}}, + "required": ["key"], + }, + }, + } + ], + tool_choice="auto", + ) + mock_process_function_call.assert_awaited() + + +@pytest.mark.asyncio +@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) +async def test_cmc_fcb_parallel_func_calling_disabled( + mock_create, + kernel: Kernel, + chat_history: ChatHistory, + mock_chat_completion_response: ChatCompletion, + openai_unit_test_env, +): + mock_chat_completion_response.choices = [ + Choice( + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + tool_calls=[ + { + "id": "test id", + "function": {"name": "test-tool", "arguments": '{"key": "value"}'}, + "type": "function", + } + ], + ), + finish_reason="stop", + ) + ] + mock_create.return_value = mock_chat_completion_response + chat_history.add_user_message("hello world") + orig_chat_history = deepcopy(chat_history) + complete_prompt_execution_settings = OpenAIChatPromptExecutionSettings( + service_id="test_service_id", + function_choice_behavior=FunctionChoiceBehavior.Auto(), + parallel_tool_calls=False, + ) + + class MockPlugin: + @kernel_function(name="test_tool") + def test_tool(self, key: str): + return "test" + + kernel.add_plugin(MockPlugin(), plugin_name="test_tool") + + with patch( + "semantic_kernel.kernel.Kernel.invoke_function_call", + new_callable=AsyncMock, + ) as mock_process_function_call: + openai_chat_completion = OpenAIChatCompletion() + await openai_chat_completion.get_chat_message_contents( + chat_history=chat_history, + settings=complete_prompt_execution_settings, + kernel=kernel, + arguments=KernelArguments(), + ) + mock_create.assert_awaited_once_with( + model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + stream=False, + messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), + parallel_tool_calls=False, + tools=[ + { + "type": "function", + "function": { + "name": "test_tool-test_tool", + "description": "", + "parameters": { + "type": "object", + "properties": {"key": {"type": "string"}}, + "required": ["key"], + }, + }, + } + ], + tool_choice="auto", ) mock_process_function_call.assert_awaited() @@ -626,6 +727,76 @@ async def test_scmc_function_choice_behavior( complete_prompt_execution_settings = OpenAIChatPromptExecutionSettings( service_id="test_service_id", function_choice_behavior=FunctionChoiceBehavior.Auto() ) + + class MockPlugin: + @kernel_function(name="test_tool") + def test_tool(self, key: str): + return "test" + + kernel.add_plugin(MockPlugin(), plugin_name="test_tool") + + with patch( + "semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion_base.OpenAIChatCompletionBase._process_function_call", + new_callable=AsyncMock, + return_value=None, + ): + openai_chat_completion = OpenAIChatCompletion() + async for msg in openai_chat_completion.get_streaming_chat_message_contents( + chat_history=chat_history, + settings=complete_prompt_execution_settings, + kernel=kernel, + arguments=KernelArguments(), + ): + assert isinstance(msg[0], StreamingChatMessageContent) + mock_create.assert_awaited_once_with( + model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + stream=True, + parallel_tool_calls=True, + tools=[ + { + "type": "function", + "function": { + "name": "test_tool-test_tool", + "description": "", + "parameters": { + "type": "object", + "properties": {"key": {"type": "string"}}, + "required": ["key"], + }, + }, + } + ], + tool_choice="auto", + stream_options={"include_usage": True}, + messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), + ) + + +@pytest.mark.asyncio +@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) +async def test_scmc_fcb_parallel_tool_call_disabled( + mock_create, + kernel: Kernel, + chat_history: ChatHistory, + mock_streaming_chat_completion_response: ChatCompletion, + openai_unit_test_env, +): + mock_create.return_value = mock_streaming_chat_completion_response + chat_history.add_user_message("hello world") + orig_chat_history = deepcopy(chat_history) + complete_prompt_execution_settings = OpenAIChatPromptExecutionSettings( + service_id="test_service_id", + function_choice_behavior=FunctionChoiceBehavior.Auto(), + parallel_tool_calls=False, + ) + + class MockPlugin: + @kernel_function(name="test_tool") + def test_tool(self, key: str): + return "test" + + kernel.add_plugin(MockPlugin(), plugin_name="test_tool") + with patch( "semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion_base.OpenAIChatCompletionBase._process_function_call", new_callable=AsyncMock, @@ -642,6 +813,22 @@ async def test_scmc_function_choice_behavior( mock_create.assert_awaited_once_with( model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], stream=True, + parallel_tool_calls=False, + tools=[ + { + "type": "function", + "function": { + "name": "test_tool-test_tool", + "description": "", + "parameters": { + "type": "object", + "properties": {"key": {"type": "string"}}, + "required": ["key"], + }, + }, + } + ], + tool_choice="auto", stream_options={"include_usage": True}, messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), )