diff --git a/python/semantic_kernel/connectors/ai/anthropic/services/anthropic_chat_completion.py b/python/semantic_kernel/connectors/ai/anthropic/services/anthropic_chat_completion.py index ed2616ba71aa..4c4c9da92d60 100644 --- a/python/semantic_kernel/connectors/ai/anthropic/services/anthropic_chat_completion.py +++ b/python/semantic_kernel/connectors/ai/anthropic/services/anthropic_chat_completion.py @@ -3,7 +3,7 @@ import json import logging import sys -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable from typing import Any, ClassVar if sys.version_info >= (3, 12): @@ -26,7 +26,10 @@ from semantic_kernel.connectors.ai.anthropic.prompt_execution_settings.anthropic_prompt_execution_settings import ( AnthropicChatPromptExecutionSettings, ) -from semantic_kernel.connectors.ai.anthropic.services.utils import MESSAGE_CONVERTERS +from semantic_kernel.connectors.ai.anthropic.services.utils import ( + MESSAGE_CONVERTERS, + update_settings_from_function_call_configuration, +) from semantic_kernel.connectors.ai.anthropic.settings.anthropic_settings import AnthropicSettings from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.function_call_choice_configuration import FunctionCallChoiceConfiguration @@ -43,10 +46,10 @@ from semantic_kernel.contents.utils.finish_reason import FinishReason as SemanticKernelFinishReason from semantic_kernel.exceptions.service_exceptions import ( ServiceInitializationError, + ServiceInvalidRequestError, ServiceInvalidResponseError, ServiceResponseException, ) -from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata from semantic_kernel.utils.experimental_decorator import experimental_class from semantic_kernel.utils.telemetry.model_diagnostics.decorators import ( trace_chat_completion, @@ -130,6 +133,19 @@ def get_prompt_execution_settings_class(self) -> type["PromptExecutionSettings"] def service_url(self) -> str | None: return str(self.async_client.base_url) + @override + def _update_function_choice_settings_callback( + self, + ) -> Callable[[FunctionCallChoiceConfiguration, "PromptExecutionSettings", FunctionChoiceType], None]: + return update_settings_from_function_call_configuration + + @override + def _reset_function_choice_settings(self, settings: "PromptExecutionSettings") -> None: + if hasattr(settings, "tool_choice"): + settings.tool_choice = None + if hasattr(settings, "tools"): + settings.tools = None + @override @trace_chat_completion(MODEL_PROVIDER_NAME) async def _inner_get_chat_message_contents( @@ -171,6 +187,7 @@ async def _inner_get_streaming_chat_message_contents( async for message in response: yield message + @override def _prepare_chat_history_for_request( self, chat_history: "ChatHistory", @@ -194,14 +211,37 @@ def _prepare_chat_history_for_request( system_message_content = None system_message_count = 0 formatted_messages: list[dict[str, Any]] = [] - for message in chat_history.messages: - # Skip system messages after the first one is found - if message.role == AuthorRole.SYSTEM: + for i in range(len(chat_history)): + prev_message = chat_history[i - 1] if i > 0 else None + curr_message = chat_history[i] + if curr_message.role == AuthorRole.SYSTEM: + # Skip system messages after the first one is found if system_message_count == 0: - system_message_content = message.content + system_message_content = curr_message.content system_message_count += 1 + elif curr_message.role == AuthorRole.USER or curr_message.role == AuthorRole.ASSISTANT: + formatted_messages.append(MESSAGE_CONVERTERS[curr_message.role](curr_message)) + elif curr_message.role == AuthorRole.TOOL: + if prev_message is None: + # Under no circumstances should a tool message be the first message in the chat history + raise ServiceInvalidRequestError("Tool message found without a preceding message.") + if prev_message.role == AuthorRole.USER or prev_message.role == AuthorRole.SYSTEM: + # A tool message should not be found after a user or system message + # Please NOTE that in SK there are the USER role and the TOOL role, but in Anthropic + # the tool messages are considered as USER messages. We are checking against the SK roles. + raise ServiceInvalidRequestError("Tool message found after a user or system message.") + + formatted_message = MESSAGE_CONVERTERS[curr_message.role](curr_message) + if prev_message.role == AuthorRole.ASSISTANT: + # The first tool message after an assistant message should be a new message + formatted_messages.append(formatted_message) + else: + # Append the tool message to the previous tool message. + # This indicates that the assistant message requested multiple parallel tool calls. + # Anthropic requires that parallel Tool messages are grouped together in a single message. + formatted_messages[-1][content_key] += formatted_message[content_key] else: - formatted_messages.append(MESSAGE_CONVERTERS[message.role](message)) + raise ServiceInvalidRequestError(f"Unsupported role in chat history: {curr_message.role}") if system_message_count > 1: logger.warning( @@ -277,50 +317,6 @@ def _create_streaming_chat_message_content( items=items, ) - def update_settings_from_function_call_configuration_anthropic( - self, - function_choice_configuration: FunctionCallChoiceConfiguration, - settings: "PromptExecutionSettings", - type: "FunctionChoiceType", - ) -> None: - """Update the settings from a FunctionChoiceConfiguration.""" - if ( - function_choice_configuration.available_functions - and hasattr(settings, "tools") - and hasattr(settings, "tool_choice") - ): - settings.tools = [ - self.kernel_function_metadata_to_function_call_format_anthropic(f) - for f in function_choice_configuration.available_functions - ] - - if ( - settings.function_choice_behavior - and settings.function_choice_behavior.type_ == FunctionChoiceType.REQUIRED - ) or type == FunctionChoiceType.REQUIRED: - settings.tool_choice = {"type": "any"} - else: - settings.tool_choice = {"type": type.value} - - def kernel_function_metadata_to_function_call_format_anthropic( - self, - metadata: KernelFunctionMetadata, - ) -> dict[str, Any]: - """Convert the kernel function metadata to function calling format.""" - return { - "name": metadata.fully_qualified_name, - "description": metadata.description or "", - "input_schema": { - "type": "object", - "properties": {p.name: p.schema_data for p in metadata.parameters}, - "required": [p.name for p in metadata.parameters if p.is_required], - }, - } - - @override - def _update_function_choice_settings_callback(self): - return self.update_settings_from_function_call_configuration_anthropic - async def _send_chat_request(self, settings: AnthropicChatPromptExecutionSettings) -> list["ChatMessageContent"]: """Send the chat request.""" try: @@ -382,10 +378,3 @@ def _get_tool_calls_from_message(self, message: Message) -> list[FunctionCallCon ) return tool_calls - - @override - def _reset_function_choice_settings(self, settings: "PromptExecutionSettings") -> None: - if hasattr(settings, "tool_choice"): - settings.tool_choice = None - if hasattr(settings, "tools"): - settings.tools = None diff --git a/python/semantic_kernel/connectors/ai/anthropic/services/utils.py b/python/semantic_kernel/connectors/ai/anthropic/services/utils.py index 774d93615927..31acecb0468f 100644 --- a/python/semantic_kernel/connectors/ai/anthropic/services/utils.py +++ b/python/semantic_kernel/connectors/ai/anthropic/services/utils.py @@ -5,11 +5,15 @@ from collections.abc import Callable, Mapping from typing import Any +from semantic_kernel.connectors.ai.function_call_choice_configuration import FunctionCallChoiceConfiguration +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceType +from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.function_call_content import FunctionCallContent from semantic_kernel.contents.function_result_content import FunctionResultContent from semantic_kernel.contents.text_content import TextContent from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata logger: logging.Logger = logging.getLogger(__name__) @@ -50,29 +54,32 @@ def _format_assistant_message(message: ChatMessageContent) -> dict[str, Any]: "type": "tool_use", "id": item.id or "", "name": item.name or "", - "input": item.arguments if isinstance(item.arguments, Mapping) else json.loads(item.arguments or ""), + "input": item.arguments + if isinstance(item.arguments, Mapping) + else json.loads(item.arguments) + if item.arguments + else {}, }) else: logger.warning( f"Unsupported item type in Assistant message while formatting chat history for Anthropic: {type(item)}" ) + formatted_message: dict[str, Any] = {"role": "assistant", "content": []} + + if message.content: + # Only include the text content if it is not empty. + # Otherwise, the Anthropic client will throw an error. + formatted_message["content"].append({ # type: ignore + "type": "text", + "text": message.content, + }) if tool_calls: - return { - "role": "assistant", - "content": [ - { - "type": "text", - "text": message.content, - }, - *tool_calls, - ], - } + # Only include the tool calls if there are any. + # Otherwise, the Anthropic client will throw an error. + formatted_message["content"].extend(tool_calls) # type: ignore - return { - "role": "assistant", - "content": message.content, - } + return formatted_message def _format_tool_message(message: ChatMessageContent) -> dict[str, Any]: @@ -108,3 +115,40 @@ def _format_tool_message(message: ChatMessageContent) -> dict[str, Any]: AuthorRole.ASSISTANT: _format_assistant_message, AuthorRole.TOOL: _format_tool_message, } + + +def update_settings_from_function_call_configuration( + function_choice_configuration: FunctionCallChoiceConfiguration, + settings: PromptExecutionSettings, + type: FunctionChoiceType, +) -> None: + """Update the settings from a FunctionChoiceConfiguration.""" + if ( + function_choice_configuration.available_functions + and hasattr(settings, "tools") + and hasattr(settings, "tool_choice") + ): + settings.tools = [ + kernel_function_metadata_to_function_call_format(f) + for f in function_choice_configuration.available_functions + ] + + if ( + settings.function_choice_behavior and settings.function_choice_behavior.type_ == FunctionChoiceType.REQUIRED + ) or type == FunctionChoiceType.REQUIRED: + settings.tool_choice = {"type": "any"} + else: + settings.tool_choice = {"type": type.value} + + +def kernel_function_metadata_to_function_call_format(metadata: KernelFunctionMetadata) -> dict[str, Any]: + """Convert the kernel function metadata to function calling format.""" + return { + "name": metadata.fully_qualified_name, + "description": metadata.description or "", + "input_schema": { + "type": "object", + "properties": {p.name: p.schema_data for p in metadata.parameters}, + "required": [p.name for p in metadata.parameters if p.is_required], + }, + } diff --git a/python/tests/integration/completions/chat_completion_test_base.py b/python/tests/integration/completions/chat_completion_test_base.py index 61152512ae11..a31882951c9b 100644 --- a/python/tests/integration/completions/chat_completion_test_base.py +++ b/python/tests/integration/completions/chat_completion_test_base.py @@ -66,9 +66,7 @@ onnx_setup: bool = is_service_setup_for_testing( ["ONNX_GEN_AI_CHAT_MODEL_FOLDER"], raise_if_not_set=False ) # Tests are optional for ONNX -anthropic_setup: bool = is_service_setup_for_testing( - ["ANTHROPIC_API_KEY", "ANTHROPIC_CHAT_MODEL_ID"], raise_if_not_set=False -) # We don't have an Anthropic deployment +anthropic_setup: bool = is_service_setup_for_testing(["ANTHROPIC_API_KEY", "ANTHROPIC_CHAT_MODEL_ID"]) # When testing Bedrock, after logging into AWS CLI this has been set, so we can use it to check if the service is setup bedrock_setup: bool = is_service_setup_for_testing(["AWS_DEFAULT_REGION"], raise_if_not_set=False) diff --git a/python/tests/unit/connectors/ai/anthropic/conftest.py b/python/tests/unit/connectors/ai/anthropic/conftest.py new file mode 100644 index 000000000000..dc7d54cae463 --- /dev/null +++ b/python/tests/unit/connectors/ai/anthropic/conftest.py @@ -0,0 +1,400 @@ +# Copyright (c) Microsoft. All rights reserved. +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock + +import pytest +from anthropic import AsyncAnthropic +from anthropic.lib.streaming import TextEvent +from anthropic.lib.streaming._types import InputJsonEvent +from anthropic.types import ( + ContentBlockStopEvent, + InputJSONDelta, + Message, + MessageDeltaUsage, + MessageStopEvent, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawMessageDeltaEvent, + RawMessageStartEvent, + TextBlock, + TextDelta, + ToolUseBlock, + Usage, +) +from anthropic.types.raw_message_delta_event import Delta + +from semantic_kernel.connectors.ai.anthropic.prompt_execution_settings.anthropic_prompt_execution_settings import ( + AnthropicChatPromptExecutionSettings, +) +from semantic_kernel.contents.chat_message_content import ( + ChatMessageContent, + FunctionCallContent, + FunctionResultContent, + TextContent, +) +from semantic_kernel.contents.const import ContentTypes +from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent, StreamingTextContent +from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.contents.utils.finish_reason import FinishReason + + +@pytest.fixture +def mock_tool_calls_message() -> ChatMessageContent: + return ChatMessageContent( + ai_model_id="claude-3-opus-20240229", + metadata={}, + content_type="message", + role=AuthorRole.ASSISTANT, + name=None, + items=[ + TextContent( + inner_content=None, + ai_model_id=None, + metadata={}, + content_type="text", + text="", + encoding=None, + ), + FunctionCallContent( + inner_content=None, + ai_model_id=None, + metadata={}, + content_type=ContentTypes.FUNCTION_CALL_CONTENT, + id="test_function_call_content", + index=1, + name="math-Add", + function_name="Add", + plugin_name="math", + arguments={"input": 3, "amount": 3}, + ), + ], + encoding=None, + finish_reason=FinishReason.TOOL_CALLS, + ) + + +@pytest.fixture +def mock_parallel_tool_calls_message() -> ChatMessageContent: + return ChatMessageContent( + ai_model_id="claude-3-opus-20240229", + metadata={}, + content_type="message", + role=AuthorRole.ASSISTANT, + name=None, + items=[ + TextContent( + inner_content=None, + ai_model_id=None, + metadata={}, + content_type="text", + text="", + encoding=None, + ), + FunctionCallContent( + inner_content=None, + ai_model_id=None, + metadata={}, + content_type=ContentTypes.FUNCTION_CALL_CONTENT, + id="test_function_call_content_1", + index=1, + name="math-Add", + function_name="Add", + plugin_name="math", + arguments={"input": 3, "amount": 3}, + ), + FunctionCallContent( + inner_content=None, + ai_model_id=None, + metadata={}, + content_type=ContentTypes.FUNCTION_CALL_CONTENT, + id="test_function_call_content_2", + index=1, + name="math-Subtract", + function_name="Subtract", + plugin_name="math", + arguments={"input": 6, "amount": 3}, + ), + ], + encoding=None, + finish_reason=FinishReason.TOOL_CALLS, + ) + + +@pytest.fixture +def mock_streaming_tool_calls_message() -> list: + stream_events = [ + RawMessageStartEvent( + message=Message( + id="test_message_id", + content=[], + model="claude-3-opus-20240229", + role="assistant", + stop_reason=None, + stop_sequence=None, + type="message", + usage=Usage(input_tokens=1720, output_tokens=2), + ), + type="message_start", + ), + RawContentBlockStartEvent(content_block=TextBlock(text="", type="text"), index=0, type="content_block_start"), + RawContentBlockDeltaEvent( + delta=TextDelta(text="", type="text_delta"), index=0, type="content_block_delta" + ), + TextEvent(type="text", text="", snapshot=""), + RawContentBlockDeltaEvent( + delta=TextDelta(text="", type="text_delta"), index=0, type="content_block_delta" + ), + TextEvent(type="text", text="", snapshot=""), + ContentBlockStopEvent( + index=0, type="content_block_stop", content_block=TextBlock(text="", type="text") + ), + RawContentBlockStartEvent( + content_block=ToolUseBlock(id="test_tool_use_message_id", input={}, name="math-Add", type="tool_use"), + index=1, + type="content_block_start", + ), + RawContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json='{"input": 3, "amount": 3}', type="input_json_delta"), + index=1, + type="content_block_delta", + ), + InputJsonEvent(type="input_json", partial_json='{"input": 3, "amount": 3}', snapshot={"input": 3, "amount": 3}), + ContentBlockStopEvent( + index=1, + type="content_block_stop", + content_block=ToolUseBlock( + id="test_tool_use_block_id", input={"input": 3, "amount": 3}, name="math-Add", type="tool_use" + ), + ), + RawMessageDeltaEvent( + delta=Delta(stop_reason="tool_use", stop_sequence=None), + type="message_delta", + usage=MessageDeltaUsage(output_tokens=159), + ), + MessageStopEvent( + type="message_stop", + message=Message( + id="test_message_id", + content=[ + TextBlock(text="", type="text"), + ToolUseBlock( + id="test_tool_use_block_id", input={"input": 3, "amount": 3}, name="math-Add", type="tool_use" + ), + ], + model="claude-3-opus-20240229", + role="assistant", + stop_reason="tool_use", + stop_sequence=None, + type="message", + usage=Usage(input_tokens=100, output_tokens=100), + ), + ), + ] + + async def async_generator(): + for event in stream_events: + yield event + + stream_mock = AsyncMock() + stream_mock.__aenter__.return_value = async_generator() + + return stream_mock + + +@pytest.fixture +def mock_tool_call_result_message() -> ChatMessageContent: + return ChatMessageContent( + inner_content=None, + ai_model_id=None, + metadata={}, + content_type="message", + role=AuthorRole.TOOL, + name=None, + items=[ + FunctionResultContent( + id="test_function_call_content", + result=6, + ) + ], + encoding=None, + finish_reason=FinishReason.TOOL_CALLS, + ) + + +@pytest.fixture +def mock_parallel_tool_call_result_message() -> ChatMessageContent: + return ChatMessageContent( + inner_content=None, + ai_model_id=None, + metadata={}, + content_type="message", + role=AuthorRole.TOOL, + name=None, + items=[ + FunctionResultContent( + id="test_function_call_content_1", + result=6, + ), + FunctionResultContent( + id="test_function_call_content_2", + result=3, + ), + ], + encoding=None, + finish_reason=FinishReason.TOOL_CALLS, + ) + + +@pytest.fixture +def mock_streaming_chat_message_content() -> StreamingChatMessageContent: + return StreamingChatMessageContent( + choice_index=0, + ai_model_id="claude-3-opus-20240229", + metadata={}, + role=AuthorRole.ASSISTANT, + name=None, + items=[ + StreamingTextContent( + inner_content=None, + ai_model_id=None, + metadata={}, + content_type="text", + text="", + encoding=None, + choice_index=0, + ), + FunctionCallContent( + inner_content=None, + ai_model_id=None, + metadata={}, + content_type=ContentTypes.FUNCTION_CALL_CONTENT, + id="tool_id", + index=0, + name="math-Add", + function_name="Add", + plugin_name="math", + arguments='{"input": 3, "amount": 3}', + ), + ], + encoding=None, + finish_reason=FinishReason.TOOL_CALLS, + ) + + +@pytest.fixture +def mock_settings() -> AnthropicChatPromptExecutionSettings: + return AnthropicChatPromptExecutionSettings() + + +@pytest.fixture +def mock_chat_message_response() -> Message: + return Message( + id="test_message_id", + content=[TextBlock(text="Hello, how are you?", type="text")], + model="claude-3-opus-20240229", + role="assistant", + stop_reason="end_turn", + stop_sequence=None, + type="message", + usage=Usage(input_tokens=10, output_tokens=10), + ) + + +@pytest.fixture +def mock_streaming_message_response() -> AsyncGenerator: + raw_message_start_event = RawMessageStartEvent( + message=Message( + id="test_message_id", + content=[], + model="claude-3-opus-20240229", + role="assistant", + stop_reason=None, + stop_sequence=None, + type="message", + usage=Usage(input_tokens=41, output_tokens=3), + ), + type="message_start", + ) + + raw_content_block_start_event = RawContentBlockStartEvent( + content_block=TextBlock(text="", type="text"), + index=0, + type="content_block_start", + ) + + raw_content_block_delta_event = RawContentBlockDeltaEvent( + delta=TextDelta(text="Hello! It", type="text_delta"), + index=0, + type="content_block_delta", + ) + + text_event = TextEvent( + type="text", + text="Hello! It", + snapshot="Hello! It", + ) + + content_block_stop_event = ContentBlockStopEvent( + index=0, + type="content_block_stop", + content_block=TextBlock(text="Hello! It's nice to meet you.", type="text"), + ) + + raw_message_delta_event = RawMessageDeltaEvent( + delta=Delta(stop_reason="end_turn", stop_sequence=None), + type="message_delta", + usage=MessageDeltaUsage(output_tokens=84), + ) + + message_stop_event = MessageStopEvent( + type="message_stop", + message=Message( + id="test_message_stop_id", + content=[TextBlock(text="Hello! It's nice to meet you.", type="text")], + model="claude-3-opus-20240229", + role="assistant", + stop_reason="end_turn", + stop_sequence=None, + type="message", + usage=Usage(input_tokens=41, output_tokens=84), + ), + ) + + # Combine all mock events into a list + stream_events = [ + raw_message_start_event, + raw_content_block_start_event, + raw_content_block_delta_event, + text_event, + content_block_stop_event, + raw_message_delta_event, + message_stop_event, + ] + + async def async_generator(): + for event in stream_events: + yield event + + # Create an AsyncMock for the stream + stream_mock = AsyncMock() + stream_mock.__aenter__.return_value = async_generator() + + return stream_mock + + +@pytest.fixture +def mock_anthropic_client_completion(mock_chat_message_response: Message) -> AsyncAnthropic: + client = MagicMock(spec=AsyncAnthropic) + messages_mock = MagicMock() + messages_mock.create = AsyncMock(return_value=mock_chat_message_response) + client.messages = messages_mock + return client + + +@pytest.fixture +def mock_anthropic_client_completion_stream(mock_streaming_message_response: AsyncGenerator) -> AsyncAnthropic: + client = MagicMock(spec=AsyncAnthropic) + messages_mock = MagicMock() + messages_mock.stream.return_value = mock_streaming_message_response + client.messages = messages_mock + return client diff --git a/python/tests/unit/connectors/ai/anthropic/services/test_anthropic_chat_completion.py b/python/tests/unit/connectors/ai/anthropic/services/test_anthropic_chat_completion.py index d368dd901c4d..bff83bfe89d6 100644 --- a/python/tests/unit/connectors/ai/anthropic/services/test_anthropic_chat_completion.py +++ b/python/tests/unit/connectors/ai/anthropic/services/test_anthropic_chat_completion.py @@ -1,27 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, MagicMock, patch import pytest from anthropic import AsyncAnthropic -from anthropic.lib.streaming import TextEvent -from anthropic.lib.streaming._types import InputJsonEvent -from anthropic.types import ( - ContentBlockStopEvent, - InputJSONDelta, - Message, - MessageDeltaUsage, - MessageStopEvent, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - TextBlock, - TextDelta, - ToolUseBlock, - Usage, -) -from anthropic.types.raw_message_delta_event import Delta +from anthropic.types import Message from semantic_kernel.connectors.ai.anthropic.prompt_execution_settings.anthropic_prompt_execution_settings import ( AnthropicChatPromptExecutionSettings, @@ -33,406 +15,20 @@ OpenAIChatPromptExecutionSettings, ) from semantic_kernel.contents.chat_history import ChatHistory -from semantic_kernel.contents.chat_message_content import ( - ChatMessageContent, - FunctionCallContent, - FunctionResultContent, - TextContent, -) -from semantic_kernel.contents.const import ContentTypes -from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent, StreamingTextContent +from semantic_kernel.contents.chat_message_content import ChatMessageContent, FunctionCallContent, TextContent +from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole -from semantic_kernel.contents.utils.finish_reason import FinishReason from semantic_kernel.exceptions import ( ServiceInitializationError, ServiceInvalidExecutionSettingsError, ServiceResponseException, ) -from semantic_kernel.functions.function_result import FunctionResult +from semantic_kernel.exceptions.service_exceptions import ServiceInvalidRequestError from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.functions.kernel_function_decorator import kernel_function -from semantic_kernel.functions.kernel_function_from_method import KernelFunctionMetadata -from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata from semantic_kernel.kernel import Kernel -@pytest.fixture -def mock_tool_calls_message() -> ChatMessageContent: - return ChatMessageContent( - inner_content=Message( - id="test_message_id", - content=[ - TextBlock(text="", type="text"), - ToolUseBlock( - id="test_tool_use_blocks", - input={"input": 3, "amount": 3}, - name="math-Add", - type="tool_use", - ), - ], - model="claude-3-opus-20240229", - role="assistant", - stop_reason="tool_use", - stop_sequence=None, - type="message", - usage=Usage(input_tokens=1720, output_tokens=194), - ), - ai_model_id="claude-3-opus-20240229", - metadata={}, - content_type="message", - role=AuthorRole.ASSISTANT, - name=None, - items=[ - FunctionCallContent( - inner_content=None, - ai_model_id=None, - metadata={}, - content_type=ContentTypes.FUNCTION_CALL_CONTENT, - id="test_function_call_content", - index=1, - name="math-Add", - function_name="Add", - plugin_name="math", - arguments={"input": 3, "amount": 3}, - ), - TextContent( - inner_content=None, - ai_model_id=None, - metadata={}, - content_type="text", - text="", - encoding=None, - ), - ], - encoding=None, - finish_reason=FinishReason.TOOL_CALLS, - ) - - -@pytest.fixture -def mock_streaming_tool_calls_message() -> list: - stream_events = [ - RawMessageStartEvent( - message=Message( - id="test_message_id", - content=[], - model="claude-3-opus-20240229", - role="assistant", - stop_reason=None, - stop_sequence=None, - type="message", - usage=Usage(input_tokens=1720, output_tokens=2), - ), - type="message_start", - ), - RawContentBlockStartEvent(content_block=TextBlock(text="", type="text"), index=0, type="content_block_start"), - RawContentBlockDeltaEvent( - delta=TextDelta(text="", type="text_delta"), index=0, type="content_block_delta" - ), - TextEvent(type="text", text="", snapshot=""), - RawContentBlockDeltaEvent( - delta=TextDelta(text="", type="text_delta"), index=0, type="content_block_delta" - ), - TextEvent(type="text", text="", snapshot=""), - ContentBlockStopEvent( - index=0, type="content_block_stop", content_block=TextBlock(text="", type="text") - ), - RawContentBlockStartEvent( - content_block=ToolUseBlock(id="test_tool_use_message_id", input={}, name="math-Add", type="tool_use"), - index=1, - type="content_block_start", - ), - RawContentBlockDeltaEvent( - delta=InputJSONDelta(partial_json='{"input": 3, "amount": 3}', type="input_json_delta"), - index=1, - type="content_block_delta", - ), - InputJsonEvent(type="input_json", partial_json='{"input": 3, "amount": 3}', snapshot={"input": 3, "amount": 3}), - ContentBlockStopEvent( - index=1, - type="content_block_stop", - content_block=ToolUseBlock( - id="test_tool_use_block_id", input={"input": 3, "amount": 3}, name="math-Add", type="tool_use" - ), - ), - RawMessageDeltaEvent( - delta=Delta(stop_reason="tool_use", stop_sequence=None), - type="message_delta", - usage=MessageDeltaUsage(output_tokens=159), - ), - MessageStopEvent( - type="message_stop", - message=Message( - id="test_message_id", - content=[ - TextBlock(text="", type="text"), - ToolUseBlock( - id="test_tool_use_block_id", input={"input": 3, "amount": 3}, name="math-Add", type="tool_use" - ), - ], - model="claude-3-opus-20240229", - role="assistant", - stop_reason="tool_use", - stop_sequence=None, - type="message", - usage=Usage(input_tokens=100, output_tokens=100), - ), - ), - ] - - async def async_generator(): - for event in stream_events: - yield event - - stream_mock = AsyncMock() - stream_mock.__aenter__.return_value = async_generator() - - return stream_mock - - -@pytest.fixture -def mock_tool_call_result_message() -> ChatMessageContent: - return ChatMessageContent( - inner_content=None, - ai_model_id=None, - metadata={}, - content_type="message", - role=AuthorRole.TOOL, - name=None, - items=[ - FunctionResultContent( - id="tool_01", - inner_content=FunctionResult( - function=KernelFunctionMetadata( - name="Add", - plugin_name="math", - description="Returns the Addition result of the values provided.", - parameters=[ - KernelParameterMetadata( - name="input", - description="the first number to add", - default_value=None, - type_="int", - is_required=True, - type_object=int, - schema_data={"type": "integer", "description": "the first number to add"}, - function_schema_include=True, - ), - KernelParameterMetadata( - name="amount", - description="the second number to add", - default_value=None, - type_="int", - is_required=True, - type_object=int, - schema_data={"type": "integer", "description": "the second number to add"}, - function_schema_include=True, - ), - ], - is_prompt=False, - is_asynchronous=False, - return_parameter=KernelParameterMetadata( - name="return", - description="the output is a number", - default_value=None, - type_="int", - is_required=True, - type_object=int, - schema_data={"type": "integer", "description": "the output is a number"}, - function_schema_include=True, - ), - additional_properties={}, - ), - value=6, - metadata={}, - ), - value=6, - ) - ], - encoding=None, - finish_reason=FinishReason.TOOL_CALLS, - ) - - -# mock StreamingChatMessageContent -@pytest.fixture -def mock_streaming_chat_message_content() -> StreamingChatMessageContent: - return StreamingChatMessageContent( - choice_index=0, - inner_content=[ - RawContentBlockDeltaEvent( - delta=TextDelta(text="", type="text_delta"), index=0, type="content_block_delta" - ), - RawContentBlockDeltaEvent( - delta=TextDelta(text="", type="text_delta"), index=0, type="content_block_delta" - ), - ContentBlockStopEvent( - index=1, - type="content_block_stop", - content_block=ToolUseBlock( - id="tool_id", - input={"input": 3, "amount": 3}, - name="math-Add", - type="tool_use", - ), - ), - RawMessageDeltaEvent( - delta=Delta(stop_reason="tool_use", stop_sequence=None), - type="message_delta", - usage=MessageDeltaUsage(output_tokens=175), - ), - ], - ai_model_id="claude-3-opus-20240229", - metadata={}, - role=AuthorRole.ASSISTANT, - name=None, - items=[ - StreamingTextContent( - inner_content=None, - ai_model_id=None, - metadata={}, - content_type="text", - text="", - encoding=None, - choice_index=0, - ), - FunctionCallContent( - inner_content=None, - ai_model_id=None, - metadata={}, - content_type=ContentTypes.FUNCTION_CALL_CONTENT, - id="tool_id", - index=0, - name="math-Add", - function_name="Add", - plugin_name="math", - arguments='{"input": 3, "amount": 3}', - ), - ], - encoding=None, - finish_reason=FinishReason.TOOL_CALLS, - ) - - -@pytest.fixture -def mock_settings() -> AnthropicChatPromptExecutionSettings: - return AnthropicChatPromptExecutionSettings() - - -@pytest.fixture -def mock_chat_message_response() -> Message: - return Message( - id="test_message_id", - content=[TextBlock(text="Hello, how are you?", type="text")], - model="claude-3-opus-20240229", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - type="message", - usage=Usage(input_tokens=10, output_tokens=10), - ) - - -@pytest.fixture -def mock_streaming_message_response() -> AsyncGenerator: - raw_message_start_event = RawMessageStartEvent( - message=Message( - id="test_message_id", - content=[], - model="claude-3-opus-20240229", - role="assistant", - stop_reason=None, - stop_sequence=None, - type="message", - usage=Usage(input_tokens=41, output_tokens=3), - ), - type="message_start", - ) - - raw_content_block_start_event = RawContentBlockStartEvent( - content_block=TextBlock(text="", type="text"), - index=0, - type="content_block_start", - ) - - raw_content_block_delta_event = RawContentBlockDeltaEvent( - delta=TextDelta(text="Hello! It", type="text_delta"), - index=0, - type="content_block_delta", - ) - - text_event = TextEvent( - type="text", - text="Hello! It", - snapshot="Hello! It", - ) - - content_block_stop_event = ContentBlockStopEvent( - index=0, - type="content_block_stop", - content_block=TextBlock(text="Hello! It's nice to meet you.", type="text"), - ) - - raw_message_delta_event = RawMessageDeltaEvent( - delta=Delta(stop_reason="end_turn", stop_sequence=None), - type="message_delta", - usage=MessageDeltaUsage(output_tokens=84), - ) - - message_stop_event = MessageStopEvent( - type="message_stop", - message=Message( - id="test_message_stop_id", - content=[TextBlock(text="Hello! It's nice to meet you.", type="text")], - model="claude-3-opus-20240229", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - type="message", - usage=Usage(input_tokens=41, output_tokens=84), - ), - ) - - # Combine all mock events into a list - stream_events = [ - raw_message_start_event, - raw_content_block_start_event, - raw_content_block_delta_event, - text_event, - content_block_stop_event, - raw_message_delta_event, - message_stop_event, - ] - - async def async_generator(): - for event in stream_events: - yield event - - # Create an AsyncMock for the stream - stream_mock = AsyncMock() - stream_mock.__aenter__.return_value = async_generator() - - return stream_mock - - -@pytest.fixture -def mock_anthropic_client_completion(mock_chat_message_response: Message) -> AsyncAnthropic: - client = MagicMock(spec=AsyncAnthropic) - messages_mock = MagicMock() - messages_mock.create = AsyncMock(return_value=mock_chat_message_response) - client.messages = messages_mock - return client - - -@pytest.fixture -def mock_anthropic_client_completion_stream(mock_streaming_message_response: AsyncGenerator) -> AsyncAnthropic: - client = MagicMock(spec=AsyncAnthropic) - messages_mock = MagicMock() - messages_mock.stream.return_value = mock_streaming_message_response - client.messages = messages_mock - return client - - async def test_complete_chat_contents( kernel: Kernel, mock_settings: AnthropicChatPromptExecutionSettings, @@ -753,7 +349,7 @@ async def test_prepare_chat_history_for_request_with_system_message(mock_anthrop assert system_message_content == "System message" assert remaining_messages == [ {"role": AuthorRole.USER, "content": "User message"}, - {"role": AuthorRole.ASSISTANT, "content": "Assistant message"}, + {"role": AuthorRole.ASSISTANT, "content": [{"type": "text", "text": "Assistant message"}]}, ] assert not any(msg["role"] == AuthorRole.SYSTEM for msg in remaining_messages) @@ -780,35 +376,121 @@ async def test_prepare_chat_history_for_request_with_tool_message( ) assert system_message_content is None - assert len(remaining_messages) == 3 + assert remaining_messages == [ + {"role": AuthorRole.USER, "content": "What is 3+3?"}, + { + "role": AuthorRole.ASSISTANT, + "content": [ + {"type": "text", "text": mock_tool_calls_message.items[0].text}, + { + "type": "tool_use", + "id": mock_tool_calls_message.items[1].id, + "name": mock_tool_calls_message.items[1].name, + "input": mock_tool_calls_message.items[1].arguments, + }, + ], + }, + { + "role": AuthorRole.USER, + "content": [ + { + "type": "tool_result", + "tool_use_id": mock_tool_call_result_message.items[0].id, + "content": str(mock_tool_call_result_message.items[0].result), + } + ], + }, + ] -async def test_prepare_chat_history_for_request_with_tool_message_streaming( +async def test_prepare_chat_history_for_request_with_parallel_tool_message( + mock_anthropic_client_completion_stream: MagicMock, + mock_parallel_tool_calls_message: ChatMessageContent, + mock_parallel_tool_call_result_message: ChatMessageContent, +): + chat_history = ChatHistory() + chat_history.add_user_message("What is 3+3?") + chat_history.add_message(mock_parallel_tool_calls_message) + chat_history.add_message(mock_parallel_tool_call_result_message) + + chat_completion_client = AnthropicChatCompletion( + ai_model_id="test_model_id", + service_id="test", + api_key="", + async_client=mock_anthropic_client_completion_stream, + ) + + remaining_messages, system_message_content = chat_completion_client._prepare_chat_history_for_request( + chat_history, role_key="role", content_key="content" + ) + + assert system_message_content is None + assert remaining_messages == [ + {"role": AuthorRole.USER, "content": "What is 3+3?"}, + { + "role": AuthorRole.ASSISTANT, + "content": [ + {"type": "text", "text": mock_parallel_tool_calls_message.items[0].text}, + *[ + { + "type": "tool_use", + "id": function_call_content.id, + "name": function_call_content.name, + "input": function_call_content.arguments, + } + for function_call_content in mock_parallel_tool_calls_message.items[1:] + ], + ], + }, + { + "role": AuthorRole.USER, + "content": [ + { + "type": "tool_result", + "tool_use_id": function_result_content.id, + "content": str(function_result_content.result), + } + for function_result_content in mock_parallel_tool_call_result_message.items + ], + }, + ] + + +async def test_prepare_chat_history_for_request_with_tool_message_right_after_user_message( mock_anthropic_client_completion_stream: MagicMock, - mock_streaming_chat_message_content: StreamingChatMessageContent, mock_tool_call_result_message: ChatMessageContent, ): chat_history = ChatHistory() chat_history.add_user_message("What is 3+3?") - chat_history.add_message(mock_streaming_chat_message_content) chat_history.add_message(mock_tool_call_result_message) - chat_completion = AnthropicChatCompletion( + chat_completion_client = AnthropicChatCompletion( ai_model_id="test_model_id", service_id="test", api_key="", async_client=mock_anthropic_client_completion_stream, ) - remaining_messages, system_message_content = chat_completion._prepare_chat_history_for_request( - chat_history, - role_key="role", - content_key="content", - stream=True, + with pytest.raises(ServiceInvalidRequestError, match="Tool message found after a user or system message."): + chat_completion_client._prepare_chat_history_for_request(chat_history, role_key="role", content_key="content") + + +async def test_prepare_chat_history_for_request_with_tool_message_as_the_first_message( + mock_anthropic_client_completion_stream: MagicMock, + mock_tool_call_result_message: ChatMessageContent, +): + chat_history = ChatHistory() + chat_history.add_message(mock_tool_call_result_message) + + chat_completion_client = AnthropicChatCompletion( + ai_model_id="test_model_id", + service_id="test", + api_key="", + async_client=mock_anthropic_client_completion_stream, ) - assert system_message_content is None - assert len(remaining_messages) == 3 + with pytest.raises(ServiceInvalidRequestError, match="Tool message found without a preceding message."): + chat_completion_client._prepare_chat_history_for_request(chat_history, role_key="role", content_key="content") async def test_send_chat_stream_request_tool_calls(